#!/usr/bin/env bash # DashCaddy Host-Side Updater # Triggered by systemd path unit when the container writes trigger.json. # Reads the trigger, backs up current API + data/, copies new files, rebuilds container. # Writes result.json so the new container knows the outcome. # # This runs on the HOST, outside the container. set -euo pipefail readonly UPDATES_DIR="/opt/dashcaddy/updates" readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json" readonly RESULT_FILE="${UPDATES_DIR}/result.json" readonly BACKUPS_DIR="${UPDATES_DIR}/backups" readonly CONTAINER_NAME="dashcaddy-api" readonly MAX_BACKUPS=3 readonly HEALTH_TIMEOUT=60 # Data directory backup — stored alongside code backups so everything rolls back together readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data" readonly DATA_BACKUP_PREFIX="data-backup" log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; } write_result() { local success="$1" version="$2" duration="$3" shift 3 local error="${1:-}" if [[ "$success" == "true" ]]; then cat > "$RESULT_FILE" < "$RESULT_FILE" </dev/null | wc -l) if (( count > MAX_BACKUPS )); then log "Cleaning old backups (${count} > ${MAX_BACKUPS})" find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \ | sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \ | xargs rm -rf fi } # ── Data backup (rsync for efficiency + permissions) ────────────────────────── backup_data_dir() { local backup_dir="$1" if [[ -d "$DATA_SOURCE_DIR" ]]; then log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/" mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}" rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \ || cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}" log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))" else log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup" fi } # ── Data restore ────────────────────────────────────────────────────────────── restore_data_dir() { local backup_dir="$1" local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}" if [[ -d "$data_backup" ]]; then log "Restoring data/ from backup..." rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \ || cp -a "$data_backup" "$DATA_SOURCE_DIR" log "Data restored successfully" else log "WARNING: No data backup found at ${data_backup} — data/ not restored" fi } wait_for_health() { local port="${1:-3001}" local timeout="$HEALTH_TIMEOUT" local elapsed=0 log "Waiting for health check (timeout: ${timeout}s)..." while (( elapsed < timeout )); do if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then log "Health check passed after ${elapsed}s" return 0 fi sleep 2 elapsed=$(( elapsed + 2 )) done log "Health check FAILED after ${timeout}s" return 1 } # ── Shared rollback: restore code + data ──────────────────────────────────── rollback_restore() { local backup_dir="$1" log "Rolling back: restoring code files..." for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done if [[ -d "$backup_dir/routes" ]]; then rm -rf "$api_source_dir/routes" cp -rf "$backup_dir/routes" "$api_source_dir/routes" fi if [[ -d "$backup_dir/src" ]]; then rm -rf "$api_source_dir/src" cp -rf "$backup_dir/src" "$api_source_dir/src" fi restore_data_dir "$backup_dir" } # ── Shared container restart (preserves SERVICES_FILE env var) ─────────────── # Uses rm + run so new env vars (e.g. SERVICES_FILE) take effect. # If docker-compose is not configured, falls back to docker start. restart_container() { local image="$1" log "Restarting container (rm + run to pick up env vars)..." # Stop and remove existing container so new env var is applied docker rm -f "$CONTAINER_NAME" 2>/dev/null || true # Re-create with same volumes and the SERVICES_FILE env var docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \ -p 127.0.0.1:3001:3001 \ -v /opt/dashcaddy/dashcaddy-api/data:/app/data \ -e SERVICES_FILE=/app/data/services.json \ "$image" log "Container restarted with fresh env" } # ── Code-only restore (used after failed build when data hasn't changed yet) ── code_restore() { local backup_dir="$1" log "Restoring code files..." for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done if [[ -d "$backup_dir/routes" ]]; then rm -rf "$api_source_dir/routes" cp -rf "$backup_dir/routes" "$api_source_dir/routes" fi if [[ -d "$backup_dir/src" ]]; then rm -rf "$api_source_dir/src" cp -rf "$backup_dir/src" "$api_source_dir/src" fi } main() { local start_time start_time=$(date +%s) # 1. Read trigger if [[ ! -f "$TRIGGER_FILE" ]]; then log "No trigger file found — nothing to do" exit 0 fi # Parse trigger.json (uses python3 which is available on all supported distros) local action version from_version staging_dir api_source_dir commit local frontend_staging_dir frontend_target_dir action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])") version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])") from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])") staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])") api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])") commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')") frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')") frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')") # Handle action=rollback (no new version to deploy) local to_version="${version}" log "=== ${action^^}: v${from_version} -> v${to_version} ===" log "Staging: ${staging_dir}" log "API source: ${api_source_dir}" # Consume the trigger immediately so we don't re-process on failure mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing" # ── Handle rollback ──────────────────────────────────────────────────────── if [[ "$action" == "rollback" ]]; then local backup_dir="${BACKUPS_DIR}/${version}" if [[ ! -d "$backup_dir" ]]; then log "ERROR: No backup found for version ${version}" write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}" rm -f "${TRIGGER_FILE}.processing" exit 1 fi log "Performing rollback to v${version}..." rollback_restore "$backup_dir" # Rebuild old code log "Rebuilding container..." cd "$api_source_dir" docker build -t dashcaddy-dashcaddy-api:latest . 2>&1 | tail -1 || true restart_container "dashcaddy-dashcaddy-api:latest" wait_for_health || log "WARNING: Health check failed after rollback" write_result "true" "$version" "$(( $(date +%s) - start_time ))" rm -f "${TRIGGER_FILE}.processing" log "=== Rollback complete ===" exit 0 fi # ── Handle update ─────────────────────────────────────────────────────────── if [[ ! -d "$staging_dir" ]]; then log "ERROR: Staging directory not found: ${staging_dir}" write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found" rm -f "${TRIGGER_FILE}.processing" exit 1 fi # 2. Backup current API code + data/ local backup_dir="${BACKUPS_DIR}/${from_version}" mkdir -p "$backup_dir" log "Backing up current API files to ${backup_dir}" for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true done [[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/" [[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/" # Backup data/ directory (services.json, config.json, credentials, etc.) backup_data_dir "$backup_dir" cleanup_old_backups # 3. Copy new files from staging to API source log "Deploying new API files..." for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done if [[ -d "$staging_dir/routes" ]]; then rm -rf "$api_source_dir/routes" cp -rf "$staging_dir/routes" "$api_source_dir/routes" fi if [[ -d "$staging_dir/src" ]]; then rm -rf "$api_source_dir/src" cp -rf "$staging_dir/src" "$api_source_dir/src" fi if [[ -n "$commit" ]]; then echo "$commit" > "$api_source_dir/VERSION" fi # 3b. Sync frontend if [[ -z "$frontend_staging_dir" ]]; then parent_staging=$(dirname "$staging_dir") [[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status" fi if [[ -z "$frontend_target_dir" ]]; then for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do [[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break done fi if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir" mkdir -p "$frontend_target_dir" [[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html" [[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js" for sub in dist css vendor js; do if [[ -d "$frontend_staging_dir/$sub" ]]; then mkdir -p "$frontend_target_dir/$sub" cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true fi done if [[ -d "$frontend_staging_dir/assets" ]]; then mkdir -p "$frontend_target_dir/assets" cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true fi fi # 4. Rebuild container log "Rebuilding container..." cd "$api_source_dir" local build_ok=false local image_tag="dashcaddy-dashcaddy-api:latest" if docker build -t "$image_tag" . 2>&1; then build_ok=true fi if [[ "$build_ok" != "true" ]]; then log "ERROR: Docker build failed — rolling back code + data" code_restore "$backup_dir" docker build -t "$image_tag" . 2>&1 | tail -3 || true restart_container "$image_tag" wait_for_health || true write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed" rm -f "${TRIGGER_FILE}.processing" exit 1 fi # 5. Restart container (rm + run so new env vars take effect) restart_container "$image_tag" # 6. Health check if wait_for_health; then local duration=$(( $(date +%s) - start_time )) log "=== Update successful: v${to_version} in ${duration}s ===" write_result "true" "$to_version" "$duration" else local duration=$(( $(date +%s) - start_time )) log "ERROR: Health check failed after update — rolling back code + data" rollback_restore "$backup_dir" docker build -t "$image_tag" . 2>&1 | tail -3 || true restart_container "$image_tag" wait_for_health || log "WARNING: Rollback health check also failed" write_result "false" "$to_version" "$duration" "Health check failed after update" fi # 7. Cleanup rm -f "${TRIGGER_FILE}.processing" rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true log "=== Update process complete ===" } main "$@"