Skip to content

Commit 86e3301

Browse files
committed
test(onboard): close managed state lifecycle gaps
Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 56c627f commit 86e3301

8 files changed

Lines changed: 162 additions & 37 deletions

File tree

src/lib/onboard.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,13 +1899,7 @@ async function createSandboxWithBaseImageResolution(
18991899
extraProviders: resolvedCreateIntent.extraProviders,
19001900
staleExtraProviders: resolvedCreateIntent.staleExtraProviders ?? [],
19011901
});
1902-
const dockerDriverGateway = isLinuxDockerDriverGatewayEnabled();
1903-
const hermesStateVolumeLifecycle = managedWorkloadOnboard.createManagedHermesStateVolumeOnboardLifecycle({
1904-
agentName: requestedAgentName,
1905-
runtimeProvider: managedWorkloadRuntime.runtimeProvider,
1906-
sandboxName,
1907-
workloadKind: preparedSandboxWorkload.source.kind,
1908-
});
1902+
const dockerDriverGateway = isLinuxDockerDriverGatewayEnabled(), hermesStateVolumeLifecycle = managedWorkloadOnboard.createManagedHermesStateVolumeOnboardLifecycle({ agentName: requestedAgentName, runtimeProvider: managedWorkloadRuntime.runtimeProvider, sandboxName, workloadKind: preparedSandboxWorkload.source.kind });
19091903
const { initialSandboxPolicy, policyTier: resolvedCreatePolicyTier, messagingProviders, gpuRoutePlan, compatibilityPolicyPath, initialGpuRoute, sandboxReadyTimeoutSecs, buildId, dashboardRemoteBindPrepared, legacyBuildContext, launch: { createArgv, effectiveDashboardPort, intendedSandboxStartupCommand, managedBootstrapIdentity, managedStartupRootApplyRequest, prebuild, sandboxEnv, sandboxStartupCommand } } = await managedWorkloadOnboard.prepareOnboardSandboxWorkloadLaunch({
19101904
runtime: managedWorkloadRuntime, workload: preparedSandboxWorkload,
19111905
legacy: { preparedBuildContext, agent, fromDockerfile, createAgentSandbox: (selectedAgent) => baseImageResolutionFlow.createAgentSandboxWithResolution(baseImageResolutionContext, selectedAgent, agentOnboard.createAgentSandbox), resolvePatchInput: () => ({ preparedBuildContext, agent, fromDockerfile, model, chatUiUrl, provider, endpointUrl: createIntent?.endpointUrl ?? null, compatibleEndpointReasoning: createIntent?.compatibleEndpointReasoning, preferredInferenceApi, webSearchConfig, toolDisclosure: effectiveToolDisclosure, rebuildPreservedEnv: createIntent?.rebuildPreservedEnv, ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), hermesToolGateways, sandboxGpuConfig: effectiveSandboxGpuConfig, ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), gatewayPort: GATEWAY_PORT }) },
@@ -2074,8 +2068,7 @@ async function createSandboxWithBaseImageResolution(
20742068
}),
20752069
},
20762070
);
2077-
hermesStateVolumeLifecycle.commit();
2078-
if ("complete" in recreateRuntime) recreateRuntime.complete();
2071+
hermesStateVolumeLifecycle.commit(); if ("complete" in recreateRuntime) recreateRuntime.complete();
20792072
restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization
20802073

20812074
// DNS proxy — run a forwarder in the sandbox pod so the isolated
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it } from "vitest";
5+
6+
import { containerPathsOverlap } from "./path-overlap";
7+
8+
describe("containerPathsOverlap", () => {
9+
it.each([
10+
["/sandbox/.hermes", "/sandbox/.hermes/"],
11+
["/sandbox/.hermes/", "/sandbox/.hermes/session"],
12+
["/var/lib/nemoclaw", "/var/lib/nemoclaw/managed-startup/"],
13+
["/", "/sandbox"],
14+
])("normalizes trailing slashes before detecting overlap", (left, right) => {
15+
expect(containerPathsOverlap(left, right)).toBe(true);
16+
expect(containerPathsOverlap(right, left)).toBe(true);
17+
});
18+
19+
it("keeps sibling paths distinct", () => {
20+
expect(containerPathsOverlap("/sandbox/.hermes", "/sandbox/.hermes-cache")).toBe(false);
21+
});
22+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
function normalizeContainerPath(value: string): string {
5+
return value.replace(/\/+$/u, "") || "/";
6+
}
7+
8+
export function containerPathsOverlap(left: string, right: string): boolean {
9+
const normalizedLeft = normalizeContainerPath(left);
10+
const normalizedRight = normalizeContainerPath(right);
11+
const contains = (parent: string, child: string): boolean =>
12+
parent === "/" ? child.startsWith("/") : child.startsWith(`${parent}/`);
13+
return (
14+
normalizedLeft === normalizedRight ||
15+
contains(normalizedLeft, normalizedRight) ||
16+
contains(normalizedRight, normalizedLeft)
17+
);
18+
}

src/lib/onboard/managed-bootstrap/podman-bootstrap-replacement.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
ContainerEngine,
1111
ContainerEngineCommandResult,
1212
} from "../../adapters/container-engine";
13+
import { containerPathsOverlap } from "../host-mount/path-overlap";
1314
import {
1415
PODMAN_BOOTSTRAP_JOURNAL_SCHEMA_VERSION,
1516
type PodmanBootstrapJournal,
@@ -104,8 +105,7 @@ interface PodmanBootstrapReplacementAuthority {
104105
readonly watcherLease: PodmanGatewayWatcherLease;
105106
}
106107

107-
export interface PrepareStoppedPodmanBootstrapReplacementInput
108-
extends PodmanBootstrapReplacementAuthority {
108+
export interface PrepareStoppedPodmanBootstrapReplacementInput extends PodmanBootstrapReplacementAuthority {
109109
readonly plan: PodmanBootstrapReplacementPlan;
110110
}
111111

@@ -114,8 +114,7 @@ export interface StopExactPodmanBootstrapOriginalInput extends PodmanBootstrapRe
114114
readonly heldWorkload: PodmanHeldWorkloadObservation;
115115
}
116116

117-
export interface RollbackPodmanBootstrapBeforeCommitInput
118-
extends PodmanBootstrapReplacementAuthority {
117+
export interface RollbackPodmanBootstrapBeforeCommitInput extends PodmanBootstrapReplacementAuthority {
119118
readonly bootstrapIdentity: string;
120119
readonly heldWorkload: PodmanHeldWorkloadObservation;
121120
}
@@ -322,10 +321,6 @@ function exactAbsolutePath(value: unknown, label: string): string {
322321
return target;
323322
}
324323

325-
function pathsOverlap(left: string, right: string): boolean {
326-
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
327-
}
328-
329324
function assertMountDoesNotShadowState(specification: string): void {
330325
const destinations = specification.split(",").flatMap((entry) => {
331326
const separator = entry.indexOf("=");
@@ -340,7 +335,7 @@ function assertMountDoesNotShadowState(specification: string): void {
340335
}
341336
for (const destination of destinations) {
342337
const normalized = exactAbsolutePath(destination, "Podman runtime mount destination");
343-
if (pathsOverlap(normalized, PODMAN_BOOTSTRAP_STATE_DIRECTORY)) {
338+
if (containerPathsOverlap(normalized, PODMAN_BOOTSTRAP_STATE_DIRECTORY)) {
344339
failure(
345340
`Podman replacement runtime arguments cannot shadow ${PODMAN_BOOTSTRAP_STATE_DIRECTORY}.`,
346341
false,

src/lib/onboard/managed-workload/onboard-orchestration.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,71 @@
33

44
import { describe, expect, it, vi } from "vitest";
55

6-
import { prepareOnboardSandboxWorkloadLaunch } from "./onboard-orchestration";
6+
import {
7+
createManagedHermesStateVolumeOnboardLifecycle,
8+
prepareOnboardSandboxWorkloadLaunch,
9+
} from "./onboard-orchestration";
710

811
describe("managed workload onboard orchestration", () => {
12+
it("keeps failure cleanup armed until the caller commits registration", () => {
13+
let volume: { name: string; labels: Record<string, string> } | null = null;
14+
let exitCleanup: (() => void) | null = null;
15+
const calls: string[][] = [];
16+
const runDocker = vi.fn((args: readonly string[]) => {
17+
const argv = [...args];
18+
calls.push(argv);
19+
if (argv[0] === "inspect") {
20+
return volume
21+
? {
22+
status: 0,
23+
stdout: `${JSON.stringify({ Name: volume.name, Labels: volume.labels })}\n`,
24+
}
25+
: { status: 1, stderr: "Error response from daemon: no such volume" };
26+
}
27+
if (argv[0] === "create") {
28+
const labels: Record<string, string> = {};
29+
for (let index = 1; index < argv.length - 1; index += 1) {
30+
if (argv[index] !== "--label") continue;
31+
const [name, ...value] = argv[index + 1]!.split("=");
32+
labels[name!] = value.join("=");
33+
index += 1;
34+
}
35+
volume = { name: argv.at(-1)!, labels };
36+
return { status: 0, stdout: `${volume.name}\n` };
37+
}
38+
if (argv[0] === "rm") {
39+
volume = null;
40+
return { status: 0, stdout: `${argv[1]}\n` };
41+
}
42+
return { status: 1, stderr: "unexpected Docker command" };
43+
});
44+
45+
const lifecycle = createManagedHermesStateVolumeOnboardLifecycle(
46+
{
47+
agentName: "hermes",
48+
runtimeProvider: { identity: { id: "docker" } } as never,
49+
sandboxName: "alpha",
50+
workloadKind: "managed-image",
51+
},
52+
{
53+
runDocker: runDocker as never,
54+
registerExitCleanup: (cleanup) => {
55+
exitCleanup = cleanup;
56+
return vi.fn();
57+
},
58+
},
59+
);
60+
61+
lifecycle.materializeSandboxCreatePlan({} as never, (input) => {
62+
expect(input.managedStateMount).toMatchObject({ target: "/sandbox/.hermes" });
63+
return {} as never;
64+
});
65+
exitCleanup!();
66+
67+
expect(volume).toBeNull();
68+
expect(calls.some((args) => args[0] === "rm")).toBe(true);
69+
});
70+
971
it("resolves final-image patch metadata after managed build-context staging", async () => {
1072
const resolutionMetadata = { key: "published-dcode-base" };
1173
let staged = false;

src/lib/onboard/managed-workload/onboard-orchestration.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { resolveSandboxWorkloadRuntimeCapabilities } from "../workload/runtime";
6262
import {
6363
prepareManagedHermesStateVolume,
6464
type ManagedHermesStateVolumeContext,
65+
type ManagedHermesStateVolumeDeps,
6566
} from "./hermes-state-volume";
6667

6768
type ManagedProfileInput = Omit<
@@ -85,13 +86,17 @@ export function createManagedHermesStateVolumeOnboardLifecycle(
8586
input: Omit<ManagedHermesStateVolumeContext, "runtimeProviderId"> & {
8687
readonly runtimeProvider: RuntimeProviderBundle | null;
8788
},
89+
deps: ManagedHermesStateVolumeDeps = {},
8890
): ManagedHermesStateVolumeOnboardLifecycle {
89-
const scope = prepareManagedHermesStateVolume({
90-
agentName: input.agentName,
91-
runtimeProviderId: input.runtimeProvider?.identity.id,
92-
sandboxName: input.sandboxName,
93-
workloadKind: input.workloadKind,
94-
});
91+
const scope = prepareManagedHermesStateVolume(
92+
{
93+
agentName: input.agentName,
94+
runtimeProviderId: input.runtimeProvider?.identity.id,
95+
sandboxName: input.sandboxName,
96+
workloadKind: input.workloadKind,
97+
},
98+
deps,
99+
);
95100
return {
96101
materializeSandboxCreatePlan(input, materialize) {
97102
return materialize({ ...input, managedStateMount: scope?.mount });

src/lib/onboard/sandbox-create-plan-materialization.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
SandboxCreateIntent,
99
SandboxCreateMessagingProviderRequest,
1010
} from "./sandbox-create-intent-types";
11+
import { containerPathsOverlap } from "./host-mount/path-overlap";
1112
import { prepareSandboxGpuRoutePolicies } from "./sandbox-gpu-route-policy";
1213

1314
type PrepareInitialSandboxCreatePolicy =
@@ -23,17 +24,6 @@ const DCODE_MCP_SNAPSHOT_TMPFS_MOUNT = {
2324
mode: 0o1777,
2425
} as const;
2526

26-
function pathsOverlap(left: string, right: string): boolean {
27-
const normalize = (value: string) => value.replace(/\/+$/u, "") || "/";
28-
const normalizedLeft = normalize(left);
29-
const normalizedRight = normalize(right);
30-
return (
31-
normalizedLeft === normalizedRight ||
32-
normalizedLeft.startsWith(`${normalizedRight}/`) ||
33-
normalizedRight.startsWith(`${normalizedLeft}/`)
34-
);
35-
}
36-
3727
function buildSandboxDriverConfig(
3828
intent: SandboxCreateIntent,
3929
managedStateMount: MaterializeSandboxCreatePlanInput["managedStateMount"],
@@ -43,7 +33,7 @@ function buildSandboxDriverConfig(
4333
);
4434
if (managedStateMount) {
4535
const conflictingHostMount = intent.hostMounts?.find(({ target }) =>
46-
pathsOverlap(target, managedStateMount.target),
36+
containerPathsOverlap(target, managedStateMount.target),
4737
);
4838
if (conflictingHostMount) {
4939
throw new Error(

test/helpers/managed-image-buildless-e2e.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ const registerCalls = [];
147147
const runnerCommands = [];
148148
const spawnCalls = [];
149149
let sandboxCreated = false;
150+
let managedHermesVolume = null;
150151
151152
// The protected live-E2E job intentionally runs source without build:cli.
152153
// Route the root CLI's generated shared-boundary import back to its canonical
@@ -402,6 +403,7 @@ replace(managedBootstrap, "createDockerManagedBootstrapAdapter", () => {
402403
403404
const runner = require(${source("src/lib/runner.ts")});
404405
runner.run = (command, options = {}) => {
406+
const argv = Array.isArray(command) ? command.map(String) : [];
405407
const normalized = normalize(command);
406408
runnerCommands.push(normalized);
407409
if (/(?:^|\s)docker(?:\s+buildx)?\s+build(?:\s|$)/u.test(normalized)) {
@@ -412,6 +414,25 @@ runner.run = (command, options = {}) => {
412414
? { status: 0, stdout: "Name: " + sandboxName + "\nId: sbx-managed-fixture\n", stderr: "" }
413415
: { status: 1, stdout: "", stderr: "sandbox not found" };
414416
}
417+
if (argv[0] === "docker" && argv[1] === "volume") {
418+
const volumeName = argv.at(-1);
419+
if (argv[2] === "inspect") {
420+
return managedHermesVolume
421+
? { status: 0, stdout: JSON.stringify(managedHermesVolume) + "\n", stderr: "" }
422+
: { status: 1, stdout: "", stderr: "Error response from daemon: no such volume" };
423+
}
424+
if (argv[2] === "create") {
425+
const labels = {};
426+
for (let index = 3; index < argv.length - 1; index += 1) {
427+
if (argv[index] !== "--label") continue;
428+
const [name, ...value] = argv[index + 1].split("=");
429+
labels[name] = value.join("=");
430+
index += 1;
431+
}
432+
managedHermesVolume = { Name: volumeName, Labels: labels };
433+
return { status: 0, stdout: volumeName + "\n", stderr: "" };
434+
}
435+
}
415436
return { status: 0, stdout: "", stderr: "" };
416437
};
417438
runner.runFile = (file, args = []) => runner.run([file, ...args]);
@@ -656,6 +677,25 @@ function assertManagedLaunch(
656677
const fromIndex = createArgs.indexOf("--from");
657678
expect(createArgs[fromIndex + 1]).toBe(expectedContract.reference);
658679
expect(createArgs.join(" ")).not.toContain("Dockerfile");
680+
if (agent === "hermes") {
681+
const driverConfigIndex = createArgs.indexOf("--driver-config-json");
682+
expect(driverConfigIndex).toBeGreaterThanOrEqual(0);
683+
expect(JSON.parse(createArgs[driverConfigIndex + 1]!) as unknown).toMatchObject({
684+
docker: {
685+
mounts: [
686+
{
687+
type: "volume",
688+
source: "nemoclaw-hermes-state-v1-managed-hermes",
689+
target: "/sandbox/.hermes",
690+
read_only: false,
691+
},
692+
],
693+
},
694+
});
695+
expect(
696+
result.payload.runnerCommands.some((command) => command.startsWith("docker volume create ")),
697+
).toBe(true);
698+
}
659699

660700
expect(createArgs.filter((arg) => arg.startsWith("NEMOCLAW_STARTUP_PROFILE_B64="))).toEqual([]);
661701
const encodedProfile = bootstrapRequest?.encodedProfile;

0 commit comments

Comments
 (0)