#!/usr/bin/env bash set -euo pipefail # Release-only updater for Docker deployment. # Updates pull the deployment bundle from Gitea and the Docker Image via 'docker pull'. PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_DIR="$PROJECT_DIR/logs" LOG_FILE="$LOG_DIR/update.log" STATE_FILE="$PROJECT_DIR/.release-version" REPO_SLUG="Invario/Inventarsystem" API_BASE_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG" BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz" ENV_FILE="$PROJECT_DIR/.docker-build.env" APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem" COMPOSE_FILE="docker-compose-multitenant.yml" MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}" MODE="stable" mkdir -p "$LOG_DIR" chmod 777 "$LOG_DIR" 2>/dev/null || true log_message() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then log_message "ERROR: Required command not found: $1" exit 1 fi } SUDO="" if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then SUDO="sudo" fi IS_ROOT="false" if [ "$(id -u)" -eq 0 ]; then IS_ROOT="true" fi apt_install() { $SUDO apt-get update -y $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" } ensure_runtime_dependencies() { local missing=() if ! command -v docker >/dev/null 2>&1; then missing+=(docker) fi if ! docker compose version >/dev/null 2>&1; then missing+=(docker-compose-v2) fi if ! command -v openssl >/dev/null 2>&1; then missing+=(openssl) fi if ! command -v curl >/dev/null 2>&1; then missing+=(curl) fi if ! command -v python3 >/dev/null 2>&1; then missing+=(python3) fi if [ "${#missing[@]}" -gt 0 ]; then if [ "$IS_ROOT" = "true" ]; then log_message "Installing missing dependencies: ${missing[*]}" apt_install "${missing[@]}" else log_message "ERROR: Missing dependencies: ${missing[*]}" log_message "Install the missing tools manually or re-run as root." exit 1 fi fi if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker >/dev/null 2>&1 || true fi } ensure_min_root_disk_space() { local available_kb available_mb if ! command -v df >/dev/null 2>&1; then return 0 fi available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)" if [ -z "$available_kb" ]; then return 0 fi available_mb=$((available_kb / 1024)) if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR" log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB" log_message "Free disk space and rerun update." exit 1 fi } cleanup_docker_dangling_images() { if docker image prune -f >> "$LOG_FILE" 2>&1; then log_message "Cleaned up dangling Docker images" else log_message "WARNING: Could not prune dangling Docker images" fi } usage() { cat <> "$LOG_FILE" 2>&1; then log_message "Log archival check completed" else log_message "WARNING: Log archival encountered issues; continuing" fi fi } create_backup() { log_message "Creating database backup before update..." if [ -x "$PROJECT_DIR/backup.sh" ]; then if "$PROJECT_DIR/backup.sh" --mode auto >> "$LOG_FILE" 2>&1; then log_message "Universal backup completed" return 0 else log_message "WARNING: Universal backup failed; trying legacy backup path" fi fi if [ -x "$PROJECT_DIR/backup.sh" ]; then if "$PROJECT_DIR/backup.sh" --mode auto >> "$LOG_FILE" 2>&1; then log_message "Backup completed" else log_message "WARNING: Backup failed; continuing with release update" fi else log_message "WARNING: backup.sh not found; skipping backup" fi } fetch_release_metadata() { local meta_file="$1" local endpoint="${2:-latest}" curl -fsSL "$API_BASE_URL/releases/$endpoint" -o "$meta_file" } parse_latest_tag() { local meta_file="$1" python3 - <<'PY' "$meta_file" import json, sys with open(sys.argv[1], 'r', encoding='utf-8') as f: data = json.load(f) print(data.get('tag_name', '').strip()) PY } parse_asset_url() { local meta_file="$1" local asset_name="$2" python3 - <<'PY' "$meta_file" "$asset_name" import json, sys meta_file, asset_name = sys.argv[1], sys.argv[2] with open(meta_file, 'r', encoding='utf-8') as f: data = json.load(f) for asset in data.get('assets', []): if asset.get('name') == asset_name: print(asset.get('browser_download_url', '').strip()) break PY } parse_release_tag() { local meta_file="$1" python3 - <<'PY' "$meta_file" import json, sys with open(sys.argv[1], 'r', encoding='utf-8') as f: data = json.load(f) print(data.get('tag_name', '').strip()) PY } strip_prerelease_suffix() { local tag="$1" python3 - <<'PY' "$tag" import re, sys tag = sys.argv[1].strip() match = re.match(r'^(v\d+\.\d+\.\d+)', tag) print(match.group(1) if match else tag) PY } compare_semver_tags() { local left_tag="$1" local right_tag="$2" python3 - <<'PY' "$left_tag" "$right_tag" import re, sys def parse(tag): match = re.match(r'^v(\d+)\.(\d+)\.(\d+)', tag.strip()) if not match: return None return tuple(int(part) for part in match.groups()) left = parse(sys.argv[1]) right = parse(sys.argv[2]) if left is None or right is None: print('unknown') elif left > right: print('gt') elif left < right: print('lt') else: print('eq') PY } parse_prerelease_flag() { local meta_file="$1" python3 - <<'PY' "$meta_file" import json, sys with open(sys.argv[1], 'r', encoding='utf-8') as f: data = json.load(f) print('true' if data.get('prerelease') else 'false') PY } latest_stable_release() { local meta_file="$1" local releases_file="$2" local release_count=0 if ! curl -fsSL "$API_BASE_URL/releases" -o "$releases_file"; then return 1 fi if ! python3 - <<'PY' "$releases_file" "$meta_file" import json, sys releases_path, target_path = sys.argv[1], sys.argv[2] with open(releases_path, 'r', encoding='utf-8') as f: releases = json.load(f) for release in releases: if not release.get('prerelease') and not release.get('draft'): with open(target_path, 'w', encoding='utf-8') as out: json.dump(release, out) raise SystemExit(0) raise SystemExit(1) PY then return 1 fi return 0 } latest_prerelease() { local meta_file="$1" local releases_file="$2" if ! curl -fsSL "$API_BASE_URL/releases" -o "$releases_file"; then return 1 fi if ! python3 - <<'PY' "$releases_file" "$meta_file" import json, sys releases_path, target_path = sys.argv[1], sys.argv[2] with open(releases_path, 'r', encoding='utf-8') as f: releases = json.load(f) for release in releases: if release.get('prerelease') and not release.get('draft'): with open(target_path, 'w', encoding='utf-8') as out: json.dump(release, out) raise SystemExit(0) raise SystemExit(1) PY then return 1 fi return 0 } fetch_latest_release_bundle() { local release_meta="$1" local bundle_url bundle_url="$(parse_asset_url "$release_meta" "$BUNDLE_ASSET")" if [ -z "$bundle_url" ]; then return 1 fi printf '%s' "$bundle_url" } refresh_runtime_scripts_from_main() { local start_url stop_url restart_url update_url start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh" stop_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/stop.sh" restart_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/restart.sh" update_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/update.sh" curl -fsSL "$start_url" -o "$PROJECT_DIR/start.sh" || log_message "WARNING: Could not refresh start.sh from main" curl -fsSL "$stop_url" -o "$PROJECT_DIR/stop.sh" || log_message "WARNING: Could not refresh stop.sh from main" curl -fsSL "$restart_url" -o "$PROJECT_DIR/restart.sh" || log_message "WARNING: Could not refresh restart.sh from main" curl -fsSL "$update_url" -o "$PROJECT_DIR/update.sh" || log_message "WARNING: Could not refresh update.sh from main" chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" 2>/dev/null || true } download_and_extract_bundle() { local url="$1" local tmp_dir="$2" local archive="$tmp_dir/$BUNDLE_ASSET" curl -fL "$url" -o "$archive" tar -xzf "$archive" -C "$tmp_dir" # The bundle must contain docker deployment files only. cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml" cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh" cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh" if [ -f "$tmp_dir/restart.sh" ]; then cp -f "$tmp_dir/restart.sh" "$PROJECT_DIR/restart.sh" fi if [ -f "$tmp_dir/update.sh" ]; then cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh" fi # Multi-Tenant Ressourcen kopieren, falls vorhanden if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml" fi if [ -f "$tmp_dir/manage-tenant.sh" ]; then cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh" fi if [ -f "$tmp_dir/run-tenant-cmd.sh" ]; then cp -f "$tmp_dir/run-tenant-cmd.sh" "$PROJECT_DIR/run-tenant-cmd.sh" fi for file in "MULTITENANT_DEPLOYMENT.md" "MULTITENANT_PYTHON_API.md"; do if [ -f "$tmp_dir/$file" ]; then cp -f "$tmp_dir/$file" "$PROJECT_DIR/$file" fi done # Ensure executable permissions on all copied scripts chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json" log_message "Installed default config.json from release bundle" fi } deploy() { local tag="$1" local app_image="${APP_IMAGE_REPO}:${tag}" local compose_path compose_path="$PROJECT_DIR/$COMPOSE_FILE" if [ ! -f "$compose_path" ]; then log_message "ERROR: compose file not found: $compose_path" exit 1 fi cd "$PROJECT_DIR" if [ ! -f "$ENV_FILE" ]; then cat > "$ENV_FILE" <> "$ENV_FILE" fi log_message "Pulling Gitea Registry image $app_image" if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then log_message "Falling back to local Docker build for $app_image" docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1 fi # Image wurde oben gepullt, Stack hochfahren docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true } verify_stack_health() { local compose_args running_services local http_port compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE") http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')" if [ -z "$http_port" ]; then http_port="10000" fi for _ in $(seq 1 60); do running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" if printf '%s\n' "$running_services" | grep -Fxq app && \ printf '%s\n' "$running_services" | grep -Fxq redis && \ printf '%s\n' "$running_services" | grep -Fxq mongodb; then # Primary check: health endpoint responds (most reliable) if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then return 0 fi fi sleep 2 done docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb >> "$LOG_FILE" 2>&1 || true return 1 } cleanup_server_space() { log_message "Running server cleanup before update..." # Remove unused Docker objects if docker system prune -af --volumes >> "$LOG_FILE" 2>&1; then log_message "Docker system pruned (all unused images, containers, volumes, networks)" else log_message "WARNING: Docker system prune failed" fi # Delete legacy dist folder if it exists if [ -d "$PROJECT_DIR/dist" ]; then rm -rf "$PROJECT_DIR/dist" || true log_message "Legacy dist folder removed" fi # Clean up old log files older than 7 days if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then log_message "Old log files (older than 7 days) cleaned up" else log_message "WARNING: Failed to clean up old log files" fi } main() { parse_args "$@" cleanup_server_space ensure_runtime_dependencies require_cmd curl require_cmd tar require_cmd docker require_cmd python3 ensure_min_root_disk_space archive_logs create_backup # If user requested a development install, deploy the latest prerelease. if [ "$MODE" = "development" ]; then log_message "Requested development install" local tag meta_file releases_file stable_meta_file stable_tag prerelease_tag prerelease_base_tag bundle_url app_image compose_path local tmp_dir tmp_dir="$(mktemp -d)" meta_file="$tmp_dir/prerelease.json" stable_meta_file="$tmp_dir/stable.json" releases_file="$tmp_dir/releases.json" trap 'rm -rf "${tmp_dir:-}"' EXIT if ! fetch_release_metadata "$stable_meta_file" "latest"; then log_message "ERROR: Could not fetch latest stable release metadata" exit 1 fi stable_tag="$(parse_release_tag "$stable_meta_file")" if [ -z "$stable_tag" ]; then log_message "ERROR: Could not determine latest stable release tag" exit 1 fi if latest_prerelease "$meta_file" "$releases_file"; then prerelease_tag="$(parse_release_tag "$meta_file")" prerelease_base_tag="$(strip_prerelease_suffix "$prerelease_tag")" else prerelease_tag="" prerelease_base_tag="" fi if [ -n "$prerelease_tag" ]; then case "$(compare_semver_tags "$stable_tag" "$prerelease_base_tag")" in gt|eq) log_message "Latest stable release ($stable_tag) supersedes prerelease ($prerelease_tag); using stable release" tag="$stable_tag" meta_file="$stable_meta_file" ;; lt) tag="$prerelease_tag" ;; *) log_message "WARNING: Could not compare stable and prerelease versions; preferring prerelease when available" tag="$prerelease_tag" ;; esac else log_message "No prerelease found; falling back to stable release $stable_tag" tag="$stable_tag" meta_file="$stable_meta_file" fi bundle_url="$(fetch_latest_release_bundle "$meta_file")" if [ -z "$bundle_url" ]; then log_message "ERROR: Release asset not found: $BUNDLE_ASSET" exit 1 fi app_image="$APP_IMAGE_REPO:$tag" compose_path="$PROJECT_DIR/$COMPOSE_FILE" if [ ! -f "$compose_path" ]; then log_message "ERROR: compose file not found: $compose_path" exit 1 fi # Ensure ENV_FILE contains the development image if [ ! -f "$ENV_FILE" ]; then cat > "$ENV_FILE" <> "$ENV_FILE" fi log_message "Attempting to pull development image $app_image" if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then log_message "ERROR: Could not obtain development image $app_image" exit 1 fi download_and_extract_bundle "$bundle_url" "$tmp_dir" refresh_runtime_scripts_from_main # Bring up stack docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 if ! verify_stack_health; then log_message "ERROR: Development deployment failed health check" exit 1 fi echo "$tag" > "$STATE_FILE" log_message "Development update completed successfully to prerelease $tag" exit 0 fi local tmp_dir meta_file latest_tag current_tag bundle_url tmp_dir="$(mktemp -d)" meta_file="$tmp_dir/release.json" trap 'rm -rf "${tmp_dir:-}"' EXIT log_message "Checking latest stable Gitea release for $REPO_SLUG..." if ! fetch_release_metadata "$meta_file" "latest"; then log_message "WARNING: Could not fetch release metadata. Falling back to self-healing start path." if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then if verify_stack_health; then log_message "Fallback deployment completed" else log_message "ERROR: Fallback deployment failed health check" exit 1 fi else log_message "ERROR: Fallback start path failed" exit 1 fi exit 0 fi latest_tag="$(parse_release_tag "$meta_file")" if [ -z "$latest_tag" ]; then log_message "WARNING: Could not determine latest release tag. Falling back to self-healing start path." if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then if verify_stack_health; then log_message "Fallback deployment completed" else log_message "ERROR: Fallback deployment failed health check" exit 1 fi else log_message "ERROR: Fallback start path failed" exit 1 fi exit 0 fi current_tag="" if [ -f "$STATE_FILE" ]; then current_tag="$(cat "$STATE_FILE")" fi if [ "$current_tag" = "$latest_tag" ]; then log_message "Already on latest release ($latest_tag). Refreshing containers from prebuilt image." deploy "$latest_tag" if verify_stack_health; then log_message "Container refresh completed" else log_message "ERROR: Container refresh failed health check" exit 1 fi exit 0 fi bundle_url="$(parse_asset_url "$meta_file" "$BUNDLE_ASSET")" if [ -z "$bundle_url" ]; then log_message "WARNING: Release asset not found: $BUNDLE_ASSET. Falling back to self-healing start path." if INVENTAR_SETUP_CRON=0 bash "$PROJECT_DIR/start.sh" >> "$LOG_FILE" 2>&1; then if verify_stack_health; then log_message "Image-only deployment completed" else log_message "ERROR: Image-only fallback failed health check" exit 1 fi else log_message "ERROR: Fallback start path failed" exit 1 fi exit 0 fi log_message "Updating from release $latest_tag" download_and_extract_bundle "$bundle_url" "$tmp_dir" refresh_runtime_scripts_from_main deploy "$latest_tag" if ! verify_stack_health; then log_message "ERROR: Updated stack failed health check" exit 1 fi echo "$latest_tag" > "$STATE_FILE" cleanup_docker_dangling_images log_message "Update completed successfully to release $latest_tag" if [ -x "./opt/Inventarsystem/restart.sh" ]; then sudo ./opt/Inventarsystem/restart.sh fi echo "Restart of the Server Completed" } main "$@"