Files
Ekklesia/tests/ci-scripts.test.ts
T
ekklesiaandClaude Opus 5.5 703d92436b 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]>
2026-10-06 02:01:01 +00:00

214 lines
9.8 KiB
TypeScript

import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
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
* be active on one runner at once (and the inventoried Gitea ignores workflow `concurrency`), so
* nothing they start may claim a fixed host port, a fixed project or network name, or a fixed
* subnet. And when a disposable project does not become ready, the failure must say why without
* printing a credential.
*/
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const execFileAsync = promisify(execFile);
const ciScripts = [
"scripts/test-migrations-integration.sh",
"scripts/smoke-infrastructure.sh",
"scripts/smoke-deploy.sh",
];
async function source(relativePath: string): Promise<string> {
return readFile(path.join(repositoryRoot, relativePath), "utf8");
}
/** Runs the real helper under bash with the given literal secrets and input. */
async function redact(input: string, secrets: readonly string[] = []): Promise<string> {
const helper = path.join(repositoryRoot, "scripts", "lib", "compose-diagnostics.sh");
const script = `source "$1"; shift; COMPOSE_DIAGNOSTICS_SECRETS=("$@"); printf '%s' "$INPUT" | compose_diagnostics_redact`;
const { stdout } = await execFileAsync("bash", ["-c", script, "redact", helper, ...secrets], {
env: { ...process.env, INPUT: input },
});
return stdout;
}
describe("CI scripts on a shared Docker daemon", () => {
it("publish every host port ephemerally, never a fixed one", async () => {
const migrations = await source("scripts/test-migrations-integration.sh");
// `.env.example` pins both ports for local development; the suite must override both.
expect(migrations).toContain('export POSTGRES_PORT="0"');
expect(migrations).toContain('export S3_PORT="0"');
const infrastructure = await source("scripts/smoke-infrastructure.sh");
expect(infrastructure).toContain('export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"');
expect(infrastructure).toContain('export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"');
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain("EKKLESIA_HTTP_PORT=0");
expect(deploy).toContain("EKKLESIA_HTTPS_PORT=0");
for (const relativePath of ciScripts) {
const text = await source(relativePath);
// No `-p 8333:…`, `127.0.0.1:5432:…`, or `PORT=<non-zero>` anywhere.
expect(text, relativePath).not.toMatch(/\b(?:-p|--publish)\s+[^\s]*\d{2,5}:\d/u);
expect(text, relativePath).not.toMatch(/\b[A-Z_]*PORT="?[1-9]\d*"?\s*$/mu);
}
});
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(helper).toContain("$$-${RANDOM}${RANDOM}");
for (const relativePath of ciScripts) {
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);
expect(deploy).toContain("choose_edge_network() {");
expect(deploy).toContain("docker network inspect --format '{{range .IPAM.Config}}");
expect(deploy).toContain('[[ "$up_output" == *"overlap"* ]]');
});
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
const workflow = await source(".github/workflows/ci.yml");
expect(workflow).toContain(
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
);
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
});
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
for (const relativePath of ciScripts) {
const text = await source(relativePath);
expect(text, relativePath).toContain(
'source "$repository_root/scripts/lib/compose-diagnostics.sh"',
);
expect(text, relativePath).toContain("compose_report_not_ready");
expect(text, relativePath).toContain("COMPOSE_DIAGNOSTICS_SECRETS=(");
// Compose output is never printed raw: logs are only ever captured (the deploy smoke scans
// the API's for secrets), and a readiness failure's own output is never discarded.
for (const line of text
.split("\n")
.filter((candidate) => candidate.includes('compose[@]}" logs'))) {
expect(line, relativePath).toMatch(/="\$\("\$\{compose\[@\]\}" logs /u);
}
expect(text, relativePath).not.toMatch(/up --detach --wait[^\n]*>\/dev\/null 2>&1/u);
}
});
});
describe("compose diagnostics redaction", () => {
it("masks credential-looking values, URL user information, and Authorization headers", async () => {
const output = await redact(
[
"POSTGRES_PASSWORD=hunter2hunter2",
' "AWS_SECRET_ACCESS_KEY": "abc/def+ghi"',
"S3_ACCESS_KEY=AKIAEXAMPLE other=fine",
"api_key: xyz123",
"connecting to postgresql://ekklesia:s3cr3t@postgres:5432/ekklesia",
"Authorization: Bearer eyJhbGciOi.payload.sig",
"EKKLESIA_IDENTITY_SECRET=0123456789abcdef",
].join("\n"),
);
for (const secret of [
"hunter2hunter2",
"abc/def+ghi",
"AKIAEXAMPLE",
"xyz123",
"s3cr3t",
"eyJhbGciOi",
"0123456789abcdef",
]) {
expect(output).not.toContain(secret);
}
expect(output).toContain("POSTGRES_PASSWORD=[redacted]");
expect(output).toContain("other=fine");
expect(output).toContain("postgresql://[redacted]@postgres:5432/ekklesia");
});
it("masks every occurrence of a supplied literal, wherever it appears", async () => {
const output = await redact("started with temporary-pw-12345 and again temporary-pw-12345", [
"temporary-pw-12345",
]);
expect(output).toBe("started with [redacted] and again [redacted]\n");
});
it("keeps the diagnosis itself readable", async () => {
const diagnosis =
"Error response from daemon: Bind for 127.0.0.1:8333 failed: port is already allocated";
expect(await redact(diagnosis)).toBe(`${diagnosis}\n`);
expect(await redact("duplicate key value violates unique constraint")).toBe(
"duplicate key value violates unique constraint\n",
);
expect(await redact('password authentication failed for user "ekklesia"')).toBe(
'password authentication failed for user "ekklesia"\n',
);
});
it("strips terminal control sequences from container logs", async () => {
expect(await redact("\u001b[2Kpostgres-1 | ready")).toBe("postgres-1 | ready\n");
});
});