feat(docker): restore failed Compose updates and retain recent releases
CI / check (push) Successful in 1m7s
CI / deploy (push) Successful in 1m20s

This commit is contained in:
Simo committed 2026-09-07 22:10:13 +02:00
1 parent fe1ee8a6fe
commit 745d0b7247
4 files changed
+159 -16

No files matched your search

+36 -5
View File
@@ -182,11 +182,42 @@ cron entry with the absolute path of **your** checkout; keep logs in its `logs`
directory. Do not configure both CI and Compose updates for the same instance. directory. Do not configure both CI and Compose updates for the same instance.
The updater refuses an active `epicnext-cms-app` CI-managed container. The updater refuses an active `epicnext-cms-app` CI-managed container.
Logs: `logs/docker-update.log`. A failure after recreation leaves the candidate Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
running for diagnosis and returns nonzero; this Compose updater does not promise failure automatically recreates the CMS with its previous image and verifies its
automatic application or database rollback. Persistent volumes are preserved. local HTTP release and database health. The command still returns nonzero: a
Before production migrations, retain your normal database backup. The existing rollback is not a successful update. Recovery uses the current Compose configuration
CI deploy continues to restore its previous container on failed verification. and `.env`; it restores application code, not configuration edits or database migrations.
The previous image must carry a valid release label. First installations have no
previous release: a failed candidate remains available for diagnosis. A failed
rollback is explicitly logged with a retained `epicnext-cms:rollback-...` recovery tag.
Public routing must be checked separately after rollback. SIGKILL or host power loss
cannot run the rollback handler.
After success, the updater keeps the two most recent distinct releases tracked in
`logs/docker-release-history.log`. Cleanup only removes older recorded CMS image
tags, without force; images referenced by other containers (including stopped ones)
are preserved and retried on later updates. Untracked historical images, build cache,
other services and persistent volumes are not pruned. Retain the history file between
updates. Before production migrations, retain your normal database backup.
### Shared prebuilt image audit
The current Docker build is installation-specific. A shared registry image needs
these changes before it can safely replace local builds:
| Configuration | Current source | Required preparation |
| --- | --- | --- |
| Avatar imager URL | `src/lib/imager.ts`, direct `NEXT_PUBLIC_IMAGER_URL` access | Supply public runtime configuration; remove the Epicnabbo-specific fallback for other installations. |
| Badge URL | Admin user badge components, `NEXT_PUBLIC_BADGE_URL` | Pass runtime configuration from the server to client components. |
| Public application URL | Diagnostics and photo helpers, `NEXT_PUBLIC_APP_URL` fallback | Use server runtime `APP_URL`; audit any generated absolute URLs. |
| Release identity | `next.config.ts`, `NEXT_PUBLIC_CMS_RELEASE` | Keep baked into the image: one commit identifies the same application binary everywhere. |
| Environment validation | `src/env.ts` requires database URL, hotel name and production auth secret | Build with isolated fixture configuration, then validate each installation at startup. Do not publish production environment files or builder images. |
| Build context | `.dockerignore` currently includes `.env`; Dockerfile copies the context into the builder | Separate build inputs from runtime secrets; inspect final image and build layers before registry publication. |
This is a source audit, not a validated portable image. The next verification is to
run the **same image digest** for two installations with different names, domains,
imager/badge URLs and credentials, then check rendered pages and browser requests.
The registry publishing workflow is intentionally not enabled yet.
### Diagnose an update that is not visible ### Diagnose an update that is not visible
+74 -9
View File
@@ -10,7 +10,48 @@ mkdir -p "$(dirname "$LOG_FILE")"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; } log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit 1; } die() { log "ERROR: $*"; exit 1; }
migration_image="" migration_image=""
trap 'if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; fi' EXIT previous_image=""
previous_release=""
rollback_tag=""
cutover=0
probe='const r=await fetch(process.argv[1],{cache:"no-store",signal:AbortSignal.timeout(5000)});const d=await r.json();if(!r.ok||d.database!==true||d.release!==process.argv[2]){console.error(JSON.stringify({http:r.status,database:d.database,release:d.release,expected:process.argv[2]}));process.exit(1)}'
verify_container() {
local target="$1" release="$2" attempt
for attempt in $(seq 1 30); do
if docker exec "$target" node --input-type=module -e "$probe" "http://127.0.0.1:3002/api/health" "$release" >>"$LOG_FILE" 2>&1; then return 0; fi
sleep 3
done
return 1
}
finish() {
local status=$? restored
trap - EXIT
if [[ "$status" != 0 && "$cutover" = 1 ]]; then
docker compose logs --tail 100 cms >>"$LOG_FILE" 2>&1 || true
if [[ -n "$previous_image" ]]; then
log "Update failed; restoring previous image $previous_image. Database migrations are not reversed."
if CMS_RELEASE="$rollback_tag" docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1; then
restored="$(docker compose ps -q cms 2>>"$LOG_FILE" || true)"
if [[ -n "$restored" && "$(docker inspect --format '{{.Image}}' "$restored")" = "$previous_image" ]] && verify_container "$restored" "$previous_release"; then
log "Rollback verified locally: $previous_release. Check public routing separately."
else
log "ERROR: rollback verification failed. Inspect $LOG_FILE; previous image retained as epicnext-cms:$rollback_tag."
fi
else
log "ERROR: rollback could not recreate CMS. Previous image retained as epicnext-cms:$rollback_tag."
fi
else
log "First installation failed: no previous image exists to restore. Candidate retained for diagnosis."
fi
fi
if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; fi
# Keep the recovery tag on failure for manual recovery, including same-commit rebuilds.
if [[ ( "$status" = 0 || "$cutover" = 0 ) && -n "$rollback_tag" ]]; then docker image rm "epicnext-cms:$rollback_tag" >>"$LOG_FILE" 2>&1 || true; fi
exit "$status"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
# An existing CI deployment is a different owner of the same host port. # An existing CI deployment is a different owner of the same host port.
@@ -31,6 +72,14 @@ export CMS_RELEASE="$(git rev-parse HEAD)"
[[ -f .env ]] || die "Create .env before installing or updating." [[ -f .env ]] || die "Create .env before installing or updating."
docker info >/dev/null docker info >/dev/null
docker compose config --quiet docker compose config --quiet
previous_container="$(docker compose ps -q cms)"
if [[ -n "$previous_container" ]]; then
previous_image="$(docker inspect --format '{{.Image}}' "$previous_container")"
previous_release="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$previous_image")"
[[ "$previous_release" =~ ^[0-9a-f]{40}$ ]] || die "Previous image lacks a valid release label; automatic rollback cannot be verified."
rollback_tag="rollback-$CMS_RELEASE-$$"
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
fi
log "Building release $CMS_RELEASE from $DIR" log "Building release $CMS_RELEASE from $DIR"
# The builder contains the matching migration source and locked dependencies. # The builder contains the matching migration source and locked dependencies.
# No Node/package manager installation on the host is required. # No Node/package manager installation on the host is required.
@@ -42,19 +91,13 @@ revision="$(docker image inspect --format '{{index .Config.Labels "org.openconta
[[ "$revision" = "$CMS_RELEASE" ]] || die "Built image has revision $revision, expected $CMS_RELEASE." [[ "$revision" = "$CMS_RELEASE" ]] || die "Built image has revision $revision, expected $CMS_RELEASE."
docker run --rm --network host --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1 docker run --rm --network host --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1
log "Build and migrations completed; recreating only the CMS service." log "Build and migrations completed; recreating only the CMS service."
cutover=1
docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1 docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1
container="$(docker compose ps -q cms)" container="$(docker compose ps -q cms)"
[[ -n "$container" ]] || die "Compose did not start the CMS container." [[ -n "$container" ]] || die "Compose did not start the CMS container."
actual_image="$(docker inspect --format '{{.Image}}' "$container")" actual_image="$(docker inspect --format '{{.Image}}' "$container")"
[[ "$actual_image" = "$expected_image" ]] || die "Running image $actual_image differs from built image $expected_image." [[ "$actual_image" = "$expected_image" ]] || die "Running image $actual_image differs from built image $expected_image."
# Verify the actual HTTP response, not an environment variable supplied at run time. verify_container "$container" "$CMS_RELEASE" || die "HTTP health/release verification failed."
probe='const r=await fetch(process.argv[1],{cache:"no-store",signal:AbortSignal.timeout(5000)});const d=await r.json();if(!r.ok||d.database!==true||d.release!==process.argv[2]){console.error(JSON.stringify({http:r.status(),database:d.database,release:d.release,expected:process.argv[2]}));process.exit(1)}'
healthy=0
for attempt in $(seq 1 30); do
if docker exec "$container" node --input-type=module -e "$probe" "http://127.0.0.1:3002/api/health" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1; then healthy=1; break; fi
sleep 3
done
[[ "$healthy" = 1 ]] || die "HTTP health/release verification failed. The candidate remains available for diagnosis; no success was recorded."
if [[ -n "${CMS_PUBLIC_URL:-}" ]]; then if [[ -n "${CMS_PUBLIC_URL:-}" ]]; then
[[ "$CMS_PUBLIC_URL" = https://* || "$CMS_PUBLIC_URL" = http://* ]] || die "CMS_PUBLIC_URL must be an HTTP(S) URL." [[ "$CMS_PUBLIC_URL" = https://* || "$CMS_PUBLIC_URL" = http://* ]] || die "CMS_PUBLIC_URL must be an HTTP(S) URL."
docker exec "$container" node --input-type=module -e "$probe" "${CMS_PUBLIC_URL%/}/api/health?release=$CMS_RELEASE" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Public domain serves another release or is unhealthy. Check reverse proxy/CDN destination." docker exec "$container" node --input-type=module -e "$probe" "${CMS_PUBLIC_URL%/}/api/health?release=$CMS_RELEASE" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Public domain serves another release or is unhealthy. Check reverse proxy/CDN destination."
@@ -63,3 +106,25 @@ else
log "Public domain was not checked. Set CMS_PUBLIC_URL to verify reverse proxy/CDN routing as well." log "Public domain was not checked. Set CMS_PUBLIC_URL to verify reverse proxy/CDN routing as well."
fi fi
log "Verified release $CMS_RELEASE, image $actual_image, container $container" log "Verified release $CMS_RELEASE, image $actual_image, container $container"
cutover=0
# Record only this checkout's successful release tags; never prune Docker globally.
history="$DIR/logs/docker-release-history.log"
mkdir -p "$DIR/logs"
touch "$history"
mapfile -t releases < <(printf '%s\n' "$CMS_RELEASE" "$previous_release"; cat "$history")
kept=()
pending=()
for release in "${releases[@]}"; do
[[ "$release" =~ ^[0-9a-f]{40}$ ]] || continue
[[ " ${kept[*]} ${pending[*]} " != *" $release "* ]] || continue
if [[ "${#kept[@]}" -lt 2 ]]; then kept+=("$release"); continue; fi
# Even stopped containers belonging to other deployments protect an image.
if users="$(docker ps -aq --filter "ancestor=epicnext-cms:$release")" && [[ -z "$users" ]] && docker image rm "epicnext-cms:$release" >>"$LOG_FILE" 2>&1; then
log "Removed superseded release tag $release"
else
pending+=("$release")
fi
done
printf '%s\n' "${kept[@]}" "${pending[@]}" > "$history.tmp"
mv "$history.tmp" "$history"
log "Keeping the two latest releases; in-use images and persistent volumes are preserved."
+34
View File
@@ -29,6 +29,11 @@ function simulate(scenario: string) {
const dir = mkdtempSync(join(tmpdir(), "cms-compose-test-")); const dir = mkdtempSync(join(tmpdir(), "cms-compose-test-"));
try { try {
mkdirSync(join(dir, "scripts")); mkdirSync(join(dir, "scripts"));
mkdirSync(join(dir, "logs"));
writeFileSync(
join(dir, "logs/docker-release-history.log"),
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
);
copyFileSync( copyFileSync(
resolve(root, "scripts/docker-update.sh"), resolve(root, "scripts/docker-update.sh"),
join(dir, "scripts/docker-update.sh"), join(dir, "scripts/docker-update.sh"),
@@ -50,6 +55,7 @@ function simulate(scenario: string) {
if (result.error) throw result.error; if (result.error) throw result.error;
return { return {
status: result.status, status: result.status,
restored: existsSync(join(dir, "restored")),
output: result.stdout + result.stderr, output: result.stdout + result.stderr,
calls: existsSync(join(dir, "calls")) calls: existsSync(join(dir, "calls"))
? readFileSync(join(dir, "calls"), "utf8") ? readFileSync(join(dir, "calls"), "utf8")
@@ -60,6 +66,32 @@ function simulate(scenario: string) {
} }
} }
describe("Docker clone updates", () => { describe("Docker clone updates", () => {
it("removes only historical release tags beyond the current and previous", () => {
const r = simulate("success");
expect(r.calls).toContain(`docker image rm epicnext-cms:${"c".repeat(40)}`);
expect(r.calls).not.toContain(
`docker image rm epicnext-cms:${"b".repeat(40)}`,
);
});
it("preserves older images used by another container", () => {
const r = simulate("retained-in-use");
expect(r.status, r.output).toBe(0);
expect(r.calls).not.toContain(
`docker image rm epicnext-cms:${"c".repeat(40)}`,
);
});
it("reports an unsuccessful rollback and retains the recovery image", () => {
const r = simulate("rollback-failure");
expect(r.status).not.toBe(0);
expect(r.output).toContain("rollback could not recreate CMS");
expect(r.calls).not.toContain("docker image rm epicnext-cms:rollback-");
});
it("handles first installation failures without claiming rollback", () => {
const r = simulate("first-failure");
expect(r.status).not.toBe(0);
expect(r.restored).toBe(false);
expect(r.output).toContain("no previous image exists");
});
it("builds the pulled commit, migrates before recreation and verifies local/public HTTP", () => { it("builds the pulled commit, migrates before recreation and verifies local/public HTTP", () => {
const r = simulate("success"); const r = simulate("success");
expect(r.status, r.output).toBe(0); expect(r.status, r.output).toBe(0);
@@ -91,6 +123,8 @@ describe("Docker clone updates", () => {
const r = simulate(scenario); const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0); expect(r.status, r.output).not.toBe(0);
expect(r.output).not.toContain("Verified release"); expect(r.output).not.toContain("Verified release");
expect(r.restored).toBe(true);
expect(r.output).toContain("Rollback verified locally");
}, },
); );
}); });
+15 -2
View File
@@ -15,15 +15,28 @@ docker() {
case "$1 ${2:-}" in case "$1 ${2:-}" in
'inspect --format') 'inspect --format')
if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi
if [ "${@: -1}" = previous123 ] || [ -f "$TEST_DIR/restored" ]; then echo sha256:old; return 0; fi
if [ "$SCENARIO" = wrong-image ]; then echo sha256:old; else echo sha256:new; fi ;; if [ "$SCENARIO" = wrong-image ]; then echo sha256:old; else echo sha256:new; fi ;;
'image inspect') 'image inspect')
if [[ "$*" = *org.opencontainers* ]] && [[ "$*" = *sha256:old* ]]; then printf 'b%.0s' {1..40}; echo; return 0; fi
if [[ "$*" = *org.opencontainers* ]]; then echo "$TEST_SHA"; else echo sha256:new; fi ;; if [[ "$*" = *org.opencontainers* ]]; then echo "$TEST_SHA"; else echo sha256:new; fi ;;
'compose config') return 0 ;; 'compose config') return 0 ;;
'compose build') [ "$SCENARIO" != build-failure ] ;; 'compose build') [ "$SCENARIO" != build-failure ] ;;
'compose up') [ "$SCENARIO" != recreate-failure ] ;; 'compose up')
'compose ps') echo container123 ;; if [[ "$CMS_RELEASE" = rollback-* ]]; then
[ "$SCENARIO" != rollback-failure ] || return 1
touch "$TEST_DIR/restored"; return 0
fi
touch "$TEST_DIR/cutover"
[ "$SCENARIO" != recreate-failure ] ;;
'compose ps')
if [ -f "$TEST_DIR/cutover" ]; then echo container123
elif [ "$SCENARIO" != first-failure ]; then echo previous123; fi ;;
'run --rm') [ "$SCENARIO" != migration-failure ] ;; 'run --rm') [ "$SCENARIO" != migration-failure ] ;;
'ps -aq') if [ "$SCENARIO" = retained-in-use ]; then echo other-container; fi ;;
'exec container123') 'exec container123')
if [ -f "$TEST_DIR/restored" ]; then return 0; fi
if [ "$SCENARIO" = first-failure ] || [ "$SCENARIO" = rollback-failure ]; then return 1; fi
if [ "$SCENARIO" = wrong-release ]; then return 1; fi if [ "$SCENARIO" = wrong-release ]; then return 1; fi
if [ "$SCENARIO" = wrong-public ] && [[ "$*" = *example.test* ]]; then return 1; fi ;; if [ "$SCENARIO" = wrong-public ] && [[ "$*" = *example.test* ]]; then return 1; fi ;;
*) return 0 ;; *) return 0 ;;