153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
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"));
|
|
mkdirSync(join(dir, "logs"));
|
|
writeFileSync(
|
|
join(dir, "logs/docker-release-history.log"),
|
|
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
|
|
);
|
|
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,
|
|
restored: existsSync(join(dir, "restored")),
|
|
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("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", () => {
|
|
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");
|
|
expect(r.restored).toBe(true);
|
|
expect(r.output).toContain("Rollback verified locally");
|
|
},
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|