test(testbed): the stage fence must assert CONTENT ARRIVED, not just a directory

Caught while dev-planner was building against this helper: my runbook's fence
asserts a real file (shared.yaml) but the HELPER fenced only the directory —
which mkdir -p had just created. So a delivery that shipped NOTHING would pass
it: a shell pipeline reports only the LAST command's exit status, so a failing
tar piped into a succeeding docker exec yields an empty stage that is perfectly
readable and writable. The fence would go green and the real symptom would
surface as a file-not-found from rig up, three steps downstream.

stageFenceArgv now takes an optional expectFile and targets it (writability
still probed on the directory); stageTopologyPlan threads it through. The
weaker directory-only form remains available for callers with nothing specific
to expect, but the topology path now names its file.

Same class as the four before it: a check that passes while proving nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Fable 5 committed 2026-08-07 20:55:36 +00:00
1 parent cae0964b19
commit 4fa5ba48ea
2 files changed
+25 -4

No files matched your search

@@ -157,10 +157,16 @@ export function stageExtractArgv(container: string, stagePath: string): string[]
/** The pre-flight the stage must pass before anything depends on it: readable AND
* WRITABLE as the exec user, probed by DOING (touch/rm) rather than by reading mode
* bits — bits lie under ownership, ACLs, and read-only mounts. */
export function stageFenceArgv(container: string, stagePath: string): string[] {
export function stageFenceArgv(container: string, stagePath: string, expectFile?: string): string[] {
// ASSERT CONTENT ARRIVED, not merely that the directory exists — `mkdir -p` created
// it, so a delivery that shipped NOTHING (a tar failure whose non-zero exit the pipe
// masked, since a shell pipeline reports only the LAST command's status) leaves an
// empty-but-perfectly-readable-and-writable stage. Fencing the directory alone would
// pass it and defer the real symptom to a file-not-found from `rig up` downstream.
const target = expectFile ? `${stagePath}/${expectFile}` : stagePath;
return [
"exec", container, "sh", "-c",
`test -r '${stagePath}' && touch '${stagePath}/.fence-write' && rm -f '${stagePath}/.fence-write'`,
`test -r '${target}' && touch '${stagePath}/.fence-write' && rm -f '${stagePath}/.fence-write'`,
];
}
@@ -177,7 +183,7 @@ export function stageFenceArgv(container: string, stagePath: string): string[] {
* Returns docker argv arrays for the consumer's own docker seam; `tarSource` is
* the tar-side argv whose stdout pipes into `extract`'s stdin.
*/
export function stageTopologyPlan(opts: { container: string; hostDir: string; name?: string }): {
export function stageTopologyPlan(opts: { container: string; hostDir: string; name?: string; expectFile?: string }): {
stagePath: string;
steps: Array<{ label: string; argv: string[]; stdinFrom?: string[] }>;
} {
@@ -191,7 +197,7 @@ export function stageTopologyPlan(opts: { container: string; hostDir: string; na
argv: stageExtractArgv(opts.container, stagePath),
stdinFrom: stageTarSourceArgv(opts.hostDir),
},
{ label: "fence", argv: stageFenceArgv(opts.container, stagePath) },
{ label: "fence", argv: stageFenceArgv(opts.container, stagePath, opts.expectFile) },
],
};
}
@@ -92,3 +92,18 @@ describe("stageTopologyPlan — the ordering is the contract", () => {
expect(plan.steps[1]!.stdinFrom).toEqual(["-C", "/host/topologies", "-cf", "-", "."]);
});
});
describe("stage fence asserts CONTENT ARRIVED, not just a directory", () => {
it("with expectFile the fence targets the FILE (an empty stage cannot pass)", async () => {
const m = await import("./helpers/testbed-published-daemon.js");
const cmd = m.stageFenceArgv("H_A", "/home/openrig/topologies", "shared.yaml").join(" ");
expect(cmd).toContain("test -r '/home/openrig/topologies/shared.yaml'");
expect(cmd).toContain("touch '/home/openrig/topologies/.fence-write'"); // writability still on the dir
});
it("stageTopologyPlan threads expectFile into the fence step", async () => {
const m = await import("./helpers/testbed-published-daemon.js");
const plan = m.stageTopologyPlan({ container: "H_A", hostDir: "/host/t", expectFile: "rig-a.yaml" });
expect(plan.steps[2]!.argv.join(" ")).toContain("/home/openrig/topologies/rig-a.yaml");
});
});