Files
HealthLog/src/__tests__/container-scripts-ship-guard.test.ts
T

142 lines
5.3 KiB
TypeScript

/**
* Structural guard: a script a runbook tells the operator to run inside the
* container is actually in the image, together with every local file it
* imports.
*
* ## The failure this freezes
*
* `docs/ops/password-reset.md` told operators to run
* `docker compose exec app node scripts/reset-password.mjs`, and the runner
* stage of the Dockerfile copied nothing from `scripts/`: the standalone
* output does not carry it. The recovery path for a locked-out admin failed
* with "Cannot find module" at the moment it was needed. The intake-repair
* runbook had the same gap under the `healthlog-tsx` launcher.
*
* ## What it proves and what it does not
*
* Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…`
* command in `docs/` names
* a file the runner stage copies, and every relative import of that file is
* copied too. Whether the package imports resolve is proved at image build by
* the `RUN` checks next to the copies; this file cannot see the traced tree.
* A command written in some other form (a different launcher) is outside the
* matcher.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
/**
* In-container command forms: `docker compose exec <service> node scripts/…`
* or the image's own `healthlog-tsx scripts/…` launcher. A bare
* `node scripts/…` is a workstation or source-checkout command and is out of
* scope.
*/
const CONTAINER_COMMAND =
/(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g;
function markdownFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true, encoding: "utf8" })
.filter((rel) => rel.endsWith(".md"))
.map((rel) => join(dir, rel));
}
function runnerStage(): string {
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
return DOCKERFILE.slice(start);
}
function copiedIntoRunner(path: string): boolean {
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`,
"m",
).test(runnerStage());
}
/** Scripts named by a container command anywhere under docs/. */
function documentedContainerScripts(): string[] {
const found = new Set<string>();
for (const file of markdownFiles(join(ROOT, "docs"))) {
for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) {
found.add(m[1]);
}
}
return [...found].sort();
}
/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */
function entrypointScripts(): string[] {
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
return [
...new Set(
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
(m) => m[1],
),
),
].sort();
}
/** Relative import specifiers of a script, resolved against its location. */
function localImports(script: string): string[] {
const src = readFileSync(join(ROOT, script), "utf8");
const specs = [
...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm),
].map((m) => m[1]);
return specs.map((spec) => normalize(join(dirname(script), spec)));
}
describe("scripts the runbooks run inside the container ship in the image", () => {
it("finds the commands it is meant to check", () => {
const scripts = documentedContainerScripts();
// A matcher that found nothing would agree with any Dockerfile.
expect(scripts).toContain("scripts/reset-password.mjs");
expect(scripts).toContain("scripts/repair-intake-anomalies.ts");
expect(localImports("scripts/reset-password.mjs")).toContain(
"src/lib/auth/argon2-params.mjs",
);
expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false);
});
it("copies every documented script into the runner stage", () => {
const missing = documentedContainerScripts().filter(
(script) => !copiedIntoRunner(script),
);
expect(
missing,
"A runbook runs these inside the container, but the Dockerfile runner " +
"stage does not COPY them. Add the COPY (and its local imports), or " +
"change the runbook to a source-checkout command.",
).toEqual([]);
});
it("copies every local file those scripts import", () => {
const missing = documentedContainerScripts()
.flatMap((script) => localImports(script))
.filter((path) => !copiedIntoRunner(path));
expect(missing).toEqual([]);
});
});
describe("scripts the entrypoint runs ship in the image", () => {
it("finds the boot scripts it is meant to check", () => {
// An empty match would agree with any Dockerfile.
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
});
it("copies every boot script and its local imports into the runner stage", () => {
const missing = entrypointScripts()
.flatMap((script) => [script, ...localImports(script)])
.filter((path) => !copiedIntoRunner(path));
expect(
missing,
"docker-entrypoint.sh runs these on every boot, but the Dockerfile " +
"runner stage does not COPY them; the container would fail to start.",
).toEqual([]);
});
});