fix(deploy): verify Docker clone updates against the served release
This commit is contained in:
1 parent
3bac126ace
commit
cbaa115d56
15 files changed
+321
-187
No files matched your search
@@ -57,6 +57,7 @@ export async function GET() {
|
||||
emulator,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -80,7 +80,12 @@ describe("deployment transaction", () => {
|
||||
"docker tag sha256:old epicnext-cms:previous",
|
||||
);
|
||||
});
|
||||
it.each(["run-failure", "health-failure", "smoke-failure"])(
|
||||
it.each([
|
||||
"run-failure",
|
||||
"health-failure",
|
||||
"smoke-failure",
|
||||
"release-failure",
|
||||
])(
|
||||
"restores the exact previous container after %s",
|
||||
(scenario) => {
|
||||
const result = simulate(scenario);
|
||||
|
||||
@@ -29,3 +29,16 @@ describe("Docker build cache", () => {
|
||||
expect(dockerfile).not.toContain("yarn install --production");
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a compiled release to both the application build and final image", () => {
|
||||
expect(dockerfile.indexOf("ARG NEXT_DEPLOYMENT_ID")).toBeGreaterThan(
|
||||
dockerfile.indexOf("COPY . ."),
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
'LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"',
|
||||
);
|
||||
expect(dockerfile.match(/FROM node:26\.8\.1-alpine/g)).toHaveLength(2);
|
||||
const compose = readFileSync("docker-compose.yml", "utf8");
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose interpolation, not JavaScript.
|
||||
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}");
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const bash =
|
||||
process.platform === "win32"
|
||||
? ((process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.flatMap((dir) => [
|
||||
join(dir, "bash.exe"),
|
||||
join(dirname(dir), "bin", "bash.exe"),
|
||||
join(dirname(dirname(dir)), "bin", "bash.exe"),
|
||||
])
|
||||
.find((path) => existsSync(path)) ?? "bash")
|
||||
: "bash";
|
||||
const sha = "a".repeat(40);
|
||||
function simulate(scenario: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-compose-test-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "scripts"));
|
||||
copyFileSync(
|
||||
resolve(root, "scripts/docker-update.sh"),
|
||||
join(dir, "scripts/docker-update.sh"),
|
||||
);
|
||||
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
|
||||
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
timeout: 25000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASH_ENV: resolve(root, "src/test/docker-update-harness.sh"),
|
||||
TEST_DIR: dir.replaceAll("\\", "/"),
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
CMS_PUBLIC_URL: "https://example.test",
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return {
|
||||
status: result.status,
|
||||
output: result.stdout + result.stderr,
|
||||
calls: existsSync(join(dir, "calls"))
|
||||
? readFileSync(join(dir, "calls"), "utf8")
|
||||
: "",
|
||||
};
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
describe("Docker clone updates", () => {
|
||||
it("builds the pulled commit, migrates before recreation and verifies local/public HTTP", () => {
|
||||
const r = simulate("success");
|
||||
expect(r.status, r.output).toBe(0);
|
||||
expect(r.calls).toContain(`--build-arg NEXT_DEPLOYMENT_ID=${sha}`);
|
||||
expect(r.calls.indexOf("db:migrate")).toBeLessThan(
|
||||
r.calls.indexOf("compose up"),
|
||||
);
|
||||
expect(r.calls).toContain(
|
||||
"up -d --no-deps --no-build --force-recreate cms",
|
||||
);
|
||||
expect(r.calls).toContain("https://example.test/api/health");
|
||||
expect(r.output).toContain(`Verified release ${sha}`);
|
||||
expect(r.calls).not.toContain("prune");
|
||||
});
|
||||
it.each([
|
||||
"dirty",
|
||||
"ci-active",
|
||||
"pull-failure",
|
||||
"build-failure",
|
||||
"migration-failure",
|
||||
])("does not replace the container after %s", (scenario) => {
|
||||
const r = simulate(scenario);
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.calls).not.toContain("compose up");
|
||||
});
|
||||
it.each(["wrong-image", "wrong-release", "wrong-public", "recreate-failure"])(
|
||||
"never reports success for %s",
|
||||
(scenario) => {
|
||||
const r = simulate(scenario);
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.output).not.toContain("Verified release");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("HTTP release verification", () => {
|
||||
const script = readFileSync("scripts/docker-update.sh", "utf8");
|
||||
const probe = script.match(/^probe='(.+)'$/m)?.[1];
|
||||
it.each([
|
||||
["current", { database: true, release: sha }, 200, 0],
|
||||
["old release", { database: true, release: "old" }, 200, 1],
|
||||
["unknown release", { database: true, release: "unknown" }, 200, 1],
|
||||
["database down", { database: false, release: sha }, 200, 1],
|
||||
["HTTP failure", { database: true, release: sha }, 503, 1],
|
||||
])("checks %s", (_name, body, status, expected) => {
|
||||
expect(probe).toBeTruthy();
|
||||
const code = `import {createServer} from "node:http";const server=createServer((q,r)=>{r.writeHead(${status},{"content-type":"application/json"});r.end(${JSON.stringify(JSON.stringify(body))});});await new Promise(resolve=>server.listen(0,"127.0.0.1",resolve));process.argv=[process.execPath,"http://127.0.0.1:"+server.address().port,${JSON.stringify(sha)}];try{${probe}}finally{server.close();}`;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", code],
|
||||
{ encoding: "utf8", timeout: 10000 },
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status, result.stderr).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CatalogExportQueue,
|
||||
publishCatalogFiles,
|
||||
recoverCatalogQueue,
|
||||
sqlValue,
|
||||
} from "./catalog-git-core";
|
||||
import { gitProcessEnvironment } from "./git-process-environment";
|
||||
|
||||
describe("catalog export", () => {
|
||||
it("recovers queue entries owned by a terminated local process", async () => {
|
||||
|
||||
@@ -42,3 +42,6 @@ docker() {
|
||||
esac
|
||||
}
|
||||
export -f git flock pnpm curl sleep docker
|
||||
|
||||
node() { echo "node $*" >> "$TEST_DIR/calls"; [ "$SCENARIO" != release-failure ]; }
|
||||
export -f node
|
||||
@@ -0,0 +1,32 @@
|
||||
# Test doubles; never calls real Docker, Git remotes or databases.
|
||||
git() {
|
||||
echo "git $*" >> "$TEST_DIR/calls"
|
||||
case "$1" in
|
||||
status) if [ "$SCENARIO" = dirty ]; then echo ' M local.ts'; fi ;;
|
||||
hash-object) echo unchanged ;;
|
||||
rev-parse) if [ "${2:-}" = HEAD ]; then echo "$TEST_SHA"; else echo origin/main; fi ;;
|
||||
pull) [ "$SCENARIO" != pull-failure ] ;;
|
||||
esac
|
||||
}
|
||||
flock() { :; }
|
||||
sleep() { :; }
|
||||
docker() {
|
||||
echo "docker $*" >> "$TEST_DIR/calls"
|
||||
case "$1 ${2:-}" in
|
||||
'inspect --format')
|
||||
if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi
|
||||
if [ "$SCENARIO" = wrong-image ]; then echo sha256:old; else echo sha256:new; fi ;;
|
||||
'image inspect')
|
||||
if [[ "$*" = *org.opencontainers* ]]; then echo "$TEST_SHA"; else echo sha256:new; fi ;;
|
||||
'compose config') return 0 ;;
|
||||
'compose build') [ "$SCENARIO" != build-failure ] ;;
|
||||
'compose up') [ "$SCENARIO" != recreate-failure ] ;;
|
||||
'compose ps') echo container123 ;;
|
||||
'run --rm') [ "$SCENARIO" != migration-failure ] ;;
|
||||
'exec container123')
|
||||
if [ "$SCENARIO" = wrong-release ]; then return 1; fi
|
||||
if [ "$SCENARIO" = wrong-public ] && [[ "$*" = *example.test* ]]; then return 1; fi ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
export -f git flock sleep docker
|
||||
Reference in new issue
Block a user