DC-025: harden updater — channel gate + safe locked-file replacement
The host-side updater has been silently broken in two ways: 1. Empty staging directories would cause rm -rf of live routes/src with no replacement, leaving the host tree gutted while the container kept serving from its own image. Now deploy_tree() refuses to delete unless the staging source has actual files. 2. chattr +i on critical files (used to protect security-hotfixed routes from being clobbered by upstream tarballs) caused rm -rf to partially execute then fail under set -e, leaving the host in a half-deleted state. Now deploy_tree() scans for immutable files, unlocks them before replace, and re-locks them after — so security-locked files survive every update. Also adds: - Channel gate: trigger.json channel=prerelease/beta/rc/alpha is rejected unless ALLOW_PRERELEASE=true is set in /opt/dashcaddy/updates/channel.conf. Default is 'stable only', safe for production. Staging hosts opt in. - channel.conf.example documenting the new opt-in mechanism. Verified end-to-end: manual trigger.json → path unit fired → routes (53 files) + src (62 files) deployed → container rebuilt → health check passed. totp.js remained locked with security edits intact.
This commit is contained in:
+90
-12
@@ -5,6 +5,10 @@
|
||||
# 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
|
||||
|
||||
@@ -16,6 +20,7 @@ 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"
|
||||
@@ -23,6 +28,38 @@ readonly DATA_BACKUP_PREFIX="data-backup"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
write_result() {
|
||||
local success="$1" version="$2" duration="$3"
|
||||
shift 3
|
||||
@@ -215,7 +252,7 @@ main() {
|
||||
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 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'])")
|
||||
@@ -225,16 +262,25 @@ main() {
|
||||
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} ==="
|
||||
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
|
||||
|
||||
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||
if [[ "$action" == "rollback" ]]; then
|
||||
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||
@@ -290,18 +336,50 @@ main() {
|
||||
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"
|
||||
# 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
|
||||
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"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# DashCaddy update channel configuration
|
||||
#
|
||||
# Copy this file to channel.conf and uncomment ALLOW_PRERELEASE to opt in to
|
||||
# prerelease/beta/rc channels. Stable releases are always applied.
|
||||
#
|
||||
# Useful for staging hosts that want to test new releases before they hit prod.
|
||||
# Production hosts should leave this set to false (the default).
|
||||
|
||||
# ALLOW_PRERELEASE=false
|
||||
Reference in New Issue
Block a user