diff --git a/src/lib/inference/llama-cpp/host-local-runtime.test.ts b/src/lib/inference/llama-cpp/host-local-runtime.test.ts index 77f5c96b78b..4a17143f6bc 100644 --- a/src/lib/inference/llama-cpp/host-local-runtime.test.ts +++ b/src/lib/inference/llama-cpp/host-local-runtime.test.ts @@ -15,6 +15,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LLAMA_CPP_PORT } from "./contract"; import { buildLlamaCppHostLocalDockerArgv, type LlamaCppHostLocalLaunchContract, @@ -159,6 +160,15 @@ describe("llama.cpp host-local runtime materializer", () => { expect(argv.join("\n")).not.toContain("huggingface.co"); }); + it("publishes the fixed loopback host port when the bindings pin one", () => { + const argv = buildLlamaCppHostLocalDockerArgv(contract(), { + ...bindings(), + hostPort: LLAMA_CPP_PORT, + }); + + expect(valuesAfter(argv, "--publish")).toEqual([`127.0.0.1:${String(LLAMA_CPP_PORT)}:8081`]); + }); + it("takes launch settings from the declared contract instead of code defaults (#8144)", () => { const input = contract(); const changed = { diff --git a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts index d803ff1bef5..885134b9dae 100644 --- a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts +++ b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts @@ -9,12 +9,12 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContainerEngine } from "../../adapters/container-engine"; +import { LLAMA_CPP_PORT } from "../../inference/llama-cpp/contract"; import type { LlamaCppGgufCachePlan } from "../../inference/llama-cpp/gguf-cache-plan"; /* Test-only reconstruction of the exact immutable command for recovery fixtures. */ import { buildLlamaCppHostLocalServerArgv, type LlamaCppHostLocalLaunchContract, - type LlamaCppHostLocalRuntimeBindings, } from "../../inference/llama-cpp/host-local-runtime"; import { createDockerLlamaCppManagedLifecycle, @@ -32,6 +32,7 @@ import { } from "./host-local-inference"; import type { PersistedEngineAuthorityStore } from "./persisted-engine-authority"; +const HOST_PORT = String(LLAMA_CPP_PORT); const MODEL_DIGEST = `sha256:${"a".repeat(64)}`; const IMAGE = `ghcr.io/nvidia/nemoclaw/llama-cpp-server@sha256:${"c".repeat(64)}`; const PROBE_IMAGE = `quay.io/curl/curl@sha256:${"d".repeat(64)}`; @@ -231,10 +232,11 @@ function keyRootIdentitySha256(): string { }); } -function bindings(): LlamaCppHostLocalRuntimeBindings { +function bindings(): DockerLlamaCppManagedLifecycleOptions["bindings"] { return { apiKeyHostPath: apiKeyPath, containerName: "nemoclaw-llama-cpp", + hostPort: LLAMA_CPP_PORT, imageReference: IMAGE, model: { digest: MODEL_DIGEST, @@ -388,7 +390,7 @@ interface DockerFixture { } function dockerFixture( - configuredHostPort = "", + configuredHostPort = HOST_PORT, publishedHostPort?: string, publishedHostIp = "127.0.0.1", publishedBindingCount = 1, @@ -824,21 +826,22 @@ describe("dormant Docker llama.cpp managed lifecycle", () => { expect(lifecycle.runtime.destroy(receipt).status).toBe("already-absent"); }); it.each([ - ["configured", "8082", undefined], - ["published", "8081", "8082"], - ] as const)("rolls back exact ownership for %s loopback port drift (#8544)", (_kind, configured, published) => { + ["configured", "8082", undefined, /bound host port/u], + ["published", "8081", "8082", /declared binding/u], + ] as const)("rolls back exact ownership for %s loopback port drift (#8544)", (_kind, configured, published, expectedError) => { const [fixture, store] = [dockerFixture(configured, published), journalStore()]; const lifecycle = createDockerLlamaCppManagedLifecycle( options(fixture, store, { ...bindings(), hostPort: 8081 }), ); - expect(() => lifecycle.start(receiptWriter())).toThrow(/binding/u); + expect(() => lifecycle.start(receiptWriter())).toThrow(expectedError); const calls = fixture.capture.mock.calls.map((call) => call[0]); expect(calls).toContainEqual(["rm", "--force", RUNTIME_ID]); expect(calls).toContainEqual(["network", "rm", NETWORK_ID]); expect(store.list()).toEqual([]); }); - it("fails closed on non-loopback, malformed, or multiple published bindings during port-drift rollback (#8544)", () => { + it("rejects and cleans up malformed or non-loopback published bindings (#8544)", () => { for (const args of [ + [HOST_PORT, HOST_PORT, "0.0.0.0", 1], ["8081", "8082", "0.0.0.0", 1], ["8081", "invalid", "127.0.0.1", 1], ["8081", "8082", "127.0.0.1", 2], @@ -847,10 +850,11 @@ describe("dormant Docker llama.cpp managed lifecycle", () => { const lifecycle = createDockerLlamaCppManagedLifecycle( options(fixture, store, { ...bindings(), hostPort: 8081 }), ); - expect(() => lifecycle.start(receiptWriter())).toThrow("Exact rollback also failed"); + expect(() => lifecycle.start(receiptWriter())).toThrow(/port|binding/u); const calls = fixture.capture.mock.calls.map((call) => call[0]); - expect(store.list()).not.toEqual([]); - expect(calls).not.toContainEqual(["rm", "--force", RUNTIME_ID]); + expect(store.list()).toEqual([]); + expect(calls).toContainEqual(["rm", "--force", RUNTIME_ID]); + expect(calls).toContainEqual(["network", "rm", NETWORK_ID]); } }); it("uses the declarative readiness timeout as both curl retry budget and capture budget", () => { diff --git a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts index 9d111354e3c..23e387e2a39 100644 --- a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts +++ b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts @@ -118,6 +118,8 @@ interface DockerContainerInspection { }; } +type DockerContainerInspectionMode = "runtime" | "cleanup"; + interface StableFileIdentity { readonly dev: bigint; readonly ino: bigint; @@ -271,21 +273,13 @@ function parseLabels(value: unknown): Readonly> { return Object.freeze(labels); } -function parsePublishedPortBinding(value: unknown): Record | null { - if (value === null) return null; - if (!Array.isArray(value) || value.length !== 1) { - throw new Error("Docker llama.cpp container has unexpected published ports."); - } - return record(value[0], "Docker llama.cpp published port"); -} - function parseInspection( output: string, contract: LlamaCppHostLocalLaunchContract, - networkName: string, - hostPort: number | undefined, - portValidation: "exact" | "cleanup", + bindings: DockerLlamaCppManagedLifecycleOptions["bindings"], + mode: DockerContainerInspectionMode, ): DockerContainerInspection { + const networkName = bindings.network.name; let parsed: unknown; try { parsed = JSON.parse(output); @@ -307,37 +301,43 @@ function parseInspection( throw new Error("Docker llama.cpp container has unexpected network attachments."); } const attached = record(networks[networkName], "Docker llama.cpp network attachment"); - const ports = record(networkSettings.Ports, "Docker llama.cpp published ports"); - const portKey = `${String(contract.serve.port)}/tcp`; - const configuredPorts = record(hostConfig.PortBindings, "Docker llama.cpp configured ports"); - if (Object.keys(configuredPorts).length !== 1) { - throw new Error("Docker llama.cpp container has extra configured ports."); - } - const configuredBindings = configuredPorts[portKey]; - if (!Array.isArray(configuredBindings) || configuredBindings.length !== 1) { - throw new Error("Docker llama.cpp container has unexpected configured ports."); - } - const configuredPort = record(configuredBindings[0], "Docker llama.cpp configured port"); - if (configuredPort.HostIp !== "127.0.0.1") { - throw new Error("Docker llama.cpp configured host port is not loopback-only."); - } - const configuredHostPort = - configuredPort.HostPort === "" ? null : exactPort(configuredPort.HostPort); - if (portValidation === "exact" && configuredHostPort !== (hostPort ?? null)) { - throw new Error("Docker llama.cpp configured host port does not match its loopback binding."); - } - const published = parsePublishedPortBinding(ports[portKey]); - if (published !== null && published.HostIp !== "127.0.0.1") { - throw new Error("Docker llama.cpp host port is not loopback-only."); - } - const publishedHostPort = published === null ? null : exactPort(published.HostPort); - if ( - portValidation === "exact" && - hostPort !== undefined && - publishedHostPort !== null && - publishedHostPort !== hostPort - ) { - throw new Error("Docker llama.cpp published host port differs from its declared binding."); + let hostPort: number | null = null; + if (mode === "runtime") { + const ports = record(networkSettings.Ports, "Docker llama.cpp published ports"); + const portKey = `${String(contract.serve.port)}/tcp`; + const configuredPorts = record(hostConfig.PortBindings, "Docker llama.cpp configured ports"); + if (Object.keys(configuredPorts).length !== 1) { + throw new Error("Docker llama.cpp container has extra configured ports."); + } + const configuredBindings = configuredPorts[portKey]; + if (!Array.isArray(configuredBindings) || configuredBindings.length !== 1) { + throw new Error("Docker llama.cpp container has unexpected configured ports."); + } + const configuredPort = record(configuredBindings[0], "Docker llama.cpp configured port"); + if (configuredPort.HostIp !== "127.0.0.1") { + throw new Error("Docker llama.cpp configured host port is not loopback-only."); + } + if (configuredPort.HostPort !== String(bindings.hostPort)) { + throw new Error("Docker llama.cpp configured host port is not the bound host port."); + } + const publishedBindings = ports[portKey]; + if ( + publishedBindings !== null && + (!Array.isArray(publishedBindings) || publishedBindings.length !== 1) + ) { + throw new Error("Docker llama.cpp container has unexpected published ports."); + } + const published = + publishedBindings === null + ? null + : record(publishedBindings[0], "Docker llama.cpp published port"); + if (published !== null && published.HostIp !== "127.0.0.1") { + throw new Error("Docker llama.cpp host port is not loopback-only."); + } + hostPort = published === null ? null : exactPort(published.HostPort); + if (hostPort !== null && hostPort !== bindings.hostPort) { + throw new Error("Docker llama.cpp published host port differs from its declared binding."); + } } if (!Array.isArray(source.Mounts)) { throw new Error("Docker llama.cpp inspection returned malformed mounts."); @@ -406,7 +406,7 @@ function parseInspection( status: stateStatus, networkId: exactId(attached.NetworkID, "Docker attached network identity"), networkName, - hostPort: publishedHostPort, + hostPort, mounts: Object.freeze(mounts), hardening: Object.freeze({ user: String(config.User ?? ""), @@ -443,9 +443,8 @@ function inspectContainer( engine: ContainerEngine, target: string, contract: LlamaCppHostLocalLaunchContract, - networkName: string, - hostPort: number | undefined, - portValidation: "exact" | "cleanup" = "exact", + bindings: DockerLlamaCppManagedLifecycleOptions["bindings"], + mode: DockerContainerInspectionMode = "runtime", ): DockerContainerInspection | null { const result = engine.capture(["container", "inspect", target], INSPECT_TIMEOUT_MS); const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); @@ -456,13 +455,7 @@ function inspectContainer( if (!result.error && result.status === 1 && exactAbsent.test(result.stderr.trim())) { return null; } - return parseInspection( - requireSuccess("container inspection", result), - contract, - networkName, - hostPort, - portValidation, - ); + return parseInspection(requireSuccess("container inspection", result), contract, bindings, mode); } function currentUid(): bigint { @@ -947,8 +940,7 @@ function rollbackExact( options.engine, target, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, "cleanup", ); if (container === null && record.phase === "creating" && uncertainRecoveryUnixMs !== undefined) { @@ -964,8 +956,7 @@ function rollbackExact( options.engine, target, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, "cleanup", ); } @@ -976,14 +967,8 @@ function rollbackExact( captureMutation(options, lease, execution, ["rm", "--force", owned.id], MUTATION_TIMEOUT_MS), ); if ( - inspectContainer( - options.engine, - owned.id, - options.contract, - options.bindings.network.name, - options.bindings.hostPort, - "cleanup", - ) !== null + inspectContainer(options.engine, owned.id, options.contract, options.bindings, "cleanup") !== + null ) { throw new Error("Docker llama.cpp exact rollback left the owned runtime present."); } @@ -1239,8 +1224,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, authorized.receipt.runtime.runtimeId, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ); if (inspected === null) throw new Error("Docker llama.cpp owned runtime is absent."); const container = requireOwnedContainer(inspected, options, authorized.journal); @@ -1357,8 +1341,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, normalized.runtime.runtimeId, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ); const journal = options.journalStore.load(normalized.runtime.model.generation); if (existing !== null || journal !== null) authorizeReceipt(normalized, true); @@ -1373,8 +1356,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, normalized.runtime.runtimeId, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ); if (existing === null) { const journal = options.journalStore.load(normalized.runtime.model.generation); @@ -1413,8 +1395,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, inspected.container.id, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ) !== null ) { throw new Error("Docker llama.cpp removal left the exact runtime present."); @@ -1510,8 +1491,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, options.bindings.containerName, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ) !== null ) { throw new Error("Docker llama.cpp container name is already in use."); @@ -1592,8 +1572,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, options.bindings.containerName, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ); if (create.error || create.status !== 0 || created === null) { throw new Error( @@ -1619,8 +1598,7 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, created.id, options.contract, - options.bindings.network.name, - options.bindings.hostPort, + options.bindings, ); if (started === null || !started.running) { throw new Error("Docker llama.cpp start did not leave the exact runtime running."); diff --git a/src/lib/onboard/runtime-provider/host-local-inference.ts b/src/lib/onboard/runtime-provider/host-local-inference.ts index db2f75a96ae..6d665b76b28 100644 --- a/src/lib/onboard/runtime-provider/host-local-inference.ts +++ b/src/lib/onboard/runtime-provider/host-local-inference.ts @@ -169,7 +169,7 @@ export interface HostLocalLlamaCppLifecycleInput { readonly authorityStore: PersistedEngineAuthorityStore; readonly apiKeyRootHostPath: string; readonly bindingSha256: string; - readonly bindings: LlamaCppHostLocalRuntimeBindings; + readonly bindings: LlamaCppHostLocalRuntimeBindings & { readonly hostPort: number }; readonly cacheRootHostPath: string; readonly contract: LlamaCppHostLocalLaunchContract; readonly engine: ContainerEngine;