#!/usr/bin/env bash set -euo pipefail # Release-only updater for Docker deployment. # Updates are pulled exclusively from Gitea Releases assets. 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_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest" BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz" APP_IMAGE_ASSET_PREFIX="inventarsystem-image-" ENV_FILE="$PROJECT_DIR/.docker-build.env" APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem" DIST_DIR="$PROJECT_DIR/dist" COMPOSE_FILE="docker-compose-multitenant.yml" MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}" DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}" MODE="release" 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_old_dist_artifacts() { local keep_count keep_count="$DIST_KEEP_COUNT" if [ ! -d "$DIST_DIR" ]; then return 0 fi if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then keep_count=2 fi mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}') if [ "${#archives[@]}" -le "$keep_count" ]; then return 0 fi local index old_archive deleted=0 for (( index=keep_count; index<${#archives[@]}; index++ )); do old_archive="${archives[$index]}" if rm -f "$old_archive"; then deleted=$((deleted + 1)) fi done if [ "$deleted" -gt 0 ]; then log_message "Cleaned up $deleted old dist image archive(s)" 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 meta_file="$1" curl -fsSL "$API_URL" -o "$meta_file" } parse_latest_tag() { local meta_file 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 asset_name meta_file="$1" 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 } load_release_image() { local meta_file tag image_asset image_url tmp_dir archive meta_file="$1" tag="$2" image_asset="${APP_IMAGE_ASSET_PREFIX}${tag}.tar.gz" image_url="$(parse_asset_url "$meta_file" "$image_asset")" if [ -z "$image_url" ]; then log_message "ERROR: Release image asset not found: $image_asset" return 1 fi tmp_dir="$(mktemp -d)" archive="$tmp_dir/$image_asset" trap 'rm -rf "${tmp_dir:-}"' RETURN log_message "Loading app image from release asset $image_asset" curl -fL "$image_url" -o "$archive" docker load -i "$archive" >> "$LOG_FILE" 2>&1 docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true trap - RETURN } 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" } find_local_dist_image_archive() { local tag="$1" local archive if [ ! -d "$DIST_DIR" ]; then return 1 fi for archive in \ "$DIST_DIR/inventarsystem-image-$tag.tar.gz" \ "$DIST_DIR/inventarsystem-image-$tag.tar" \ "$DIST_DIR/inventarsystem-image.tar.gz" \ "$DIST_DIR/inventarsystem-image.tar"; do if [ -f "$archive" ]; then echo "$archive" return 0 fi done archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)" if [ -n "$archive" ]; then echo "$archive" return 0 fi return 1 } load_local_dist_image() { local tag="$1" local archive archive="$(find_local_dist_image_archive "$tag" || true)" if [ -z "$archive" ]; then return 1 fi log_message "Loading app image from local dist artifact: $archive" if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true return 0 fi log_message "WARNING: Failed to load local dist artifact: $archive" return 1 } download_and_extract_bundle() { local url tmp_dir archive url="$1" tmp_dir="$2" 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 chmod +x "$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 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 } deploy() { local tag="$1" local meta_file="$2" 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 if ! load_local_dist_image "$tag"; then if ! load_release_image "$meta_file" "$tag"; then log_message "Falling back to tagged 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 fi fi docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 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 # Clean up old dist artifacts cleanup_old_dist_artifacts # Clean up 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, perform a simple dev deploy flow if [ "$MODE" = "development" ]; then log_message "Requested development install" local tag="dev" local app_image="$APP_IMAGE_REPO:$tag" local 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 # Try local dist first, then pull from Gitea Registry if ! load_local_dist_image "$tag"; then 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 fi # Bring up stack docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$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" 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 Gitea release for $REPO_SLUG..." if ! fetch_release_metadata "$meta_file"; 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_latest_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" "$meta_file" 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" "$meta_file" if ! verify_stack_health; then log_message "ERROR: Updated stack failed health check" exit 1 fi echo "$latest_tag" > "$STATE_FILE" cleanup_old_dist_artifacts cleanup_docker_dangling_images log_message "Update completed successfully to release $latest_tag" sudo ./opt/Inventarsystem/restart.sh echo "Restart of the Server Completed" } main "$@"