380 lines
15 KiB
Bash
Executable File
380 lines
15 KiB
Bash
Executable File
#!/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" <<EOF
|
|
{
|
|
"success": true,
|
|
"version": "${version}",
|
|
"duration": ${duration},
|
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
}
|
|
EOF
|
|
else
|
|
cat > "$RESULT_FILE" <<EOF
|
|
{
|
|
"success": false,
|
|
"version": "${version}",
|
|
"duration": ${duration},
|
|
"error": "${error}",
|
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
}
|
|
EOF
|
|
fi
|
|
}
|
|
|
|
cleanup_old_backups() {
|
|
local count
|
|
count=$(find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/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 "$@"
|