mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(testbed): L6 STEP-0 — stage the container-mode topology across the host/container boundary
The defect (operator receipt 1caca5be): the loader resolves a scenario's `topology` to a HOST absolute path and buildRealDeps forwards it UNCHANGED to a CONTAINER daemon (`rig up <hostPath>`), which reads it inside the container and reports a mystery "Source not found" — the same host-vs-container path assumption behind the /root staging block and the readable-not-writable block. Fix (the class, not the instance): a container-mode `stageTopology` seam on the ScenarioDaemon contract that stages the topology's DIRECTORY into the container (the fixtures reference culture.md + agents/ relatively, so siblings ride along) and returns the IN-CONTAINER path. The pipeline consumes it before `up`; host-mode omits the seam (identity — the host path passes through unchanged). The METHOD (tar-pipe plan + argv + expectFile fence) is imported from dev50's shared module — one method for runbook and adapter, no re-derivation. New scenario-container-stage.ts (makeContainerStageTopology) drives the plan's steps (mkdir → extract-with-tar-stdin → fence) through the docker seam, extended to carry the extract step's stdin. Loud+named on failure: any non-zero step (INCLUDING the fence, which now asserts the FILE arrived via expectFile — not just the mkdir'd dir) throws ContainerStageError with the step diagnostic PROPAGATED, so a masked tar failure (empty-but-writable stage) can never green through to an EACCES/not-found three steps downstream. The seam contract requires the invoker to check BOTH the tar and docker exits (a shell pipe masks the first). Harness-class only (test/helpers + test/) — no pack/image touch, no repin #5. VM gate: staging driver 3/3 (ordering + masked-tar-fail + fence-fail), adapter test green, parity fence unbroken, pipeline 17/17 (host-mode unregressed), daemon tsc 0. node_modules per-worktree (readlink verified inside the worktree, no primary-tree false-green). Docker L6 green is HOST-SIDE — handed off. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
4fa5ba48ea
commit
a8fdb565d9
5 files changed
+155
-3
No files matched your search
@@ -0,0 +1,69 @@
|
||||
// L6 STEP-0 — the container-mode TOPOLOGY STAGING adapter (the pipeline's half of the boundary).
|
||||
//
|
||||
// The defect it closes: the loader resolves a scenario's `topology` to a HOST absolute path, and
|
||||
// buildRealDeps forwards it UNCHANGED to a CONTAINER daemon (`rig up <hostPath>`), which reads it
|
||||
// INSIDE the container and reports a mystery "Source not found" — the same host-vs-container path
|
||||
// assumption behind the /root staging block and the readable-but-not-writable block.
|
||||
//
|
||||
// The METHOD lives in the shared module (testbed-published-daemon.ts: stageTopologyPlan + argv
|
||||
// helpers, one source of truth with the runbook). THIS file is only the pipeline INTEGRATION — it
|
||||
// drives the plan's steps through the injected docker seam. It stages the topology's DIRECTORY (the
|
||||
// fixtures reference culture.md + agents/ relatively, so they must ride along) and returns the
|
||||
// IN-CONTAINER path to the topology file — never the host absolute (containerStagePath throws on
|
||||
// absolute/'..' so a host path cannot leak through).
|
||||
|
||||
import { basename, dirname } from "node:path";
|
||||
import { stageTopologyPlan } from "./testbed-published-daemon.js";
|
||||
import type { DockerResult } from "./scenario-container.js";
|
||||
|
||||
/**
|
||||
* The injected docker seam, extended to carry a step's stdin. A step with `stdinFrom` (the tar-side
|
||||
* argv) runs `tar <stdinFrom>` piped into `docker <argv>` (the tar-pipe stage); a step without it is
|
||||
* a plain `docker <argv>`. The invoker MUST check BOTH the tar exit AND the docker exit and report a
|
||||
* non-zero code if EITHER fails — a shell pipeline reports only the last command's status, so a
|
||||
* failing tar into a succeeding `docker exec` would otherwise mask an empty stage (the false-green
|
||||
* class). Real host-side invoker implements the two-process pipe + dual-exit check; the VM fake
|
||||
* models both, including the tar-fails/docker-succeeds case.
|
||||
*/
|
||||
export type StagingDocker = (args: string[], stdinFrom?: string[]) => Promise<DockerResult>;
|
||||
|
||||
/** Loud, typed failure — a silent stage/fence failure is exactly the false-green class we keep
|
||||
* killing (an empty or unwritable stage looks fine until `rig up` EACCESes three steps downstream). */
|
||||
export class ContainerStageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ContainerStageError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container-mode `stageTopology`: stage the host topology file's DIRECTORY into the container's
|
||||
* exec-user stage, then return the in-container path to the topology file. Host-mode never wires this
|
||||
* (identity — the host daemon reads the host path directly). Drives the plan's steps IN ORDER
|
||||
* (mkdir → extract → fence); the fence runs AFTER extraction (an empty stage passes a naive read
|
||||
* check) and BEFORE the path is consumed, or the first symptom is EACCES downstream, not a named
|
||||
* failure here. Any non-zero step (incl. the fence) throws with the step's diagnostic PROPAGATED.
|
||||
*/
|
||||
export function makeContainerStageTopology(
|
||||
container: string,
|
||||
docker: StagingDocker,
|
||||
): (hostTopologyPath: string) => Promise<string> {
|
||||
return async (hostTopologyPath: string): Promise<string> => {
|
||||
const hostDir = dirname(hostTopologyPath);
|
||||
// expectFile = the topology's basename: the fence asserts the FILE arrived, not just the dir
|
||||
// mkdir created — so a shipped-nothing delivery (masked tar failure) is unrepresentable.
|
||||
const plan = stageTopologyPlan({ container, hostDir, name: "topologies", expectFile: basename(hostTopologyPath) });
|
||||
for (const step of plan.steps) {
|
||||
const res = await docker(step.argv, step.stdinFrom);
|
||||
if (res.code !== 0) {
|
||||
throw new ContainerStageError(
|
||||
`container stage step '${step.label}' failed (exit ${res.code}) in ${container}: ` +
|
||||
`${res.stderr || res.stdout || "(no output)"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The in-container topology path: the staged dir + the topology's basename. NEVER the host
|
||||
// absolute — plan.stagePath came from containerStagePath (throws on absolute/'..').
|
||||
return `${plan.stagePath}/${basename(hostTopologyPath)}`;
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { assertNoForeignDaemon, type HermeticScaffold } from "./hermetic-env.js";
|
||||
import { type ScenarioDaemon } from "./scenario-daemon.js";
|
||||
import { makeContainerStageTopology, type StagingDocker } from "./scenario-container-stage.js";
|
||||
// The ONE published-daemon procedure — imported, never re-derived (drift is what the
|
||||
// module's parity fence exists to catch): explicit 0.0.0.0 bind + the bearer that bind
|
||||
// demands, the unqualified `P:C` publish, and the explicit non-ephemeral host port.
|
||||
@@ -48,8 +49,9 @@ export class ContainerDaemonError extends Error {
|
||||
export interface SpawnContainerDaemonOptions {
|
||||
/** The testbed image identity to run (manifest.image, e.g. "openrig-testbed:<gitSha>"). */
|
||||
image: string;
|
||||
/** Injected docker invoker — the container analogue of runRig's `node <rigBin>` seam. */
|
||||
docker: (args: string[]) => Promise<DockerResult>;
|
||||
/** Injected docker invoker — the container analogue of runRig's `node <rigBin>` seam. Extended to
|
||||
* carry a step's stdin (the topology-stage tar-pipe); see StagingDocker for the dual-exit contract. */
|
||||
docker: StagingDocker;
|
||||
/** The daemon port INSIDE the container (default CONTAINER_PORT = 7433). */
|
||||
containerPort?: number;
|
||||
/** Override the published host port. Default L3_HOST_PORT (19433) — EXPLICIT + non-ephemeral;
|
||||
@@ -159,5 +161,9 @@ export async function spawnContainerDaemon(
|
||||
await docker(["rm", "-f", containerId]).catch(() => {});
|
||||
scaffold.cleanup();
|
||||
},
|
||||
// L6 STEP-0 — container-mode path translation: stage the topology's dir into the container and
|
||||
// return its in-container path, so `rig up` never receives a host-absolute path the container
|
||||
// daemon cannot read. buildRealDeps/the pipeline consumes this before `up`; host-mode omits it.
|
||||
stageTopology: makeContainerStageTopology(containerId, docker),
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,13 @@ export interface ScenarioDaemon {
|
||||
restart: () => Promise<void>;
|
||||
/** Stop the daemon (via the shipped `rig daemon stop`) and remove the scaffold. */
|
||||
stop: () => Promise<void>;
|
||||
/**
|
||||
* L6 STEP-0 — translate a HOST topology path to a path the daemon can read. Host-mode omits this
|
||||
* (identity: the daemon reads the host path directly). CONTAINER-mode implements it by staging the
|
||||
* topology's directory INTO the container and returning the in-container path, so `rig up` is never
|
||||
* handed a host-absolute path a container daemon cannot resolve ("Source not found").
|
||||
*/
|
||||
stageTopology?: (hostTopologyPath: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface SpawnScenarioDaemonOptions {
|
||||
|
||||
@@ -251,10 +251,17 @@ export async function runScenarioFile(
|
||||
mkdirSync(seatCwd, { recursive: true });
|
||||
const daemon = await resolveScenarioDaemonSpawner(opts)(scaffold, opts);
|
||||
try {
|
||||
// L6 STEP-0 — container-mode translates the HOST topology path to the in-container staged path
|
||||
// (host-mode has no stageTopology → the host path passes through unchanged). A stage/fence
|
||||
// failure throws here (loud, named) and the finally tears the container down — never a mystery
|
||||
// "Source not found" from `rig up` reading a host path it cannot see inside the container.
|
||||
const upTopologyPath = daemon.stageTopology
|
||||
? await daemon.stageTopology(loaded.loaded.topologyPath)
|
||||
: loaded.loaded.topologyPath;
|
||||
const baseDeps = buildRealDeps({
|
||||
daemon,
|
||||
rigBin: opts.rigBin,
|
||||
topologyPath: loaded.loaded.topologyPath,
|
||||
topologyPath: upTopologyPath,
|
||||
seatCwd,
|
||||
...opts.deps,
|
||||
// Container-mode stamps the image id onto every ledger row; host-mode (no
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
makeContainerStageTopology,
|
||||
ContainerStageError,
|
||||
type StagingDocker,
|
||||
} from "./helpers/scenario-container-stage.js";
|
||||
import { CONTAINER_STAGE_ROOT } from "./helpers/testbed-published-daemon.js";
|
||||
|
||||
// L6 STEP-0 — the container-mode topology-staging driver. It drives the shared module's plan
|
||||
// (mkdir → extract-with-tar-stdin → fence) through the injected docker seam and returns the
|
||||
// IN-CONTAINER topology path, never the host absolute. The fence targets the FILE (expectFile), so a
|
||||
// shipped-nothing delivery — a masked tar failure that leaves an empty but readable+writable stage —
|
||||
// is unrepresentable. Runs hermetically with a fake StagingDocker; the real-docker leg is host-side.
|
||||
|
||||
const CONTAINER = "c0ffeeb0ba";
|
||||
const HOST_TOPO = "/host/fixtures/scenarios/topo-stub-baton.yaml";
|
||||
const STAGE = `${CONTAINER_STAGE_ROOT}/topologies`;
|
||||
|
||||
/** A recording fake for the extended (stdin-carrying) docker seam. `fail(label)` returns a non-zero
|
||||
* exit for a given step — including the tar-fails/docker-succeeds case, which the real invoker
|
||||
* surfaces as a non-zero extract because it checks BOTH exits (a shell would mask it). */
|
||||
function fakeStagingDocker(fail?: (label: string) => number) {
|
||||
const calls: Array<{ args: string[]; stdinFrom?: string[] }> = [];
|
||||
const label = (args: string[], stdinFrom?: string[]) =>
|
||||
args.includes("mkdir") ? "mkdir" : stdinFrom ? "extract" : "fence";
|
||||
const docker: StagingDocker = vi.fn(async (args, stdinFrom) => {
|
||||
calls.push({ args, stdinFrom });
|
||||
const l = label(args, stdinFrom);
|
||||
const code = fail?.(l) ?? 0;
|
||||
return { stdout: "", stderr: code ? `boom-${l}` : "", code };
|
||||
});
|
||||
return { docker, calls };
|
||||
}
|
||||
|
||||
describe("makeContainerStageTopology — stage the topology dir into the container", () => {
|
||||
it("drives mkdir → extract(tar stdin) → fence IN ORDER and returns the in-container path", async () => {
|
||||
const { docker, calls } = fakeStagingDocker();
|
||||
const containerPath = await makeContainerStageTopology(CONTAINER, docker)(HOST_TOPO);
|
||||
|
||||
expect(containerPath).toBe(`${STAGE}/topo-stub-baton.yaml`); // in-container, never the host absolute
|
||||
expect(calls.map((c) => (c.args.includes("mkdir") ? "mkdir" : c.stdinFrom ? "extract" : "fence")))
|
||||
.toEqual(["mkdir", "extract", "fence"]); // ordering is the contract
|
||||
expect(calls[0]!.args).toEqual(["exec", CONTAINER, "mkdir", "-p", STAGE]);
|
||||
// extract carries the tar-side argv as stdin — tar the HOST DIR (siblings ride along: culture.md, agents/)
|
||||
expect(calls[1]!.args).toEqual(["exec", "-i", CONTAINER, "tar", "-C", STAGE, "-xf", "-"]);
|
||||
expect(calls[1]!.stdinFrom).toEqual(["-C", "/host/fixtures/scenarios", "-cf", "-", "."]);
|
||||
// the fence asserts the FILE arrived (expectFile), not just the mkdir'd dir
|
||||
expect(calls[2]!.args.join(" ")).toContain(`test -r '${STAGE}/topo-stub-baton.yaml'`);
|
||||
});
|
||||
|
||||
it("a masked tar failure (extract non-zero) fails LOUD + NAMED — not a silent empty stage", async () => {
|
||||
const { docker } = fakeStagingDocker((l) => (l === "extract" ? 2 : 0));
|
||||
await expect(makeContainerStageTopology(CONTAINER, docker)(HOST_TOPO))
|
||||
.rejects.toThrow(/stage step 'extract' failed \(exit 2\).*boom-extract/s);
|
||||
});
|
||||
|
||||
it("a fence failure (content did NOT arrive) fails LOUD + NAMED at the fence, not 3 steps later", async () => {
|
||||
const { docker } = fakeStagingDocker((l) => (l === "fence" ? 1 : 0));
|
||||
const p = makeContainerStageTopology(CONTAINER, docker)(HOST_TOPO);
|
||||
await expect(p).rejects.toThrow(ContainerStageError);
|
||||
await expect(p).rejects.toThrow(/stage step 'fence' failed \(exit 1\)/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user