diff --git a/src/lib/onboard/managed-bootstrap/README.md b/src/lib/onboard/managed-bootstrap/README.md index 32213dd1249..adde636fa56 100644 --- a/src/lib/onboard/managed-bootstrap/README.md +++ b/src/lib/onboard/managed-bootstrap/README.md @@ -66,6 +66,11 @@ including its supervisor environment, to immutable prepared authority before activation. The native boundary introduces no driver-specific environment policy. +The first Docker-specific groundwork defines a private, monotonic cutover +journal and a canonical launch-spec normalizer. Each surface is independently +validated and remains dormant: no registered runtime provider imports either +module, and neither changes sandbox creation or lifecycle behavior. + ## Architectural disposition The coordinator deliberately lands as a dormant trust-boundary slice before a diff --git a/src/lib/onboard/managed-bootstrap/docker-journal.test.ts b/src/lib/onboard/managed-bootstrap/docker-journal.test.ts new file mode 100644 index 00000000000..7ed0d8bba6a --- /dev/null +++ b/src/lib/onboard/managed-bootstrap/docker-journal.test.ts @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createFileDockerManagedBootstrapJournalStore, + DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY, + DOCKER_MANAGED_BOOTSTRAP_JOURNAL_SCHEMA_VERSION, + type DockerManagedBootstrapJournal, + parseDockerManagedBootstrapJournal, + serializeDockerManagedBootstrapJournal, +} from "./docker-journal"; + +const roots: string[] = []; +const IDENTITY = "1".repeat(64); +const journal = Object.freeze({ + schemaVersion: DOCKER_MANAGED_BOOTSTRAP_JOURNAL_SCHEMA_VERSION, + phase: "staged", + bootstrapIdentity: IDENTITY, + sandbox: { + sandboxName: "alpha", + sandboxId: "sandbox-alpha", + driverId: "docker", + }, + profileFingerprint: "2".repeat(64), + imageReference: `registry.example/image@sha256:${"3".repeat(64)}`, + runtimeImageContentId: `sha256:${"4".repeat(64)}`, + originalRuntimeId: "5".repeat(64), + replacementRuntimeId: "6".repeat(64), + originalName: "openshell-alpha", + replacementStagingName: "openshell-alpha-staged", + backupName: "openshell-alpha-backup", + originalSpecHash: "7".repeat(64), + replacementSpecHash: "8".repeat(64), +} satisfies DockerManagedBootstrapJournal); + +function readPinnedPrivateFile(target: string): { readonly mode: number; readonly text: string } { + const descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + const text = fs.readFileSync(descriptor, "utf8"); + const after = fs.fstatSync(descriptor, { bigint: true }); + expect(after.dev).toBe(before.dev); + expect(after.ino).toBe(before.ino); + expect(after.size).toBe(before.size); + expect(after.mtimeNs).toBe(before.mtimeNs); + expect(after.ctimeNs).toBe(before.ctimeNs); + return { mode: Number(before.mode & 0o777n), text }; + } finally { + fs.closeSync(descriptor); + } +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("Docker managed bootstrap journal", () => { + it("publishes private canonical state through only monotonic phases", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-journal-")); + roots.push(root); + const store = createFileDockerManagedBootstrapJournalStore(root); + store.create(journal); + const directory = path.join(root, DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY); + const file = path.join(directory, `${IDENTITY}.json`); + expect(fs.statSync(directory).mode & 0o777).toBe(0o700); + const persisted = readPinnedPrivateFile(file); + expect(persisted.mode).toBe(0o600); + expect(parseDockerManagedBootstrapJournal(persisted.text)).toEqual(journal); + expect(() => store.create(journal)).toThrow("already exists"); + expect(() => store.transition(IDENTITY, "staged", "shared-state-committed")).toThrow( + "unsupported", + ); + + expect(store.transition(IDENTITY, "staged", "cutover").phase).toBe("cutover"); + expect(store.transition(IDENTITY, "cutover", "shared-state-committed").phase).toBe( + "shared-state-committed", + ); + store.remove(IDENTITY, ["shared-state-committed"]); + expect(store.load(IDENTITY)).toBeNull(); + }); + + it("recovers one durable cutover decision before journal replacement", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-journal-")); + roots.push(root); + const store = createFileDockerManagedBootstrapJournalStore(root); + store.create(journal); + store.transition(IDENTITY, "staged", "cutover"); + const file = path.join(root, DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY, `${IDENTITY}.json`); + fs.writeFileSync(`${file}.decision`, "rollback-authorized\n", { mode: 0o600 }); + + expect(store.load(IDENTITY)?.phase).toBe("rollback-authorized"); + expect(parseDockerManagedBootstrapJournal(readPinnedPrivateFile(file).text).phase).toBe( + "rollback-authorized", + ); + fs.unlinkSync(`${file}.decision`); + expect(store.load(IDENTITY)?.phase).toBe("rollback-authorized"); + expect(() => store.transition(IDENTITY, "cutover", "shared-state-committed")).toThrow( + "expected phase cutover", + ); + store.remove(IDENTITY, ["rollback-authorized"]); + }); + + it("reconciles an exclusive decision collision by typed durable authority", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-journal-")); + roots.push(root); + const store = createFileDockerManagedBootstrapJournalStore(root); + store.create(journal); + store.transition(IDENTITY, "staged", "cutover"); + const target = path.join( + root, + DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY, + `${IDENTITY}.json.decision`, + ); + const link = vi.spyOn(fs, "linkSync").mockImplementationOnce(() => { + fs.writeFileSync(target, "rollback-authorized\n", { flag: "wx", mode: 0o600 }); + throw Object.assign(new Error("exclusive decision collision"), { code: "EEXIST" }); + }); + try { + expect(store.transition(IDENTITY, "cutover", "rollback-authorized").phase).toBe( + "rollback-authorized", + ); + } finally { + link.mockRestore(); + } + }); + + it("preserves a primary journal write failure when temporary cleanup also fails", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-journal-")); + roots.push(root); + const store = createFileDockerManagedBootstrapJournalStore(root); + store.create(journal); + const rename = vi.spyOn(fs, "renameSync").mockImplementationOnce(() => { + throw new Error("primary journal rename failure"); + }); + const unlink = vi.spyOn(fs, "unlinkSync").mockImplementationOnce(() => { + throw new Error("temporary cleanup failure"); + }); + try { + expect(() => store.transition(IDENTITY, "staged", "cutover")).toThrow( + "primary journal rename failure", + ); + } finally { + rename.mockRestore(); + unlink.mockRestore(); + } + }); + + it.skipIf(process.platform === "win32")("refuses a symlink in place of journal authority", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-journal-")); + roots.push(root); + const store = createFileDockerManagedBootstrapJournalStore(root); + store.create(journal); + const file = path.join(root, DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY, `${IDENTITY}.json`); + const moved = `${file}.moved`; + fs.renameSync(file, moved); + fs.symlinkSync(moved, file); + + expect(() => store.load(IDENTITY)).toThrow("journal file ownership boundary is invalid"); + }); + + it("rejects non-canonical authority", () => { + expect(() => + parseDockerManagedBootstrapJournal(`${JSON.stringify({ ...journal, phase: "unknown" })}\n`), + ).toThrow("phase is unsupported"); + expect( + serializeDockerManagedBootstrapJournal(Object.freeze({ ...journal, phase: "staged" })), + ).toBe(`${JSON.stringify(journal)}\n`); + }); +}); diff --git a/src/lib/onboard/managed-bootstrap/docker-journal.ts b/src/lib/onboard/managed-bootstrap/docker-journal.ts new file mode 100644 index 00000000000..c6043409d7d --- /dev/null +++ b/src/lib/onboard/managed-bootstrap/docker-journal.ts @@ -0,0 +1,448 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import type { ManagedBootstrapSandboxIdentity } from "./adapter"; + +export const DOCKER_MANAGED_BOOTSTRAP_JOURNAL_SCHEMA_VERSION = 1 as const; +export const DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY = "managed-bootstrap"; + +const SHA256_RE = /^[a-f0-9]{64}$/u; +const MAX_JOURNAL_BYTES = 32 * 1024; +const JOURNAL_DIRECTORY_MODE = 0o700; +const JOURNAL_FILE_MODE = 0o600; +const DECISION_PHASES = new Set([ + "rollback-authorized", + "shared-state-committed", +]); + +export type DockerManagedBootstrapJournalPhase = + | "staged" + | "cutover" + | "rollback-authorized" + | "shared-state-committed"; + +export interface DockerManagedBootstrapJournal { + readonly schemaVersion: typeof DOCKER_MANAGED_BOOTSTRAP_JOURNAL_SCHEMA_VERSION; + readonly phase: DockerManagedBootstrapJournalPhase; + readonly bootstrapIdentity: string; + readonly sandbox: ManagedBootstrapSandboxIdentity; + readonly profileFingerprint: string; + readonly imageReference: string; + readonly runtimeImageContentId: string; + readonly originalRuntimeId: string; + readonly replacementRuntimeId: string; + readonly originalName: string; + readonly replacementStagingName: string; + readonly backupName: string; + readonly originalSpecHash: string; + readonly replacementSpecHash: string; +} + +export interface DockerManagedBootstrapJournalStore { + create(journal: DockerManagedBootstrapJournal): void; + load(bootstrapIdentity: string): DockerManagedBootstrapJournal | null; + transition( + bootstrapIdentity: string, + expected: DockerManagedBootstrapJournalPhase, + next: DockerManagedBootstrapJournalPhase, + ): DockerManagedBootstrapJournal; + remove(bootstrapIdentity: string, expected: readonly DockerManagedBootstrapJournalPhase[]): void; +} + +/** + * Alternate stores may use this only when the durable mutation completed and + * the caller lost its acknowledgement. Ordinary I/O and fsync failures must + * retain their original error type and are never reconciled as success. + */ +export class DockerManagedBootstrapJournalAcknowledgementLostError extends Error { + constructor(message: string) { + super(message); + this.name = "DockerManagedBootstrapJournalAcknowledgementLostError"; + } +} + +class DockerManagedBootstrapJournalExistsError extends Error { + constructor() { + super( + "Managed bootstrap Docker journal is invalid: journal already exists for this bootstrap identity", + ); + this.name = "DockerManagedBootstrapJournalExistsError"; + } +} + +const ALLOWED_TRANSITIONS = new Set([ + "staged->cutover", + "cutover->rollback-authorized", + "cutover->shared-state-committed", +]); + +function fail(message: string): never { + throw new Error(`Managed bootstrap Docker journal is invalid: ${message}`); +} + +function exactString(value: unknown, label: string, maxBytes = 4096): string { + if ( + typeof value !== "string" || + value.length === 0 || + value !== value.trim() || + value.includes("\0") || + Buffer.byteLength(value, "utf8") > maxBytes + ) { + fail(`${label} must be one bounded exact string`); + } + return value; +} + +function exactSha256(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA256_RE.test(value)) { + fail(`${label} must be lowercase SHA-256`); + } + return value; +} + +function exactPhase(value: unknown): DockerManagedBootstrapJournalPhase { + if ( + !["staged", "cutover", "rollback-authorized", "shared-state-committed"].includes(String(value)) + ) { + fail("phase is unsupported"); + } + return value as DockerManagedBootstrapJournalPhase; +} + +function exactSandbox(value: unknown): ManagedBootstrapSandboxIdentity { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail("sandbox identity must be an object"); + } + const sandbox = value as Record; + if (Object.keys(sandbox).sort().join(",") !== "driverId,sandboxId,sandboxName") { + fail("sandbox identity schema is invalid"); + } + return Object.freeze({ + sandboxName: exactString(sandbox.sandboxName, "sandbox name"), + sandboxId: exactString(sandbox.sandboxId, "sandbox ID"), + driverId: exactString(sandbox.driverId, "driver ID"), + }); +} + +export function normalizeDockerManagedBootstrapJournal( + value: unknown, +): DockerManagedBootstrapJournal { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail("journal must be an object"); + } + const journal = value as Record; + const expectedKeys = [ + "backupName", + "bootstrapIdentity", + "imageReference", + "originalName", + "originalRuntimeId", + "originalSpecHash", + "phase", + "profileFingerprint", + "replacementRuntimeId", + "replacementSpecHash", + "replacementStagingName", + "runtimeImageContentId", + "sandbox", + "schemaVersion", + ]; + if ( + Object.keys(journal).sort().join(",") !== expectedKeys.sort().join(",") || + journal.schemaVersion !== DOCKER_MANAGED_BOOTSTRAP_JOURNAL_SCHEMA_VERSION + ) { + fail("journal schema is invalid"); + } + const normalized = Object.freeze({ + schemaVersion: DOCKER_MANAGED_BOOTSTRAP_JOURNAL_SCHEMA_VERSION, + phase: exactPhase(journal.phase), + bootstrapIdentity: exactSha256(journal.bootstrapIdentity, "bootstrap identity"), + sandbox: exactSandbox(journal.sandbox), + profileFingerprint: exactSha256(journal.profileFingerprint, "profile fingerprint"), + imageReference: exactString(journal.imageReference, "image reference"), + runtimeImageContentId: exactString(journal.runtimeImageContentId, "runtime image content ID"), + originalRuntimeId: exactSha256(journal.originalRuntimeId, "original runtime ID"), + replacementRuntimeId: exactSha256(journal.replacementRuntimeId, "replacement runtime ID"), + originalName: exactString(journal.originalName, "original name", 253), + replacementStagingName: exactString( + journal.replacementStagingName, + "replacement staging name", + 253, + ), + backupName: exactString(journal.backupName, "backup name", 253), + originalSpecHash: exactSha256(journal.originalSpecHash, "original spec hash"), + replacementSpecHash: exactSha256(journal.replacementSpecHash, "replacement spec hash"), + } satisfies DockerManagedBootstrapJournal); + if (normalized.originalRuntimeId === normalized.replacementRuntimeId) { + fail("original and replacement runtime IDs must differ"); + } + if ( + new Set([normalized.originalName, normalized.replacementStagingName, normalized.backupName]) + .size !== 3 + ) { + fail("original, staging, and backup names must be distinct"); + } + return normalized; +} + +export function serializeDockerManagedBootstrapJournal( + journal: DockerManagedBootstrapJournal, +): string { + const normalized = normalizeDockerManagedBootstrapJournal(journal); + const serialized = `${JSON.stringify(normalized)}\n`; + if (Buffer.byteLength(serialized, "utf8") > MAX_JOURNAL_BYTES) { + fail("serialized journal exceeds its bounded transport"); + } + return serialized; +} + +export function parseDockerManagedBootstrapJournal(text: string): DockerManagedBootstrapJournal { + if ( + text.length === 0 || + text.includes("\0") || + Buffer.byteLength(text, "utf8") > MAX_JOURNAL_BYTES + ) { + fail("serialized journal is empty or too large"); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + fail("serialized journal is not valid JSON"); + } + const journal = normalizeDockerManagedBootstrapJournal(parsed); + if (serializeDockerManagedBootstrapJournal(journal) !== text) { + fail("serialized journal is not canonical"); + } + return journal; +} + +function assertDirectory(directory: string): void { + fs.mkdirSync(directory, { recursive: true, mode: JOURNAL_DIRECTORY_MODE }); + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) { + fail("journal directory must be a private real directory"); + } +} + +function journalPath(directory: string, bootstrapIdentity: string): string { + exactSha256(bootstrapIdentity, "bootstrap identity"); + return path.join(directory, `${bootstrapIdentity}.json`); +} + +function decisionPath(target: string): string { + return `${target}.decision`; +} + +function sameStableMetadata(left: fs.BigIntStats, right: fs.BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.uid === right.uid && + left.gid === right.gid && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function readPrivateFile(target: string, label: string): string | null { + const noFollow = fs.constants.O_NOFOLLOW; + if (typeof noFollow !== "number") { + fail(`cannot safely open ${label} because O_NOFOLLOW is unavailable`); + } + const nonblock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollow | nonblock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + if ((error as NodeJS.ErrnoException).code === "ELOOP") { + fail(`${label} file ownership boundary is invalid`); + } + throw error; + } + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + before.nlink !== 1n || + (before.mode & 0o077n) !== 0n || + before.size <= 0n || + before.size > BigInt(MAX_JOURNAL_BYTES) + ) { + fail(`${label} file ownership boundary is invalid`); + } + const contents = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < contents.length) { + const count = fs.readSync(descriptor, contents, offset, contents.length - offset, offset); + if (count === 0) break; + offset += count; + } + const overflow = Buffer.alloc(1); + const overflowCount = fs.readSync(descriptor, overflow, 0, 1, offset); + const after = fs.fstatSync(descriptor, { bigint: true }); + if (offset !== contents.length || overflowCount !== 0 || !sameStableMetadata(before, after)) { + fail(`${label} file changed during its stable read`); + } + return contents.toString("utf8"); + } finally { + fs.closeSync(descriptor); + } +} + +function fsyncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, "r"); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function atomicWrite( + directory: string, + target: string, + contents: string, + exclusive: boolean, +): void { + const temporary = path.join( + directory, + `.${path.basename(target)}.${process.pid}.${Date.now().toString(16)}.tmp`, + ); + let descriptor: number | null = null; + let primaryFailure: { readonly error: unknown } | null = null; + try { + descriptor = fs.openSync(temporary, "wx", JOURNAL_FILE_MODE); + fs.writeFileSync(descriptor, contents, "utf8"); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + if (exclusive) { + try { + fs.linkSync(temporary, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new DockerManagedBootstrapJournalExistsError(); + } + throw error; + } + fs.unlinkSync(temporary); + } else { + fs.renameSync(temporary, target); + } + fs.chmodSync(target, JOURNAL_FILE_MODE); + fsyncDirectory(directory); + } catch (error) { + primaryFailure = { error }; + } + let cleanupFailure: { readonly error: unknown } | null = null; + if (descriptor !== null) { + try { + fs.closeSync(descriptor); + } catch (error) { + cleanupFailure = { error }; + } + } + try { + fs.unlinkSync(temporary); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" && cleanupFailure === null) { + cleanupFailure = { error }; + } + } + if (primaryFailure !== null) throw primaryFailure.error; + if (cleanupFailure !== null) throw cleanupFailure.error; +} + +export function createFileDockerManagedBootstrapJournalStore( + stateRoot: string, +): DockerManagedBootstrapJournalStore { + const directory = path.join(stateRoot, DOCKER_MANAGED_BOOTSTRAP_JOURNAL_DIRECTORY); + const load = (bootstrapIdentity: string): DockerManagedBootstrapJournal | null => { + assertDirectory(directory); + const target = journalPath(directory, bootstrapIdentity); + const contents = readPrivateFile(target, "journal"); + if (contents === null) return null; + const journal = parseDockerManagedBootstrapJournal(contents); + const decision = readPrivateFile(decisionPath(target), "decision"); + if (decision === null) return journal; + const phase = decision.endsWith("\n") ? decision.slice(0, -1) : ""; + if ( + !DECISION_PHASES.has(phase as DockerManagedBootstrapJournalPhase) || + (journal.phase !== "cutover" && journal.phase !== phase) + ) { + fail("decision does not match its cutover journal"); + } + const decided = normalizeDockerManagedBootstrapJournal({ ...journal, phase }); + if (journal.phase === "cutover") { + atomicWrite(directory, target, serializeDockerManagedBootstrapJournal(decided), false); + } + return decided; + }; + return Object.freeze({ + create(journal: DockerManagedBootstrapJournal) { + const normalized = normalizeDockerManagedBootstrapJournal(journal); + assertDirectory(directory); + const target = journalPath(directory, normalized.bootstrapIdentity); + if (readPrivateFile(decisionPath(target), "decision") !== null) { + fail("stale decision exists for this bootstrap identity"); + } + atomicWrite(directory, target, serializeDockerManagedBootstrapJournal(normalized), true); + }, + load, + transition( + bootstrapIdentity: string, + expected: DockerManagedBootstrapJournalPhase, + next: DockerManagedBootstrapJournalPhase, + ) { + if (!ALLOWED_TRANSITIONS.has(`${expected}->${next}`)) { + fail(`transition ${expected} to ${next} is unsupported`); + } + assertDirectory(directory); + const target = journalPath(directory, bootstrapIdentity); + const current = load(bootstrapIdentity); + if (current?.phase === next) return current; + if (!current || current.phase !== expected) { + fail(`expected phase ${expected} before transition to ${next}`); + } + const updated = normalizeDockerManagedBootstrapJournal({ ...current, phase: next }); + if (expected === "cutover") { + const decision = decisionPath(target); + try { + atomicWrite(directory, decision, `${next}\n`, true); + } catch (error) { + if ( + !(error instanceof DockerManagedBootstrapJournalExistsError) || + readPrivateFile(decision, "decision") !== `${next}\n` + ) { + throw error; + } + } + } + atomicWrite(directory, target, serializeDockerManagedBootstrapJournal(updated), false); + return updated; + }, + remove(bootstrapIdentity: string, expected: readonly DockerManagedBootstrapJournalPhase[]) { + assertDirectory(directory); + const target = journalPath(directory, bootstrapIdentity); + const current = load(bootstrapIdentity); + if (!current || !expected.includes(current.phase)) { + fail(`journal removal is not authorized from phase ${current?.phase ?? "absent"}`); + } + const decision = decisionPath(target); + if (readPrivateFile(decision, "decision") !== null) { + fs.unlinkSync(decision); + fsyncDirectory(directory); + } + fs.unlinkSync(target); + fsyncDirectory(directory); + }, + }); +} diff --git a/src/lib/onboard/managed-bootstrap/docker-spec.test.ts b/src/lib/onboard/managed-bootstrap/docker-spec.test.ts new file mode 100644 index 00000000000..3a8da33b9af --- /dev/null +++ b/src/lib/onboard/managed-bootstrap/docker-spec.test.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createDockerGpuInspectFixture } from "../__test-helpers__/docker-gpu-patch-fixtures"; +import { + normalizeDockerManagedBootstrapLaunchSpec, + parseDockerManagedBootstrapLaunchSpec, +} from "./docker-spec"; + +describe("managed bootstrap Docker launch spec", () => { + it("hashes reproducible launch state while excluding runtime ID, phase, IP, and gateway", () => { + const first = createDockerGpuInspectFixture(); + const second = structuredClone(first); + second.Id = "another-runtime-id"; + Object.assign(second, { State: { Running: false, Dead: true } }); + second.NetworkSettings!.Networks!["openshell-docker"]!.IPAddress = "172.18.0.99"; + second.NetworkSettings!.Networks!["openshell-docker"]!.Gateway = "172.18.0.254"; + + const expected = normalizeDockerManagedBootstrapLaunchSpec(first); + const observed = normalizeDockerManagedBootstrapLaunchSpec(second); + + expect(observed.hash).toBe(expected.hash); + expect(observed.canonicalJson).toBe(expected.canonicalJson); + expect(parseDockerManagedBootstrapLaunchSpec(expected.canonicalJson)).toEqual(expected.spec); + }); + + it("changes the hash when a reproducible launch field changes", () => { + const first = createDockerGpuInspectFixture(); + const second = structuredClone(first); + Object.assign(second.Config!, { StopTimeout: 45 }); + + expect(normalizeDockerManagedBootstrapLaunchSpec(second).hash).not.toBe( + normalizeDockerManagedBootstrapLaunchSpec(first).hash, + ); + }); + + it("orders durable launch keys by code unit across host locale settings", () => { + const inspect = createDockerGpuInspectFixture(); + inspect.Config!.Labels = { + "com.nvidia.foo": "lower", + "com.nvidia.Foo": "upper", + "com.nvidia-foo": "punctuation", + }; + + const canonical = normalizeDockerManagedBootstrapLaunchSpec(inspect).canonicalJson; + + expect(canonical.indexOf('"com.nvidia-foo"')).toBeLessThan( + canonical.indexOf('"com.nvidia.Foo"'), + ); + expect(canonical.indexOf('"com.nvidia.Foo"')).toBeLessThan( + canonical.indexOf('"com.nvidia.foo"'), + ); + }); + + it("detaches and deeply freezes canonical launch state at the hashed boundary", () => { + const inspect = createDockerGpuInspectFixture(); + const normalized = normalizeDockerManagedBootstrapLaunchSpec(inspect); + const { canonicalJson, hash } = normalized; + const config = normalized.spec.inspect.Config as Record; + const hostConfig = normalized.spec.inspect.HostConfig as Record; + const network = normalized.spec.inspect.NetworkSettings!.Networks!["openshell-docker"]!; + + expect(() => Object.assign(config, { StopTimeout: 999 })).toThrow(TypeError); + expect(() => Object.assign(hostConfig, { Runtime: "mutated" })).toThrow(TypeError); + expect(() => network.Aliases!.push("mutated")).toThrow(TypeError); + + Object.assign(inspect.Config!, { StopTimeout: 45 }); + Object.assign(inspect.HostConfig!, { Runtime: "mutated" }); + inspect.NetworkSettings!.Networks!["openshell-docker"]!.Aliases!.push("mutated"); + + expect(normalized.spec.inspect.Config).not.toHaveProperty("StopTimeout"); + expect(normalized.spec.inspect.HostConfig).not.toHaveProperty("Runtime"); + expect(network.Aliases).toEqual(["openshell-alpha"]); + expect(normalized.canonicalJson).toBe(`${JSON.stringify(normalized.spec)}\n`); + expect(normalized.canonicalJson).toBe(canonicalJson); + expect(normalized.hash).toBe(hash); + expect(normalizeDockerManagedBootstrapLaunchSpec(inspect).hash).not.toBe(hash); + }); + + it.each([ + { + name: "anonymous Config.Volumes whose data source cannot be proven", + mutate: (inspect: ReturnType) => { + Object.assign(inspect.Config!, { Volumes: { "/var/lib/state": {} } }); + }, + error: /config fields it cannot reproduce exactly: Volumes\./u, + }, + { + name: "multiple attached networks", + mutate: (inspect: ReturnType) => { + inspect.NetworkSettings!.Networks!.secondary = { Aliases: ["alpha-secondary"] }; + }, + error: /multiple attached networks/u, + }, + { + name: "an unknown HostConfig field", + mutate: (inspect: ReturnType) => { + (inspect.HostConfig as Record).FutureRuntimeField = true; + }, + error: /unsupported fields: FutureRuntimeField/u, + }, + ])("fails closed for $name", ({ mutate, error }) => { + const inspect = createDockerGpuInspectFixture(); + mutate(inspect); + expect(() => normalizeDockerManagedBootstrapLaunchSpec(inspect)).toThrow(error); + }); +}); diff --git a/src/lib/onboard/managed-bootstrap/docker-spec.ts b/src/lib/onboard/managed-bootstrap/docker-spec.ts new file mode 100644 index 00000000000..19eaef7f288 --- /dev/null +++ b/src/lib/onboard/managed-bootstrap/docker-spec.ts @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +import type { DockerContainerInspect } from "../docker-gpu-patch-types"; + +const CONFIG_KEYS = new Set([ + "ArgsEscaped", + "AttachStderr", + "AttachStdin", + "AttachStdout", + "Cmd", + "Domainname", + "Entrypoint", + "Env", + "ExposedPorts", + "Healthcheck", + "Hostname", + "Image", + "Labels", + "MacAddress", + "NetworkDisabled", + "OnBuild", + "OpenStdin", + "Shell", + "StdinOnce", + "StopSignal", + "StopTimeout", + "Tty", + "User", + "Volumes", + "WorkingDir", +]); + +const HOST_CONFIG_KEYS = new Set([ + "AutoRemove", + "Binds", + "BlkioDeviceReadBps", + "BlkioDeviceReadIOps", + "BlkioDeviceWriteBps", + "BlkioDeviceWriteIOps", + "BlkioWeight", + "BlkioWeightDevice", + "CapAdd", + "CapDrop", + "Cgroup", + "CgroupParent", + "CgroupnsMode", + "ConsoleSize", + "ContainerIDFile", + "CpuCount", + "CpuPercent", + "CpuPeriod", + "CpuQuota", + "CpuRealtimePeriod", + "CpuRealtimeRuntime", + "CpuShares", + "CpusetCpus", + "CpusetMems", + "DeviceCgroupRules", + "DeviceRequests", + "Devices", + "Dns", + "DnsOptions", + "DnsSearch", + "ExtraHosts", + "GroupAdd", + "IOMaximumBandwidth", + "IOMaximumIOps", + "Init", + "IpcMode", + "Isolation", + "Links", + "LogConfig", + "MaskedPaths", + "Memory", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "Mounts", + "NanoCpus", + "NetworkMode", + "OomKillDisable", + "OomScoreAdj", + "PidMode", + "PidsLimit", + "PortBindings", + "Privileged", + "PublishAllPorts", + "ReadonlyPaths", + "ReadonlyRootfs", + "RestartPolicy", + "Runtime", + "SecurityOpt", + "ShmSize", + "StorageOpt", + "Sysctls", + "Tmpfs", + "UTSMode", + "Ulimits", + "UsernsMode", + "VolumeDriver", + "VolumesFrom", +]); + +const UNSUPPORTED_CONFIG_KEYS = new Set([ + "ArgsEscaped", + "AttachStderr", + "AttachStdin", + "AttachStdout", + "MacAddress", + "OnBuild", + "Shell", + "Volumes", +]); + +const UNSUPPORTED_HOST_CONFIG_KEYS = new Set([ + "BlkioDeviceReadBps", + "BlkioDeviceReadIOps", + "BlkioDeviceWriteBps", + "BlkioDeviceWriteIOps", + "BlkioWeight", + "BlkioWeightDevice", + "Cgroup", + "ConsoleSize", + "ContainerIDFile", + "CpuCount", + "CpuPercent", + "CpuRealtimePeriod", + "CpuRealtimeRuntime", + "IOMaximumBandwidth", + "IOMaximumIOps", + "Isolation", + "Links", + "MaskedPaths", + "MemorySwappiness", + "ReadonlyPaths", + "StorageOpt", + "VolumeDriver", + "VolumesFrom", +]); + +export interface DockerManagedBootstrapLaunchSpec { + readonly schemaVersion: 1; + readonly inspect: Pick< + DockerContainerInspect, + "Name" | "Config" | "HostConfig" | "NetworkSettings" + > & { readonly Platform?: string }; +} + +function isEmptyDefault(value: unknown): boolean { + if (value === undefined || value === null || value === false || value === "" || value === 0) { + return true; + } + if (Array.isArray(value)) return value.length === 0; + if (typeof value === "object") return Object.keys(value as object).length === 0; + return false; +} + +function exactObject(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Managed bootstrap Docker ${label} must be an object.`); + } + return value as Record; +} + +function assertKnownKeys( + record: Record, + allowed: ReadonlySet, + label: string, +): void { + const unknown = Object.keys(record).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new Error( + `Managed bootstrap Docker ${label} contains unsupported fields: ${unknown.sort().join(", ")}.`, + ); + } +} + +function assertUnsupportedDefaults(host: Record): void { + const active = [...UNSUPPORTED_HOST_CONFIG_KEYS].filter((key) => !isEmptyDefault(host[key])); + if (active.length > 0) { + throw new Error( + `Managed bootstrap refuses Docker launch fields it cannot reproduce exactly: ${active + .sort() + .join(", ")}.`, + ); + } +} + +function byCodeUnit(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function normalizedNetworkSettings( + value: DockerContainerInspect["NetworkSettings"], +): DockerContainerInspect["NetworkSettings"] { + const networks = value?.Networks ?? {}; + return { + Networks: Object.fromEntries( + Object.entries(networks) + .sort(([left], [right]) => byCodeUnit(left, right)) + .map(([name, network]) => [ + name, + { + Aliases: [...(network.Aliases ?? [])].sort(), + }, + ]), + ), + }; +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => byCodeUnit(left, right)) + .map(([key, nested]) => [key, canonicalize(nested)]), + ); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + for (const nested of Object.values(value)) deepFreeze(nested); + return Object.freeze(value); +} + +export function parseExactDockerContainerInspect(output: string): DockerContainerInspect { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + throw new Error("Managed bootstrap Docker inspect output is malformed."); + } + if (!Array.isArray(parsed) || parsed.length !== 1) { + throw new Error("Managed bootstrap Docker inspect must return exactly one workload."); + } + return exactObject(parsed[0], "inspect") as DockerContainerInspect; +} + +export function normalizeDockerManagedBootstrapLaunchSpec(inspect: DockerContainerInspect): { + readonly canonicalJson: string; + readonly hash: string; + readonly spec: DockerManagedBootstrapLaunchSpec; +} { + const raw = inspect as DockerContainerInspect & Record; + const config = exactObject(raw.Config, "Config"); + const hostConfig = exactObject(raw.HostConfig, "HostConfig"); + assertKnownKeys(config, CONFIG_KEYS, "Config"); + assertKnownKeys(hostConfig, HOST_CONFIG_KEYS, "HostConfig"); + const unsupportedConfig = [...UNSUPPORTED_CONFIG_KEYS].filter( + (key) => !isEmptyDefault(config[key]), + ); + if (unsupportedConfig.length > 0) { + throw new Error( + `Managed bootstrap refuses Docker config fields it cannot reproduce exactly: ${unsupportedConfig + .sort() + .join(", ")}.`, + ); + } + assertUnsupportedDefaults(hostConfig); + + if (config.NetworkDisabled === true) { + throw new Error("Managed bootstrap does not support Config.NetworkDisabled."); + } + if (config.StdinOnce === true) { + throw new Error("Managed bootstrap does not support Config.StdinOnce."); + } + if (hostConfig.AutoRemove === true) { + throw new Error("Managed bootstrap cannot preserve an auto-remove held workload."); + } + if (hostConfig.PublishAllPorts === true) { + throw new Error("Managed bootstrap requires explicit Docker port bindings."); + } + if (Object.keys(inspect.NetworkSettings?.Networks ?? {}).length > 1) { + throw new Error("Managed bootstrap refuses a Docker workload with multiple attached networks."); + } + + const spec: DockerManagedBootstrapLaunchSpec = { + schemaVersion: 1, + inspect: { + Name: inspect.Name, + Config: config as DockerContainerInspect["Config"], + HostConfig: hostConfig as DockerContainerInspect["HostConfig"], + NetworkSettings: normalizedNetworkSettings(inspect.NetworkSettings), + ...("Platform" in raw && typeof raw.Platform === "string" ? { Platform: raw.Platform } : {}), + }, + }; + const canonicalSpec = deepFreeze(canonicalize(spec) as DockerManagedBootstrapLaunchSpec); + const canonicalJson = `${JSON.stringify(canonicalSpec)}\n`; + return Object.freeze({ + canonicalJson, + hash: createHash("sha256").update(canonicalJson, "utf8").digest("hex"), + spec: canonicalSpec, + }); +} + +export function parseDockerManagedBootstrapLaunchSpec( + canonicalJson: string, +): DockerManagedBootstrapLaunchSpec { + let parsed: unknown; + try { + parsed = JSON.parse(canonicalJson); + } catch { + throw new Error("Managed bootstrap Docker launch snapshot is malformed."); + } + const record = exactObject(parsed, "launch snapshot"); + if ( + Object.keys(record).sort().join(",") !== ["inspect", "schemaVersion"].join(",") || + record.schemaVersion !== 1 + ) { + throw new Error("Managed bootstrap Docker launch snapshot schema is invalid."); + } + const normalized = normalizeDockerManagedBootstrapLaunchSpec( + exactObject(record.inspect, "launch snapshot inspect") as DockerContainerInspect, + ); + if (normalized.canonicalJson !== canonicalJson) { + throw new Error("Managed bootstrap Docker launch snapshot is not canonical."); + } + return normalized.spec; +} diff --git a/test/runtime-provider-source-shape.test.ts b/test/runtime-provider-source-shape.test.ts index fe6bd97d130..94d235cad52 100644 --- a/test/runtime-provider-source-shape.test.ts +++ b/test/runtime-provider-source-shape.test.ts @@ -121,6 +121,8 @@ describe("runtime provider central source boundary", () => { it("inventories every dormant managed-bootstrap protocol source", () => { expect(bootstrapProtocolPaths).toEqual([ "src/lib/onboard/managed-bootstrap/adapter.ts", + "src/lib/onboard/managed-bootstrap/docker-journal.ts", + "src/lib/onboard/managed-bootstrap/docker-spec.ts", "src/lib/onboard/managed-bootstrap/envelope.ts", "src/lib/onboard/managed-bootstrap/index.ts", ]);