diff --git a/scripts/dashcaddy-update.sh b/scripts/dashcaddy-update.sh new file mode 100755 index 0000000..b6de496 --- /dev/null +++ b/scripts/dashcaddy-update.sh @@ -0,0 +1,379 @@ +#!/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 IMAGE_TAG="dashcaddy-dashcaddy-api:latest" +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 + if [[ -d "$backup_dir/dns-providers" ]]; then + rm -rf "$api_source_dir/dns-providers" + cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" + fi + restore_data_dir "$backup_dir" +} + +# ── Deployment mode ─────────────────────────────────────────────────────────── +# Reproduce the SAME container the install created so an auto-update keeps every +# volume + env var (docker socket, Caddyfile, config/credentials, updates mount), +# not a minimal subset. Standard installs use docker-compose (compose file in the +# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a +# bare docker run is the last resort. build_image() and restart_container() both +# honor the detected mode so build and run stay consistent. +deploy_mode() { + if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then + echo compose + elif [[ -x /opt/dashcaddy/start.sh ]]; then + echo startsh + else + echo run + fi +} + +# Build the API image using whatever the install is wired for. Returns the build +# command's exit status so callers can detect failure. +build_image() { + cd "$api_source_dir" || return 1 + case "$(deploy_mode)" in + compose) docker compose build 2>&1 || docker-compose build 2>&1 ;; + *) docker build -t "$IMAGE_TAG" . 2>&1 ;; + esac +} + +# ── Shared container restart — recreate with the full, install-defined spec ─── +# Recreates (rm + run / compose up) so new code AND new env vars take effect. +restart_container() { + cd "$api_source_dir" 2>/dev/null || true + case "$(deploy_mode)" in + compose) + log "Recreating container via docker compose (full compose spec)..." + docker compose up -d 2>&1 || docker-compose up -d 2>&1 + ;; + startsh) + log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..." + bash /opt/dashcaddy/start.sh + ;; + *) + log "Recreating container via minimal docker run (fallback)..." + docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + 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_TAG" + ;; + esac + log "Container recreated" +} + +# ── 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 + if [[ -d "$backup_dir/dns-providers" ]]; then + rm -rf "$api_source_dir/dns-providers" + cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" + 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..." + build_image 2>&1 | tail -3 || true + + restart_container + 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/" + [[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$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 [[ -d "$staging_dir/dns-providers" ]]; then + rm -rf "$api_source_dir/dns-providers" + cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers" + 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..." + local build_ok=false + if build_image; then + build_ok=true + fi + + if [[ "$build_ok" != "true" ]]; then + log "ERROR: Docker build failed — rolling back code + data" + code_restore "$backup_dir" + build_image 2>&1 | tail -3 || true + restart_container + 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 (recreate so new code + env vars take effect) + restart_container + + # 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" + build_image 2>&1 | tail -3 || true + restart_container + 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 "$@"