feat(docker): restore failed Compose updates and retain recent releases
This commit is contained in:
1 parent
fe1ee8a6fe
commit
745d0b7247
4 files changed
+159
-16
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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,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 ;;
|
||||||
|
|||||||
Reference in new issue
Block a user