test(ci): every CI stack is cleaned up, even when its job is killed
A migration-smoke stack (PostgreSQL and SeaweedFS) leaked on the shared runner and ran for two days, until it was removed by hand. Every CI script already tears its stack down in an EXIT/INT/TERM trap, but a trap never runs when the runner kills the job container: a cancelled job, or the runner's own time limit, since Gitea 1.24 ignores timeout-minutes. Static checks (tests/ci-scripts.test.ts): - every stack script names its project through one helper: the reserved ekklesia-ci- prefix, run, attempt, job key, and a unique suffix; - everything the deploy smoke creates outside Compose carries the CI label; - every workflow job that starts stacks sweeps stale CI stacks first, and removes its own in a final if: always() step. Each matrix leg has its own key, so one cannot remove the other's running stack. Behaviour against real Docker (scripts/test-ci-sweep.sh): - --job removes this job's containers, network, volume, image and staging directory, and leaves other jobs' and non-CI stacks alone; - --job disconnects, never removes, a job container on the stack's network, and refuses to run outside CI; - --stale spares young stacks and removes old ones, never anything outside the prefix. Fails today: there is no helper, no sweep, no label and no step. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2152eb036a
commit
95440b37ea
+58
-10
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
/**
|
||||
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
||||
@@ -59,21 +60,68 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("give every Compose project a name unique to the invocation, not only the run", async () => {
|
||||
expect(await source("scripts/test-migrations-integration.sh")).toMatch(
|
||||
/raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||
it("name every stack through one helper: the reserved CI prefix, the job's key, and a unique suffix", async () => {
|
||||
const helper = await source("scripts/lib/ci-project.sh");
|
||||
// Run, attempt, job key, kind, then a per-invocation suffix: unique even when job containers
|
||||
// share a PID, and findable later by run and job alone (scripts/ci-sweep.sh --job).
|
||||
expect(helper).toContain(
|
||||
"ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-",
|
||||
);
|
||||
expect(await source("scripts/smoke-infrastructure.sh")).toMatch(
|
||||
/raw_project="ekklesia-smoke-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||
);
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain('run_id="$(openssl rand -hex 4)"');
|
||||
expect(deploy).toContain('project="ekklesia-deploy-smoke-${run_id}"');
|
||||
expect(helper).toContain("$$-${RANDOM}${RANDOM}");
|
||||
for (const relativePath of ciScripts) {
|
||||
expect(await source(relativePath), relativePath).toContain('--project-name "$project"');
|
||||
const text = await source(relativePath);
|
||||
expect(text, relativePath).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
|
||||
expect(text, relativePath).toMatch(/^project="\$\(ci_project_name [a-z"${}_-]+\)"$/mu);
|
||||
expect(text, relativePath).not.toContain("raw_project=");
|
||||
expect(text, relativePath).toContain('--project-name "$project"');
|
||||
}
|
||||
});
|
||||
|
||||
it("label everything the deploy smoke creates outside Compose, so a sweep can find it", async () => {
|
||||
// Continuation lines joined, so each command is checked whole.
|
||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||
const creating = deploy
|
||||
.split("\n")
|
||||
.filter((line) => /\bdocker (?:run --detach|volume create|build)\b/u.test(line));
|
||||
expect(creating.length).toBeGreaterThanOrEqual(4);
|
||||
for (const line of creating) {
|
||||
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
|
||||
}
|
||||
});
|
||||
|
||||
it("sweep stale stacks before, and this job's own stacks after, every job that starts any", async () => {
|
||||
const workflow = parse(await source(".github/workflows/ci.yml")) as {
|
||||
jobs: Record<
|
||||
string,
|
||||
{ env?: Record<string, string>; steps: { if?: string; run?: string; name?: string }[] }
|
||||
>;
|
||||
};
|
||||
const startsStacks =
|
||||
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|docker build/u;
|
||||
const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
|
||||
job.steps.some((step) => startsStacks.test(step.run ?? "")),
|
||||
);
|
||||
expect(jobs.map(([id]) => id).sort()).toEqual(
|
||||
["deploy-smoke", "image-build", "infrastructure-smoke", "migration-smoke"].sort(),
|
||||
);
|
||||
for (const [id, job] of jobs) {
|
||||
expect(job.env?.EKKLESIA_CI_JOB_KEY, id).toBeTruthy();
|
||||
const runs = job.steps.map((step) => step.run ?? "");
|
||||
const firstStack = runs.findIndex((run) => startsStacks.test(run));
|
||||
const stale = runs.findIndex((run) => run.includes("bash scripts/ci-sweep.sh --stale"));
|
||||
const own = job.steps.findIndex(
|
||||
(step) =>
|
||||
step.if === "always()" && (step.run ?? "").includes("bash scripts/ci-sweep.sh --job"),
|
||||
);
|
||||
expect(stale, `${id}: stale sweep`).toBeGreaterThanOrEqual(0);
|
||||
expect(stale, `${id}: stale sweep runs first`).toBeLessThan(firstStack);
|
||||
expect(own, `${id}: its own cleanup, always`).toBeGreaterThan(firstStack);
|
||||
expect(own, `${id}: cleanup is the last step`).toBe(job.steps.length - 1);
|
||||
}
|
||||
// One matrix leg's cleanup must never remove the other leg's running stack.
|
||||
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
|
||||
});
|
||||
|
||||
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||
|
||||
Reference in New Issue
Block a user