- _isNewer: same-version releases are never 'newer' (commit labels are opaque stamps) — kills the same-version auto-apply regression loop - _autoCheckAndApply: identical version@sha256 never re-applied - dashcaddy-update.sh: truthful rollback verdicts (failed rebuild/health = exit 1 + failure result), exact frontend snapshot/restore (incl. update-introduced owned subtrees), contents-copy cp fallbacks with manifest reconciliation, JSON-encoded results/meta/stamp, prune on every exit path - self-updater: frontend-only Linux releases fail loudly (no more silent no-op success stuck in 'applying') - start.sh: DASHCADDY_UPDATE_ENABLED=true (Sami 2026-09-13), json-file log caps 10M x3, source->webroot sync via update-stamp contract - tests: _isNewer regression suite, functional cycle + json/escape suites (scripts/test-frontend-cycle.sh, scripts/test-json-escape.sh) 15 judge rounds: C,C,D,D,C,C,D,C,C,C,D,C,A
789 lines
34 KiB
Bash
Executable File
789 lines
34 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.
|
||
#
|
||
# Channel selection: by default only "stable" releases are applied. Set
|
||
# ALLOW_PRERELEASE=true in /opt/dashcaddy/updates/channel.conf to opt in to
|
||
# prerelease/beta/rc channels. Useful for staging hosts, not production.
|
||
|
||
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
|
||
readonly CHANNEL_CONF="${UPDATES_DIR}/channel.conf"
|
||
|
||
# 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"
|
||
|
||
# Updater state (trigger.json / result.json) backup — keeps the audit trail
|
||
# (what version we were attempting, what the previous update's outcome was) tied
|
||
# to the same versioned backup directory as code + data. After a failed update,
|
||
# operators can inspect what was attempted without correlating timestamps, and
|
||
# rollback tooling can reconstruct a "what just happened" view of the update
|
||
# state machine. NOTE: we do NOT auto-restore trigger.json on rollback — the
|
||
# rollback handler reads a fresh trigger.json written by the operator/container;
|
||
# restoring the previous attempt's trigger would clobber the active rollback
|
||
# request. Backups here are read-only forensic evidence.
|
||
readonly UPDATE_STATE_BACKUP_PREFIX="update-state"
|
||
readonly TRIGGER_PROCESSING="${TRIGGER_FILE}.processing"
|
||
|
||
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||
|
||
# Decide if a given release channel is acceptable on this host.
|
||
# Returns 0 (accept) or 1 (reject) and logs the reason.
|
||
channel_allowed() {
|
||
local channel="$1"
|
||
local allow_prerelease="false"
|
||
|
||
if [[ -f "$CHANNEL_CONF" ]]; then
|
||
# shellcheck disable=SC1090
|
||
source "$CHANNEL_CONF"
|
||
allow_prerelease="${ALLOW_PRERELEASE:-false}"
|
||
fi
|
||
|
||
case "${channel,,}" in
|
||
stable|"")
|
||
return 0
|
||
;;
|
||
prerelease|beta|rc|alpha)
|
||
if [[ "${allow_prerelease,,}" == "true" ]]; then
|
||
log "Channel '${channel}' accepted (ALLOW_PRERELEASE=true in ${CHANNEL_CONF})"
|
||
return 0
|
||
else
|
||
log "Channel '${channel}' rejected — set ALLOW_PRERELEASE=true in ${CHANNEL_CONF} to accept"
|
||
return 1
|
||
fi
|
||
;;
|
||
*)
|
||
log "Channel '${channel}' rejected — unknown channel"
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ── JSON string escaping (DC-122) ─────────────────────────────────────────────
|
||
# Complete JSON string encoder for the rare no-python fallback path: mandatory
|
||
# escapes (quote, backslash) plus ALL control bytes U+0000–U+001F as \uXXXX or
|
||
# their short forms.
|
||
json_escape() {
|
||
local s="$1" out="" ch i hex
|
||
for (( i=0; i<${#s}; i++ )); do
|
||
ch="${s:i:1}"
|
||
case "$ch" in
|
||
\\) out+='\\' ;;
|
||
\") out+='\"' ;;
|
||
$'\b') out+='\b' ;;
|
||
$'\f') out+='\f' ;;
|
||
$'\n') out+='\n' ;;
|
||
$'\r') out+='\r' ;;
|
||
$'\t') out+='\t' ;;
|
||
*)
|
||
if [[ "$ch" < ' ' || "$ch" == $'\x7f' ]]; then
|
||
printf -v hex '%02x' "'$ch"
|
||
out+="\u00${hex}"
|
||
else
|
||
out+="$ch"
|
||
fi
|
||
;;
|
||
esac
|
||
done
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
write_result() {
|
||
local success="$1" version="$2" duration="$3"
|
||
shift 3
|
||
local error="${1:-}"
|
||
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
python3 - "$success" "$version" "$duration" "$error" > "$RESULT_FILE" <<'PY'
|
||
import json, sys, datetime
|
||
success, version, duration, error = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||
obj = {
|
||
"success": success == "true",
|
||
"version": version,
|
||
"duration": int(duration) if duration.isdigit() else 0,
|
||
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||
}
|
||
if error:
|
||
obj["error"] = error
|
||
print(json.dumps(obj, indent=2))
|
||
PY
|
||
else
|
||
# Safe fallback: escaped interpolation (no raw quote/backslash leakage).
|
||
local esc_version esc_error
|
||
esc_version=$(json_escape "$version")
|
||
esc_error=$(json_escape "$error")
|
||
if [[ "$success" == "true" ]]; then
|
||
cat > "$RESULT_FILE" <<EOF
|
||
{
|
||
"success": true,
|
||
"version": "${esc_version}",
|
||
"duration": ${duration},
|
||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
}
|
||
EOF
|
||
else
|
||
cat > "$RESULT_FILE" <<EOF
|
||
{
|
||
"success": false,
|
||
"version": "${esc_version}",
|
||
"duration": ${duration},
|
||
"error": "${esc_error}",
|
||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
}
|
||
EOF
|
||
fi
|
||
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}"
|
||
# DC-122: fallback must copy CONTENTS ("dir/.") — a bare "cp -a dir dest"
|
||
# nests a data/ level inside the existing destination dir, which then made
|
||
# restore_data_dir restore data/data/... (rollback restoring nothing).
|
||
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|
||
|| { log "rsync unavailable — cp fallback (contents copy)"; cp -a "$DATA_SOURCE_DIR/." "${backup_dir}/${DATA_BACKUP_PREFIX}/"; }
|
||
# Manifest of backed-up files — lets the no-rsync restore path mirror
|
||
# rsync --delete semantics (remove live files that the backup lacks).
|
||
( cd "${backup_dir}/${DATA_BACKUP_PREFIX}" && find . -type f -printf '%P\n' | sort ) \
|
||
> "${backup_dir}/data.manifest" 2>/dev/null || true
|
||
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
|
||
}
|
||
|
||
# ── Updater state backup (trigger.json.processing + result.json) ─────────────
|
||
# Captures what was being attempted + the last result so post-mortem can answer
|
||
# "why did this fail" without joining timestamps across files. Tolerates absent
|
||
# files (first-ever run) and locked files (chattr +i). Idempotent — re-running
|
||
# overwrites the previous backup.
|
||
backup_update_state() {
|
||
local backup_dir="$1"
|
||
local state_dir="${backup_dir}/${UPDATE_STATE_BACKUP_PREFIX}"
|
||
mkdir -p "$state_dir"
|
||
|
||
local copied=0
|
||
for src in "$TRIGGER_PROCESSING" "$RESULT_FILE"; do
|
||
if [[ -f "$src" ]]; then
|
||
# Unlock temporarily if immutable, copy, re-lock.
|
||
local was_locked=false
|
||
if lsattr -d "$src" 2>/dev/null | awk '{exit !($1 ~ /i/)}'; then
|
||
was_locked=true
|
||
chattr -i "$src" 2>/dev/null || true
|
||
fi
|
||
cp -f "$src" "${state_dir}/$(basename "$src")" 2>/dev/null && copied=$(( copied + 1 ))
|
||
if [[ "$was_locked" == "true" ]]; then
|
||
chattr +i "$src" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if (( copied > 0 )); then
|
||
log "Update-state backup: ${copied} file(s) -> ${state_dir}"
|
||
else
|
||
log "Update-state backup: nothing to back up (no trigger/result files)"
|
||
rmdir "$state_dir" 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
# ── Data restore ──────────────────────────────────────────────────────────────
|
||
restore_data_dir() {
|
||
local backup_dir="$1"
|
||
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||
if [[ -d "$data_backup" ]]; then
|
||
# DC-122: contents copy on the cp fallback — see backup_data_dir note.
|
||
if rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null; then
|
||
log "Data restored successfully (rsync --delete)"
|
||
else
|
||
log "rsync unavailable — cp fallback + manifest reconciliation"
|
||
cp -a "$data_backup/." "$DATA_SOURCE_DIR/"
|
||
# Mirror deletion semantics without rsync: remove live files that the
|
||
# backup manifest says did not exist at backup time.
|
||
local manifest="${backup_dir}/data.manifest"
|
||
if [[ -f "$manifest" ]]; then
|
||
local live_list deleted=0
|
||
live_list="$( cd "$DATA_SOURCE_DIR" && find . -type f -printf '%P\n' | sort )"
|
||
while IFS= read -r rel; do
|
||
[[ -z "$rel" ]] && continue
|
||
# defense against path traversal in a corrupted manifest
|
||
[[ "$rel" == ..* || "$rel" == */..* || "$rel" == *../* ]] && continue
|
||
rm -f "$DATA_SOURCE_DIR/$rel" && deleted=$(( deleted + 1 ))
|
||
done < <(comm -13 "$manifest" <(printf '%s\n' "$live_list"))
|
||
# prune directories that became empty
|
||
find "$DATA_SOURCE_DIR" -mindepth 1 -type d -empty -delete 2>/dev/null || true
|
||
log "Manifest reconciliation: removed ${deleted} post-backup file(s)"
|
||
fi
|
||
log "Data restored successfully (cp fallback)"
|
||
fi
|
||
else
|
||
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||
fi
|
||
}
|
||
|
||
# ── Frontend backup/restore (DC-122) ──────────────────────────────────────────
|
||
# The frontend is synced to the live web root BEFORE the API rebuild + health
|
||
# check. If the update then fails, the new frontend would pair with the rolled
|
||
# back API. Snapshot exactly what we touch so rollback can restore it.
|
||
FRONTEND_SUBDIRS="dist css vendor js assets"
|
||
|
||
# Snapshot = files + a manifest + metadata, so restore is EXACT:
|
||
# frontend.meta : JSON with target dir + whether index.html/sw.js existed
|
||
# frontend.manifest : every file that existed at snapshot time (relative)
|
||
# Restore deletes live files that the snapshot manifest does not know about
|
||
# (an update-introduced asset dies with the update) and recreates absent
|
||
# snapshot files.
|
||
backup_frontend_dir() {
|
||
local backup_dir="$1" target="$2"
|
||
# DC-122: reset any stale snapshot from a previous update with the same
|
||
# version key before writing this one.
|
||
rm -rf "${backup_dir}/frontend" "${backup_dir}/frontend.manifest" "${backup_dir}/frontend.meta"
|
||
mkdir -p "${backup_dir}/frontend"
|
||
[[ -f "$target/index.html" ]] && cp -f "$target/index.html" "${backup_dir}/frontend/index.html"
|
||
[[ -f "$target/sw.js" ]] && cp -f "$target/sw.js" "${backup_dir}/frontend/sw.js"
|
||
for sub in $FRONTEND_SUBDIRS; do
|
||
[[ -d "$target/$sub" ]] && cp -rf "$target/$sub" "${backup_dir}/frontend/$sub"
|
||
done
|
||
( cd "${backup_dir}/frontend" && find . -type f -printf '%P\n' | sort ) \
|
||
> "${backup_dir}/frontend.manifest" 2>/dev/null || true
|
||
# DC-122: use a validated target path for frontend.meta — json-escaped.
|
||
local esc_target
|
||
esc_target=$(json_escape "$target")
|
||
printf '{"target":"%s","indexExisted":%s,"swExisted":%s}\n' \
|
||
"$esc_target" \
|
||
"$( [[ -f "$target/index.html" ]] && echo true || echo false )" \
|
||
"$( [[ -f "$target/sw.js" ]] && echo true || echo false )" \
|
||
> "${backup_dir}/frontend.meta"
|
||
log "Frontend snapshot saved to ${backup_dir}/frontend ($(wc -l < "${backup_dir}/frontend.manifest" 2>/dev/null || echo 0) files)"
|
||
}
|
||
|
||
# ── Frontend target validation (DC-122) ───────────────────────────────────────
|
||
# A custom DASHCADDY_HOST_FRONTEND_DIR is supported, but ONLY if it validates:
|
||
# absolute, exists, is a directory, no '..' components, not the filesystem root.
|
||
# The SAME validator gates deploy and restore, so anything we deploy to is
|
||
# something we can roll back, and nothing else is ever touched.
|
||
validate_frontend_target() {
|
||
local t="$1"
|
||
[[ -n "$t" ]] || return 1
|
||
[[ "$t" = /* ]] || return 1
|
||
[[ "$t" != "/" ]] || return 1
|
||
[[ "$t" != *..* ]] || return 1
|
||
[[ -d "$t" ]] || return 1
|
||
return 0
|
||
}
|
||
|
||
restore_frontend_dir() {
|
||
local backup_dir="$1"
|
||
local snap="${backup_dir}/frontend"
|
||
[[ -d "$snap" ]] || { log "No frontend snapshot in this backup — skipping frontend restore"; return 0; }
|
||
# DC-122: resolve the target FIRST, validate BEFORE any delete/copy this
|
||
# function performs. A recorded custom target is honored — it was validated
|
||
# by this same function's rules before deployment, and recorded in meta.
|
||
local target=""
|
||
if [[ -f "${backup_dir}/frontend.meta" ]]; then
|
||
target=$(python3 -c "import json;print(json.load(open('${backup_dir}/frontend.meta')).get('target',''))" 2>/dev/null)
|
||
# DC-122 (hardened after live-fire test caught it): if the recorded meta
|
||
# target is present but INVALID, refuse outright. Never fall through to
|
||
# discovery with an untrusted/invalid target — that could point the
|
||
# restore at a directory the snapshot was never taken from.
|
||
if [[ -n "$target" ]] && ! validate_frontend_target "$target"; then
|
||
log "REFUSING frontend restore: recorded meta target is invalid: '${target}'"
|
||
return 1
|
||
fi
|
||
validate_frontend_target "$target" || target=""
|
||
fi
|
||
if [[ -z "$target" ]]; then
|
||
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
||
[[ -d "$candidate" ]] && target="$candidate" && break
|
||
done
|
||
fi
|
||
validate_frontend_target "$target" || { log "Refusing frontend restore on unexpected/unknown target: '${target:-}'"; return 1; }
|
||
log "Restoring frontend to ${target} (exact state, updater-owned scope)..."
|
||
# DC-122: structural exact restore of the owned subtrees:
|
||
# - snapshot has the subtree → rm -rf live + copy snapshot (exact)
|
||
# - snapshot lacks the subtree but live has it → the update introduced it
|
||
# ⇒ remove it. (Fixes rollback leaving behind e.g. a css/ dir the update
|
||
# created when none existed before.)
|
||
# index.html / sw.js are governed by frontend.meta existence flags.
|
||
# Files outside FRONTEND_SUBDIRS are NEVER touched.
|
||
for sub in $FRONTEND_SUBDIRS; do
|
||
if [[ -d "$snap/$sub" ]]; then
|
||
rm -rf "${target:?}/${sub:?}"
|
||
cp -rf "$snap/$sub" "${target:?}/$sub"
|
||
elif [[ -d "${target:?}/${sub}" ]]; then
|
||
rm -rf "${target:?}/${sub:?}"
|
||
log "Removed update-introduced subtree: ${sub}/"
|
||
fi
|
||
done
|
||
# index.html / sw.js per frontend.meta existence flags (single pass).
|
||
if grep -q '"indexExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/index.html" ]]; then
|
||
cp -f "$snap/index.html" "${target:?}/index.html"
|
||
elif grep -q '"indexExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then
|
||
rm -f "${target:?}/index.html"
|
||
fi
|
||
if grep -q '"swExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/sw.js" ]]; then
|
||
cp -f "$snap/sw.js" "${target:?}/sw.js"
|
||
elif grep -q '"swExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then
|
||
rm -f "${target:?}/sw.js"
|
||
fi
|
||
# Rollback removes the deployment stamp: the restored frontend is NOT a
|
||
# self-updater deployment, so start.sh's source sync must resume authority.
|
||
rm -f "${target:?}/update-stamp.json"
|
||
# Empty dirs left behind by the removal pass.
|
||
find "${target:?}/dist" "${target:?}/css" "${target:?}/js" "${target:?}/vendor" "${target:?}/assets" \
|
||
-mindepth 1 -type d -empty -delete 2>/dev/null || true
|
||
log "Frontend restored"
|
||
}
|
||
|
||
# ── Docker space reclaim (DC-122) ─────────────────────────────────────────────
|
||
# Every rebuild leaves the previous image dangling (~250MB); prune it so a
|
||
# churn of auto-updates doesn't seize disk. Safe to call at any exit point.
|
||
prune_docker() {
|
||
docker image prune -f --filter "dangling=true" >/dev/null 2>&1 || true
|
||
docker builder prune -f --keep-storage 500m >/dev/null 2>&1 || true
|
||
}
|
||
|
||
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"
|
||
restore_frontend_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" \
|
||
--log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \
|
||
-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 channel
|
||
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 '')")
|
||
channel=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('channel') or 'stable')")
|
||
# Handle action=rollback (no new version to deploy)
|
||
local to_version="${version}"
|
||
|
||
log "=== ${action^^}: v${from_version} -> v${to_version} (channel: ${channel}) ==="
|
||
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"
|
||
|
||
# Channel gate: refuse to apply prereleases unless explicitly opted-in.
|
||
# Rollbacks always allowed (no new release channel involved).
|
||
if [[ "${action}" != "rollback" ]] && ! channel_allowed "${channel}"; then
|
||
write_result "false" "$to_version" "0" "Channel '${channel}' not allowed on this host"
|
||
rm -f "${TRIGGER_FILE}.processing"
|
||
exit 1
|
||
fi
|
||
|
||
# DC-122: validate the frontend target BEFORE any backup/deploy mutation.
|
||
# An invalid trigger-provided target is a hard failure of the whole update
|
||
# (the release cannot be applied faithfully), not a silent frontend skip.
|
||
if [[ "$action" != "rollback" && -n "$frontend_target_dir" ]] && ! validate_frontend_target "$frontend_target_dir"; then
|
||
log "ERROR: frontend target '${frontend_target_dir}' failed validation — refusing update"
|
||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Invalid frontendTargetDir in trigger: ${frontend_target_dir}"
|
||
rm -f "${TRIGGER_FILE}.processing"
|
||
exit 1
|
||
fi
|
||
|
||
# ── 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 — DC-122: a rollback that cannot rebuild or cannot
|
||
# recover health is a FAILED rollback and must be reported as such.
|
||
log "Rebuilding container..."
|
||
local rebuild_ok=false health_ok=false
|
||
if build_image; then rebuild_ok=true; fi
|
||
|
||
restart_container
|
||
if wait_for_health; then health_ok=true; fi
|
||
|
||
if [[ "$rebuild_ok" == "true" && "$health_ok" == "true" ]]; then
|
||
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||
log "=== Rollback complete ==="
|
||
prune_docker
|
||
rm -f "${TRIGGER_FILE}.processing"
|
||
exit 0
|
||
else
|
||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" \
|
||
"Rollback incomplete (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED"
|
||
log "=== Rollback FAILED (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED ==="
|
||
prune_docker
|
||
rm -f "${TRIGGER_FILE}.processing"
|
||
exit 1
|
||
fi
|
||
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"
|
||
|
||
# DC-122: snapshot the live frontend so a failed update can roll it back
|
||
# (frontend is synced to the web root before build+health check).
|
||
# Use the TRIGGER-provided target when set (custom installs) — the snapshot
|
||
# must cover exactly the dir the deploy will touch — else discover.
|
||
local fe_target="${frontend_target_dir}"
|
||
if [[ -z "$fe_target" ]]; then
|
||
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
||
[[ -d "$candidate" ]] && fe_target="$candidate" && break
|
||
done
|
||
fi
|
||
[[ -n "$fe_target" && -d "$fe_target" ]] && backup_frontend_dir "$backup_dir" "$fe_target"
|
||
|
||
# Backup updater state (trigger.json.processing + result.json) so post-mortem
|
||
# has a forensic trail tied to this exact version's backup.
|
||
backup_update_state "$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
|
||
# Safety: only replace routes/src if staging has the dir AND it's non-empty.
|
||
# An empty or partial staging dir used to cause live routes/src to be wiped
|
||
# when a prior update cycle was interrupted. We also handle locked files
|
||
# (chattr +i) by temporarily unlocking before replace and re-locking after.
|
||
deploy_tree() {
|
||
local rel="$1" # e.g. "routes"
|
||
local src="${staging_dir}/${rel}"
|
||
local dst="${api_source_dir}/${rel}"
|
||
|
||
if [[ ! -d "$src" ]] || [[ -z "$(ls -A "$src" 2>/dev/null)" ]]; then
|
||
[[ -d "$src" ]] && log "WARNING: staging ${rel}/ exists but is empty — leaving live ${rel}/ untouched"
|
||
return 0
|
||
fi
|
||
|
||
# Collect any locked files (chattr +i) in the destination. lsattr's
|
||
# first field is the attribute flags ("i" at position 5 = immutable);
|
||
# the second field is the filename. We unlock before rm -rf and re-lock
|
||
# after so the locked state survives the update.
|
||
local locked_files=()
|
||
if [[ -d "$dst" ]]; then
|
||
while IFS= read -r lf; do
|
||
[[ -n "$lf" ]] && locked_files+=("$lf")
|
||
done < <(find "$dst" -type f \( -name "*.js" -o -name "*.json" -o -name "*.sh" \) -print0 2>/dev/null \
|
||
| xargs -0 lsattr -a 2>/dev/null \
|
||
| awk '$1 ~ /i/ { print $2 }')
|
||
fi
|
||
|
||
for lf in "${locked_files[@]:-}"; do
|
||
[[ -n "$lf" ]] && chattr -i "$lf" 2>/dev/null || true
|
||
done
|
||
|
||
rm -rf "$dst"
|
||
cp -rf "$src" "$dst"
|
||
local file_count
|
||
file_count=$(find "$dst" -type f 2>/dev/null | wc -l)
|
||
log "${rel}/ deployed (${file_count} files)"
|
||
|
||
for lf in "${locked_files[@]:-}"; do
|
||
[[ -n "$lf" ]] && [[ -f "$lf" ]] && chattr +i "$lf" 2>/dev/null || true
|
||
done
|
||
}
|
||
deploy_tree "routes"
|
||
deploy_tree "src"
|
||
deploy_tree "dns-providers"
|
||
if [[ -n "$commit" ]]; then
|
||
echo "$commit" > "$api_source_dir/VERSION"
|
||
fi
|
||
|
||
# 3a. Apply post-deploy patches — fix upstream bugs in released tarballs
|
||
# (e.g. v1.14.4 has broken require paths and missing license-keygen module).
|
||
# Runs AFTER staging copy, BEFORE docker build. Idempotent.
|
||
local patch_script="/opt/dashcaddy/scripts/dashcaddy-post-deploy-patches.sh"
|
||
if [[ -x "$patch_script" ]]; then
|
||
log "Applying post-deploy patches..."
|
||
if "$patch_script" "$api_source_dir"; then
|
||
log "Post-deploy patches applied successfully"
|
||
else
|
||
log "WARNING: Post-deploy patches exited non-zero — continuing build anyway"
|
||
fi
|
||
else
|
||
log "NOTE: $patch_script not found or not executable — skipping post-deploy patches"
|
||
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
|
||
# DC-122: same validator gates deployment — if the trigger-provided custom
|
||
# target doesn't validate, refuse before mutating anything.
|
||
if ! validate_frontend_target "$frontend_target_dir"; then
|
||
log "ERROR: frontend target '${frontend_target_dir}' failed validation — skipping frontend sync (update continues for API only)"
|
||
else
|
||
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
|
||
# DC-122: host-side deployment stamp — start.sh treats a stamped, newer
|
||
# deployment as authoritative and skips its source-bundle sync (this is
|
||
# the only writer that can reach the web root with real host paths).
|
||
local esc_ver
|
||
esc_ver=$(json_escape "$to_version")
|
||
printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||
> "$frontend_target_dir/update-stamp.json" 2>/dev/null || true
|
||
fi # DC-122 close: validated frontend-target branch
|
||
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"
|
||
restore_frontend_dir "$backup_dir"
|
||
local rb_rebuild_ok=false rb_health_ok=false
|
||
if build_image; then rb_rebuild_ok=true; fi
|
||
restart_container
|
||
if wait_for_health; then rb_health_ok=true; fi
|
||
prune_docker
|
||
if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then
|
||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed — rolled back cleanly"
|
||
else
|
||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" \
|
||
"Docker build failed AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED"
|
||
fi
|
||
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"
|
||
local rb_rebuild_ok=false rb_health_ok=false
|
||
if build_image; then rb_rebuild_ok=true; fi
|
||
restart_container
|
||
if wait_for_health; then rb_health_ok=true; fi
|
||
prune_docker
|
||
if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then
|
||
write_result "false" "$to_version" "$duration" "Health check failed after update — rolled back cleanly"
|
||
else
|
||
write_result "false" "$to_version" "$duration" \
|
||
"Health check failed after update AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED"
|
||
fi
|
||
fi
|
||
|
||
# 7. Cleanup
|
||
rm -f "${TRIGGER_FILE}.processing"
|
||
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
||
|
||
# 8. DC-122: reclaim docker space after every self-update rebuild (old
|
||
# dangling image layers otherwise accumulate ~250MB per apply).
|
||
prune_docker
|
||
|
||
log "=== Update process complete ==="
|
||
}
|
||
|
||
main "$@"
|