diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 5c4dbe4fcde..8f0357c445f 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -92,40 +92,42 @@ The first run can take 10 to 30 minutes. Later runs reuse the cached image and model weights. The managed container uses Docker's `--restart unless-stopped` policy, so Docker restarts it unless an operator explicitly stops it. -### Check Storage Before Downloads +### Check Storage Before the Image Pull -For an uncached image, NemoClaw checks Docker image storage before the watched image pull starts. -It checks the Hugging Face model cache before that pull and checks it again afterward before `hf download` starts. -The image estimate includes compressed content, unpacked layers, and pull-staging headroom. -The model estimate includes the model's full published size plus temporary-write headroom because a local cache cannot prove which files a mutable remote revision will reuse. +For an uncached pinned image, NemoClaw checks Docker image storage before the watched image pull starts. +The estimate reserves three times the image's compressed registry size plus `3 GiB` for compressed content, unpacked layers, and pull staging. For Docker's classic image store, NemoClaw checks the absolute `DockerRootDir` reported by `docker info`. For Docker's containerd image store, it checks the top-level `root` from `/etc/containerd/config.toml`, or `/var/lib/containerd` when that configuration file is absent, and also checks `DockerRootDir` for pull staging. The check uses the location with the least available capacity. -The model-cache check uses `~/.cache/huggingface`, or its nearest existing parent filesystem when the cache directory does not exist yet. NemoClaw recognizes an image cache hit only when Docker contains the exact platform digest. -A cache hit skips the cold-pull capacity check, but the explicit watched `docker pull` still runs against that immutable digest. -Before trusting the host model-cache capacity, NemoClaw also verifies that the selected Docker daemon maps bind mounts to the local host; a remote or unverifiable Docker host makes the model-cache check inconclusive. -An explicit Unix socket is trusted only when it is Docker's default `/var/run/docker.sock`; any other socket can forward to a daemon with a different filesystem and is treated as unverifiable. -Known container markers are rejected immediately. -Before trusting client-side image-store capacity, NemoClaw requires the selected default Docker socket peer to have a visible PID and share the client's mount namespace; a nested PID namespace or unverifiable peer fails closed before the image pull. -Before trusting model-cache capacity, NemoClaw also asks the pinned cached vLLM image to read a random mode-`0600` sentinel through a bounded, read-only Docker bind mount. -The pull is idempotent for the cached digest and cannot select different layers from a moved release tag. -The model-download helper and the long-lived vLLM container use `--pull=never`, so an implicit pull cannot bypass the storage gate. -The second model-cache check detects capacity consumed by the image pull before the model download command can start. - -When capacity is insufficient or NemoClaw cannot verify it, the warning identifies the image or model, available capacity, approximate requirement, checked path and source, remediation, and diagnostic commands. +A cache hit skips the capacity check, but the explicit watched `docker pull` still runs against that immutable digest. +The model-download helper and the long-lived vLLM container use `--pull=never`, so an implicit pull cannot select another image. + +NemoClaw treats `/var/run/docker.sock` and `/run/docker.sock` as the local default Docker socket paths. +It cannot inspect daemon storage through a remote endpoint, a named non-default Docker context, or another Unix socket. +When both `DOCKER_CONTEXT` and `DOCKER_HOST` are set, NemoClaw follows Docker CLI precedence and uses `DOCKER_CONTEXT`. +When the check is inconclusive, NemoClaw prints the reason, approximate image requirement, and diagnostic commands, then continues with the pull without claiming that capacity is sufficient. + +When verified capacity is insufficient, the warning identifies the image, available capacity, approximate requirement, checked path and source, remediation, and diagnostic commands. Interactive setup continues only when you enter `y` or `yes` explicitly. -Pressing **Enter**, entering `n` or `no`, or entering any other response stops before the guarded pull or model download starts. +Pressing **Enter**, entering `n` or `no`, or entering any other response stops before the guarded image pull starts. -`--yes` and `NEMOCLAW_YES=1` do not accept managed-vLLM storage warnings. +`--yes` and `NEMOCLAW_YES=1` do not accept a verified insufficient-storage warning. Non-interactive setup stops unless you set `NEMOCLAW_IGNORE_VLLM_DISK_SPACE=1` exactly. -This override accepts both insufficient-capacity and inconclusive checks for Docker image storage and the model cache. -Use it only after you independently verify that both storage locations can complete the downloads. +The override applies only when NemoClaw verifies that Docker image storage is insufficient. +Use it only after you independently verify that Docker storage can complete the image pull. +Inconclusive checks continue automatically and do not require the override. + +This preflight checks only the pinned vLLM container image. +It does not estimate or gate the Hugging Face model-weight download into `~/.cache/huggingface`. +Before the first managed-vLLM run, verify that the filesystem backing your model cache has enough free space for the selected model. + + If image pull output stops making progress, a watchdog stops the stalled pull without imposing a fixed wall-clock limit on slow but active downloads. If vLLM does not become ready, NemoClaw prints a short tail of the container logs before exiting. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 24b475a72c3..12145b2dfcb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3250,7 +3250,7 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_INSTALL_TAG` | release tag | For internal installer commands: the release tag to install. Defaults to the admin-promoted `lkg` tag when unset. Overridden by the `--install-tag` flag. | | `NEMOCLAW_VLLM_MODEL` | registry slug or Hugging Face model id | Selects the model the managed-vLLM install path serves. Recognised slugs: `qwen3.6-27b`, `qwen3.6-35b-a3b-nvfp4`, `nemotron-3-nano-4b`, `deepseek-v4-flash`, `deepseek-r1-distill-70b`. Unset uses the per-platform profile default. Gated models (e.g. `deepseek-r1-distill-70b`) require `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN`. | | `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` | JSON array of non-blank strings | Appends advanced operator-owned tokens to the managed `vllm serve` command after NemoClaw's registry defaults. Example: `["--max-num-seqs","2"]`. Malformed JSON, non-string tokens, or blank tokens fail before Docker work starts. | -| `NEMOCLAW_IGNORE_VLLM_DISK_SPACE` | exactly `1` | Allows managed vLLM to continue when Docker image-storage or model-cache capacity is insufficient or cannot be verified; other values do not bypass the checks. | +| `NEMOCLAW_IGNORE_VLLM_DISK_SPACE` | exactly `1` | Allows managed vLLM to continue when verified Docker image-storage capacity is insufficient; other values do not bypass the check. Inconclusive image-storage checks report diagnostics and continue automatically. The check does not cover Hugging Face model-cache capacity. | | `NEMOCLAW_MINIMAL_BOOTSTRAP` | `1` to enable | Skips default OpenClaw workspace-template seeding for new pristine workspaces. Existing files are not deleted; refer to [Understand Runtime Changes](../manage-sandboxes/configure-sandboxes/understand-runtime-changes). | diff --git a/scripts/install.sh b/scripts/install.sh index d49e9bd24e4..f487398830e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2655,7 +2655,7 @@ detect_express_platform() { fi case "$model" in *DGX*Spark*) printf "DGX Spark" ;; - *DGX*Station*) printf "DGX Station" ;; + *DGX*Station* | *Station*GB300*) printf "DGX Station" ;; *) ;; esac } diff --git a/src/lib/inference/vllm-models.test.ts b/src/lib/inference/vllm-models.test.ts index 1eb1aacdd14..bbe843206e2 100644 --- a/src/lib/inference/vllm-models.test.ts +++ b/src/lib/inference/vllm-models.test.ts @@ -16,25 +16,6 @@ import { } from "./vllm-models"; describe("vllm model registry", () => { - it("records a finite positive Hugging Face download size for every model", () => { - for (const model of VLLM_MODELS) { - expect(Number.isFinite(model.downloadSizeBytes)).toBe(true); - expect(model.downloadSizeBytes).toBeGreaterThan(0); - } - }); - - it("pins the official Hugging Face repository totals", () => { - expect( - Object.fromEntries(VLLM_MODELS.map((model) => [model.envValue, model.downloadSizeBytes])), - ).toEqual({ - "qwen3.6-27b": 30_900_000_000, - "deepseek-r1-distill-70b": 141_000_000_000, - "nemotron-3-nano-4b": 5_280_000_000, - "deepseek-v4-flash": 160_000_000_000, - "qwen3.6-35b-a3b-nvfp4": 23_500_000_000, - }); - }); - it("returns null when NEMOCLAW_VLLM_MODEL is unset so the caller can fall back to the profile default", () => { expect(selectVllmModelFromEnv({} as NodeJS.ProcessEnv)).toBeNull(); }); diff --git a/src/lib/inference/vllm-models.ts b/src/lib/inference/vllm-models.ts index d38f13dac8b..7359fc9bfd0 100644 --- a/src/lib/inference/vllm-models.ts +++ b/src/lib/inference/vllm-models.ts @@ -35,8 +35,6 @@ export interface VllmModelDef { label: string; /** Stable identifier accepted via `NEMOCLAW_VLLM_MODEL`. */ envValue: string; - /** Approximate full Hugging Face repository download size in bytes. */ - downloadSizeBytes: number; /** `--max-model-len` flag value. */ maxModelLen: number; /** Model-specific flags appended after the shared serving flags. */ @@ -65,7 +63,6 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [ id: "Qwen/Qwen3.6-27B-FP8", label: "Qwen3.6 27B FP8", envValue: "qwen3.6-27b", - downloadSizeBytes: 30_900_000_000, maxModelLen: 262144, modelArgs: [ "--gpu-memory-utilization", @@ -88,7 +85,6 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [ id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", label: "DeepSeek-R1 Distill Llama 70B", envValue: "deepseek-r1-distill-70b", - downloadSizeBytes: 141_000_000_000, maxModelLen: 32768, modelArgs: [ "--gpu-memory-utilization", @@ -108,7 +104,6 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [ id: "nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8", label: "NVIDIA Nemotron-3 Nano 4B FP8", envValue: "nemotron-3-nano-4b", - downloadSizeBytes: 5_280_000_000, // Matches the model card's `max_position_embeddings` and the vLLM // example NVIDIA publishes for this checkpoint. The previous value // (262000) was an undocumented round-down with no headroom rationale. @@ -138,7 +133,6 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [ id: "deepseek-ai/DeepSeek-V4-Flash", label: "DeepSeek V4 Flash", envValue: "deepseek-v4-flash", - downloadSizeBytes: 160_000_000_000, maxModelLen: 1048576, modelArgs: [ "--kv-cache-dtype", @@ -179,7 +173,6 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [ id: "nvidia/Qwen3.6-35B-A3B-NVFP4", label: "Qwen3.6 35B-A3B NVFP4", envValue: "qwen3.6-35b-a3b-nvfp4", - downloadSizeBytes: 23_500_000_000, maxModelLen: 262144, // Additive flags on top of the shared serving defaults. The shared flags // already cover --tensor-parallel-size/--pipeline-parallel-size/ diff --git a/src/lib/inference/vllm-storage.test.ts b/src/lib/inference/vllm-storage.test.ts index 2ac7bc1c6e8..c9b56d23c31 100644 --- a/src/lib/inference/vllm-storage.test.ts +++ b/src/lib/inference/vllm-storage.test.ts @@ -1,24 +1,15 @@ // 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 { - dockerSocketPeerSharesMountNamespace, formatStorageBytes, imageStorageRequirementBytes, - modelStorageRequirementBytes, - probeDockerBindIdentity, - probeDockerHostLocality, probeDockerStorage, - probeModelCacheStorage, resolveDockerStorageLocations, } from "./vllm-storage"; const GIB = 1024n ** 3n; -const tempDirs: string[] = []; function nativeDockerInfo(overrides: Record = {}): string { return JSON.stringify({ @@ -26,8 +17,6 @@ function nativeDockerInfo(overrides: Record = {}): string { DockerRootDir: "/var/lib/docker", Driver: "overlay2", DriverStatus: [], - Name: "dgx-station", - OperatingSystem: "Ubuntu 24.04", OSType: "linux", SecurityOptions: [], ...overrides, @@ -35,27 +24,22 @@ function nativeDockerInfo(overrides: Record = {}): string { } const nativeHost = { - clientContainerized: false, dockerContext: undefined, dockerHost: undefined, - osRelease: "6.8.0-generic", platform: "linux" as NodeJS.Platform, - dockerSocketPeerSharesMountNamespace: () => true, }; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { force: true, recursive: true }); }); -describe("managed vLLM storage requirements", () => { +describe("managed vLLM image-storage requirements", () => { it("reserves compressed, unpacked, and staging space for an image pull (#6757)", () => { expect(imageStorageRequirementBytes(1_000_000_000)).toBe(3_000_000_000n + 3n * GIB); - }); - - it("requires the full model size plus temporary-file headroom (#6757)", () => { - expect(modelStorageRequirementBytes(Number(10n * GIB))).toBe(12n * GIB); + expect(() => imageStorageRequirementBytes(0)).toThrow( + "vLLM image download size must be a positive finite byte count", + ); }); it("formats available and required bytes as rounded GiB values", () => { @@ -65,11 +49,24 @@ describe("managed vLLM storage requirements", () => { }); describe("Docker image-storage detection", () => { - it("uses DockerRootDir for the classic image store (#6757)", () => { - expect(resolveDockerStorageLocations(nativeDockerInfo(), nativeHost)).toEqual({ + it("measures DockerRootDir for the classic image store (#6757)", () => { + const statfs = vi.fn(() => ({ bavail: 7n, bsize: GIB })); + + expect( + probeDockerStorage({ + ...nativeHost, + dockerInfo: () => nativeDockerInfo(), + statfs, + }), + ).toEqual({ ok: true, - locations: [{ path: "/var/lib/docker", source: "Docker root directory" }], + capacity: { + availableBytes: 7n * GIB, + path: "/var/lib/docker", + source: "Docker root directory", + }, }); + expect(statfs).toHaveBeenCalledWith("/var/lib/docker"); }); it("checks both the configured containerd root and Docker pull staging (#6757)", () => { @@ -77,19 +74,20 @@ describe("Docker image-storage detection", () => { bavail: target === "/var/lib/docker" ? 2n : 40n, bsize: GIB, })); - const result = probeDockerStorage({ - ...nativeHost, - dockerInfo: () => - nativeDockerInfo({ - Driver: "overlayfs", - DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], - }), - exists: (target) => target === "/etc/containerd/config.toml", - readFile: () => 'root = "/mnt/containerd"', - statfs, - }); - expect(result).toEqual({ + expect( + probeDockerStorage({ + ...nativeHost, + dockerInfo: () => + nativeDockerInfo({ + Driver: "overlayfs", + DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], + }), + exists: (target) => target === "/etc/containerd/config.toml", + readFile: () => 'root = "/mnt/containerd"', + statfs, + }), + ).toEqual({ ok: true, capacity: { availableBytes: 2n * GIB, @@ -102,15 +100,15 @@ describe("Docker image-storage detection", () => { }); it("uses the documented containerd root when no config file exists (#6757)", () => { - const result = resolveDockerStorageLocations( - nativeDockerInfo({ - Driver: "overlayfs", - DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], - }), - { ...nativeHost, exists: () => false }, - ); - - expect(result).toEqual({ + expect( + resolveDockerStorageLocations( + nativeDockerInfo({ + Driver: "overlayfs", + DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], + }), + { ...nativeHost, exists: () => false }, + ), + ).toEqual({ ok: true, locations: [ { path: "/var/lib/containerd", source: "containerd image store" }, @@ -119,58 +117,33 @@ describe("Docker image-storage detection", () => { }); }); - it("fails closed for overlayfs without the exact containerd marker (#6757)", () => { - const result = resolveDockerStorageLocations( - nativeDockerInfo({ - Driver: "overlayfs", - DriverStatus: [["note", "io.containerd.snapshotter.v1"]], - }), - nativeHost, - ); - - expect(result).toEqual({ - ok: false, - reason: "docker reported ambiguous image-storage driver overlayfs", - }); - }); + it.each([ + "/var/run/docker.sock", + "unix:///var/run/docker.sock", + "/run/docker.sock", + "unix:///run/docker.sock", + ])("measures storage through the local Docker socket %s (#6858)", (dockerHost) => { + const statfs = vi.fn(() => ({ bavail: 9n, bsize: GIB })); - it("fails closed when containerd imports can override the configured root (#6757)", () => { - const result = resolveDockerStorageLocations( - nativeDockerInfo({ - Driver: "overlayfs", - DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], - }), - { + expect( + probeDockerStorage({ ...nativeHost, - exists: (target) => target === "/etc/containerd/config.toml", - readFile: () => 'root = "/mnt/containerd"\nimports = ["/etc/containerd/conf.d/*.toml"]', - }, - ); - - expect(result).toEqual({ - ok: false, - reason: "containerd config imports other files that can override its image-store root", - }); - }); - - it("fails closed with an actionable reason for rootless containerd (#6757)", () => { - const result = resolveDockerStorageLocations( - nativeDockerInfo({ - Driver: "overlayfs", - DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], - SecurityOptions: ["name=rootless"], + dockerHost, + dockerInfo: () => nativeDockerInfo(), + statfs, }), - nativeHost, - ); - - expect(result).toEqual({ - ok: false, - reason: - "rootless Docker detected; managed vLLM cannot verify the containerd image-store location", + ).toEqual({ + ok: true, + capacity: { + availableBytes: 9n * GIB, + path: "/var/lib/docker", + source: "Docker root directory", + }, }); + expect(statfs).toHaveBeenCalledWith("/var/lib/docker"); }); - it("returns an inconclusive result for remote Docker contexts (#6757)", () => { + it("returns an inconclusive result for remote Docker endpoints (#6757)", () => { expect( resolveDockerStorageLocations(nativeDockerInfo(), { ...nativeHost, @@ -182,25 +155,26 @@ describe("Docker image-storage detection", () => { }); }); - it("honors DOCKER_HOST precedence over DOCKER_CONTEXT (#6757)", () => { + it("honors a named DOCKER_CONTEXT over DOCKER_HOST (#6757)", () => { expect( resolveDockerStorageLocations(nativeDockerInfo(), { ...nativeHost, - dockerContext: "default", - dockerHost: "ssh://builder.example.test", + dockerContext: "remote-builder", + dockerHost: "unix:///run/docker.sock", }), ).toEqual({ ok: false, - reason: "Docker uses a remote endpoint (ssh://builder.example.test)", + reason: + "Docker uses a named context (remote-builder) whose host filesystem cannot be inspected", }); }); - it("ignores a named DOCKER_CONTEXT when DOCKER_HOST selects a local socket (#6757)", () => { + it("honors an explicit default DOCKER_CONTEXT over a remote DOCKER_HOST (#6757)", () => { expect( resolveDockerStorageLocations(nativeDockerInfo(), { ...nativeHost, - dockerContext: "remote-builder", - dockerHost: "unix:///var/run/docker.sock", + dockerContext: "default", + dockerHost: "ssh://builder.example.test", }), ).toEqual({ ok: true, @@ -208,7 +182,7 @@ describe("Docker image-storage detection", () => { }); }); - it("fails closed for a named Docker context whose filesystem is not local (#6757)", () => { + it("returns an inconclusive result for a named Docker context (#6757)", () => { expect( resolveDockerStorageLocations( nativeDockerInfo({ ClientInfo: { Context: "remote-builder" } }), @@ -217,268 +191,114 @@ describe("Docker image-storage detection", () => { ).toEqual({ ok: false, reason: - "Docker uses a named context (remote-builder) whose host filesystem cannot be verified", - }); - }); - - it.each([ - { - boundary: "non-Linux host", - expected: "Docker runs behind a darwin host boundary", - info: nativeDockerInfo(), - overrides: { ...nativeHost, platform: "darwin" as NodeJS.Platform }, - }, - { - boundary: "WSL kernel", - expected: "Docker runs behind a WSL host boundary", - info: nativeDockerInfo(), - overrides: { ...nativeHost, osRelease: "5.15.153.1-microsoft-standard-WSL2" }, - }, - { - boundary: "Docker Desktop VM", - expected: "Docker runs inside a VM or compatibility layer", - info: nativeDockerInfo({ OperatingSystem: "Docker Desktop" }), - overrides: nativeHost, - }, - { - boundary: "Colima VM", - expected: "Docker runs inside a VM or compatibility layer", - info: nativeDockerInfo({ OperatingSystem: "Colima" }), - overrides: nativeHost, - }, - { - boundary: "Podman compatibility layer", - expected: "Docker runs inside a VM or compatibility layer", - info: nativeDockerInfo({ OperatingSystem: "Podman Engine" }), - overrides: nativeHost, - }, - ])("fails closed across a $boundary (#6757)", ({ expected, info, overrides }) => { - expect(resolveDockerStorageLocations(info, overrides)).toEqual({ - ok: false, - reason: expected, + "Docker uses a named context (remote-builder) whose host filesystem cannot be inspected", }); }); - it("checks Docker host locality without requiring image-store capacity (#6757)", () => { + it("returns an inconclusive result outside a native Linux engine (#6757)", () => { expect( - probeDockerHostLocality({ + resolveDockerStorageLocations(nativeDockerInfo(), { ...nativeHost, - dockerInfo: () => nativeDockerInfo({ Driver: "unrecognized" }), + platform: "darwin", }), - ).toEqual({ ok: true }); + ).toEqual({ ok: false, reason: "Docker runs behind a darwin host boundary" }); + expect( + resolveDockerStorageLocations(nativeDockerInfo({ OSType: "windows" }), nativeHost), + ).toEqual({ ok: false, reason: "Docker is not using a Linux engine" }); + }); + it("returns an inconclusive result for an ambiguous image-storage driver (#6757)", () => { expect( - probeDockerHostLocality({ - ...nativeHost, - dockerHost: "ssh://builder.example.test", - dockerInfo: () => nativeDockerInfo({ Driver: "unrecognized" }), - }), + resolveDockerStorageLocations( + nativeDockerInfo({ + Driver: "overlayfs", + DriverStatus: [["note", "io.containerd.snapshotter.v1"]], + }), + nativeHost, + ), ).toEqual({ ok: false, - reason: "Docker uses a remote endpoint (ssh://builder.example.test)", + reason: "docker reported ambiguous image-storage driver overlayfs", }); }); - it("fails closed for a non-default Unix socket that can forward a remote daemon (#6757)", () => { - vi.stubEnv("DOCKER_HOST", "unix:///tmp/forwarded-remote.sock"); - vi.stubEnv("DOCKER_CONTEXT", "default"); + it("does not guess a containerd root when configuration can override it (#6757)", () => { + const containerdInfo = nativeDockerInfo({ + Driver: "overlayfs", + DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], + }); expect( - probeDockerHostLocality({ - clientContainerized: false, - dockerInfo: () => nativeDockerInfo(), - osRelease: nativeHost.osRelease, - platform: nativeHost.platform, - dockerSocketPeerSharesMountNamespace: () => true, + resolveDockerStorageLocations(containerdInfo, { + ...nativeHost, + exists: (target) => target === "/etc/containerd/config.toml", + readFile: () => 'root = "/mnt/containerd"\nimports = ["/etc/containerd/conf.d/*.toml"]', }), ).toEqual({ ok: false, - reason: - "Docker uses a non-default socket (unix:///tmp/forwarded-remote.sock) whose daemon host filesystem cannot be verified", + reason: "containerd config imports other files that can override its image-store root", }); - }); - - it("fails closed when the default Docker socket is mounted into a client container (#6757)", () => { - vi.stubEnv("DOCKER_HOST", "unix:///var/run/docker.sock"); - vi.stubEnv("DOCKER_CONTEXT", "default"); - vi.spyOn(fs, "existsSync").mockImplementation((target) => target === "/.dockerenv"); - expect( - probeDockerHostLocality({ - dockerInfo: () => nativeDockerInfo(), - osRelease: nativeHost.osRelease, - platform: nativeHost.platform, + resolveDockerStorageLocations(containerdInfo, { + ...nativeHost, + exists: (target) => target === "/etc/containerd/config.toml", + readFile: () => { + throw new Error("permission denied"); + }, }), ).toEqual({ ok: false, - reason: - "Docker client runs inside a container, so daemon bind-mount storage cannot be verified", + reason: "could not read /etc/containerd/config.toml: permission denied", }); }); - it("classifies the Docker socket peer mount namespace without trusting procfs PID 1", () => { - const capture = vi.fn(() => "shared\n"); - - expect(dockerSocketPeerSharesMountNamespace("/var/run/docker.sock", capture, "linux")).toBe( - true, - ); - expect(capture).toHaveBeenCalledWith( - expect.arrayContaining(["python3", "-I", "-c", "/var/run/docker.sock"]), - { ignoreError: true, timeout: 5_000 }, - ); - capture.mockReturnValueOnce("different\n"); - expect(dockerSocketPeerSharesMountNamespace("/var/run/docker.sock", capture, "linux")).toBe( - false, - ); - capture.mockReturnValueOnce("unknown\n"); - expect( - dockerSocketPeerSharesMountNamespace("/var/run/docker.sock", capture, "linux"), - ).toBeNull(); - }); - - it("fails closed when the Docker socket peer has a different mount namespace (#6757)", () => { + it("returns an inconclusive result for rootless containerd (#6757)", () => { expect( - probeDockerHostLocality({ - ...nativeHost, - dockerHost: "unix:///var/run/docker.sock", - dockerInfo: () => nativeDockerInfo(), - dockerSocketPeerSharesMountNamespace: () => false, - }), + resolveDockerStorageLocations( + nativeDockerInfo({ + Driver: "overlayfs", + DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], + SecurityOptions: ["name=rootless"], + }), + nativeHost, + ), ).toEqual({ ok: false, reason: - "Docker client and socket peer use different mount namespaces, so daemon filesystem identity cannot be verified", + "rootless Docker detected; managed vLLM cannot inspect the containerd image-store location", }); }); - it("fails closed before statfs when a nested PID namespace hides the socket peer (#6757)", () => { - const statfs = vi.fn(() => ({ bavail: 1_000n, bsize: GIB })); - + it("surfaces filesystem inspection failures without reporting low capacity (#6757)", () => { expect( probeDockerStorage({ ...nativeHost, - dockerHost: "unix:///var/run/docker.sock", dockerInfo: () => nativeDockerInfo(), - dockerSocketPeerSharesMountNamespace: () => null, - statfs, + statfs: () => { + throw new Error("permission denied"); + }, }), ).toEqual({ ok: false, - reason: "Docker socket peer PID or mount namespace could not be verified", + path: "/var/lib/docker", + reason: "could not inspect /var/lib/docker: permission denied", + source: "Docker root directory", }); - expect(statfs).not.toHaveBeenCalled(); }); - it("reads the Docker selection when each storage probe starts (#6757)", () => { + it("reads the Docker selection when each capacity probe starts (#6757)", () => { vi.stubEnv("DOCKER_CONTEXT", "remote-builder"); vi.stubEnv("DOCKER_HOST", ""); expect( probeDockerStorage({ - clientContainerized: false, dockerInfo: () => nativeDockerInfo(), - osRelease: nativeHost.osRelease, - platform: nativeHost.platform, - dockerSocketPeerSharesMountNamespace: () => true, + platform: "linux", }), ).toEqual({ ok: false, reason: - "Docker uses a named context (remote-builder) whose host filesystem cannot be verified", - }); - }); - - it("does not fall back to DockerRootDir when containerd config is unreadable (#6757)", () => { - const result = resolveDockerStorageLocations( - nativeDockerInfo({ - Driver: "overlayfs", - DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], - }), - { - ...nativeHost, - exists: (target) => target === "/etc/containerd/config.toml", - readFile: () => { - throw new Error("permission denied"); - }, - }, - ); - - expect(result).toEqual({ - ok: false, - reason: "could not read /etc/containerd/config.toml: permission denied", - }); - }); -}); - -describe("Hugging Face model-cache storage", () => { - it("proves Docker bind identity with a bounded read-only sentinel round trip (#6757)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-bind-identity-")); - tempDirs.push(root); - const cacheDir = path.join(root, ".cache", "huggingface"); - const dockerReadBind = vi.fn((_image: string, sourcePath: string) => { - const fd = fs.openSync(sourcePath, "r"); - try { - expect(fs.fstatSync(fd).mode & 0o777).toBe(0o600); - const token = fs.readFileSync(fd, "utf8"); - expect(sourcePath).not.toContain(token.slice("nemoclaw-storage-token:".length)); - return token; - } finally { - fs.closeSync(fd); - } - }); - - expect( - probeDockerBindIdentity(cacheDir, "example.test/vllm@sha256:pinned", { - ...nativeHost, - dockerInfo: () => nativeDockerInfo(), - dockerReadBind, - }), - ).toEqual({ ok: true }); - expect(dockerReadBind).toHaveBeenCalledWith( - "example.test/vllm@sha256:pinned", - expect.stringMatching(/\.nemoclaw-storage-probe-/), - ); - expect(fs.readdirSync(root)).toEqual([]); - }); - - it("fails closed when namespace-local PID 1 hides a private mount namespace (#6757)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-bind-identity-")); - tempDirs.push(root); - - expect( - probeDockerBindIdentity( - path.join(root, ".cache", "huggingface"), - "example.test/vllm@sha256:pinned", - { - ...nativeHost, - clientContainerized: false, - dockerHost: "unix:///var/run/docker.sock", - dockerInfo: () => nativeDockerInfo(), - dockerReadBind: () => "", - }, - ), - ).toEqual({ - ok: false, - reason: - "Docker daemon could not read the client storage sentinel; bind-mount filesystem identity cannot be verified", - }); - expect(fs.readdirSync(root)).toEqual([]); - }); - - it("checks the nearest existing filesystem before creating the cache directory (#6757)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-storage-")); - tempDirs.push(root); - const cacheDir = path.join(root, ".cache", "huggingface"); - const statfs = vi.fn(() => ({ bavail: 7n, bsize: GIB })); - - expect(probeModelCacheStorage(cacheDir, { statfs })).toEqual({ - ok: true, - capacity: { - availableBytes: 7n * GIB, - path: root, - source: "model cache filesystem", - }, + "Docker uses a named context (remote-builder) whose host filesystem cannot be inspected", }); - expect(statfs).toHaveBeenCalledWith(root); }); }); diff --git a/src/lib/inference/vllm-storage.ts b/src/lib/inference/vllm-storage.ts index afcabc3aedf..f197a0d78cc 100644 --- a/src/lib/inference/vllm-storage.ts +++ b/src/lib/inference/vllm-storage.ts @@ -1,115 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { randomUUID } from "node:crypto"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { parse as parseToml } from "smol-toml"; import { dockerCapture } from "../adapters/docker"; -import { runCapture } from "../runner"; import { buildVllmDockerEnv } from "./vllm-docker-env"; export const VLLM_STORAGE_OVERRIDE_ENV = "NEMOCLAW_IGNORE_VLLM_DISK_SPACE"; const GIB_BYTES = 1024n ** 3n; const IMAGE_PULL_TEMP_HEADROOM_BYTES = 3n * GIB_BYTES; -const MODEL_MINIMUM_HEADROOM_BYTES = 2n * GIB_BYTES; const DEFAULT_CONTAINERD_ROOT = "/var/lib/containerd"; const DEFAULT_CONTAINERD_CONFIG = "/etc/containerd/config.toml"; -const DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"; -const DOCKER_SOCKET_PEER_PROBE_TIMEOUT_MS = 5_000; -// SO_PEERCRED translates the daemon PID into the caller's PID namespace. A -// hidden peer therefore exposes the namespace-local PID 1 topology directly, -// while equal mountinfo snapshots prove that client-side statfs sees the same -// mount table as the process serving the selected Docker socket. -const DOCKER_SOCKET_PEER_PROBE = String.raw` -import socket -import struct -import sys - -def read_mountinfo(target): - with open(target, "rb") as stream: - return stream.read() - -sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) -sock.settimeout(2) -try: - sock.connect(sys.argv[1]) - size = struct.calcsize("3i") - peer_pid, _, _ = struct.unpack( - "3i", sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, size) - ) - if peer_pid <= 0: - print("unknown") - else: - own_before = read_mountinfo("/proc/self/mountinfo") - peer = read_mountinfo(f"/proc/{peer_pid}/mountinfo") - own_after = read_mountinfo("/proc/self/mountinfo") - if own_before != own_after: - print("unknown") - else: - print("shared" if own_before == peer else "different") -except (OSError, ValueError): - print("unknown") -finally: - sock.close() -`; +const DEFAULT_DOCKER_SOCKET_PATHS = new Set(["/var/run/docker.sock", "/run/docker.sock"]); interface StorageProbeDeps { - clientContainerized: boolean; dockerContext: string | undefined; dockerHost: string | undefined; dockerInfo: () => string; - dockerReadBind: (image: string, sourcePath: string) => string; - dockerSocketPeerSharesMountNamespace: () => boolean | null; exists: (target: string) => boolean; - osRelease: string; platform: NodeJS.Platform; readFile: (target: string) => string; statfs: (target: string) => { bavail: bigint; bsize: bigint }; } -function clientLooksContainerized(): boolean { - if (String(process.env.container ?? "").trim()) return true; - if ( - fs.existsSync("/.dockerenv") || - fs.existsSync("/run/.containerenv") || - fs.existsSync("/run/systemd/container") - ) { - return true; - } - try { - return /(?:^|[/:.-])(?:docker|kubepods|containerd|libpod|lxc)(?:$|[/:.-])/im.test( - fs.readFileSync("/proc/self/cgroup", "utf8"), - ); - } catch { - return false; - } -} - -export function dockerSocketPeerSharesMountNamespace( - socketPath: string, - capture: ( - command: readonly string[], - options: { ignoreError: true; timeout: number }, - ) => string = runCapture, - platform: NodeJS.Platform = process.platform, -): boolean | null { - if (platform !== "linux") return null; - const result = capture(["python3", "-I", "-c", DOCKER_SOCKET_PEER_PROBE, socketPath], { - ignoreError: true, - timeout: DOCKER_SOCKET_PEER_PROBE_TIMEOUT_MS, - }).trim(); - if (result === "shared") return true; - if (result === "different") return false; - return null; -} - function defaultStorageProbeDeps(): StorageProbeDeps { const dockerEnv = buildVllmDockerEnv(); return { - clientContainerized: clientLooksContainerized(), dockerContext: dockerEnv.DOCKER_CONTEXT, dockerHost: dockerEnv.DOCKER_HOST, dockerInfo: () => @@ -118,39 +36,7 @@ function defaultStorageProbeDeps(): StorageProbeDeps { ignoreError: true, timeout: 10_000, }), - dockerReadBind: (image, sourcePath) => - dockerCapture( - [ - "run", - "--rm", - "--network=none", - "--pull=never", - "--entrypoint", - "/bin/cat", - "--mount", - `type=bind,src=${sourcePath},dst=/nemoclaw-storage-sentinel,readonly`, - image, - "/nemoclaw-storage-sentinel", - ], - { - env: dockerEnv, - ignoreError: true, - timeout: 30_000, - }, - ), - dockerSocketPeerSharesMountNamespace: () => { - const endpoint = dockerEnv.DOCKER_HOST?.trim() ?? ""; - const socketPath = endpoint.startsWith("unix://") - ? endpoint.slice("unix://".length) - : path.isAbsolute(endpoint) - ? endpoint - : endpoint - ? "" - : DEFAULT_DOCKER_SOCKET; - return socketPath ? dockerSocketPeerSharesMountNamespace(socketPath) : null; - }, exists: fs.existsSync, - osRelease: os.release(), platform: process.platform, readFile: (target) => fs.readFileSync(target, "utf8"), statfs: (target) => fs.statfsSync(target, { bigint: true }), @@ -167,15 +53,11 @@ export type StorageProbeResult = | { ok: true; capacity: StorageCapacity } | { ok: false; reason: string; path?: string; source?: string }; -export type DockerHostLocalityResult = { ok: true } | { ok: false; reason: string }; - interface DockerInfoShape { ClientInfo?: { Context?: unknown }; DockerRootDir?: unknown; Driver?: unknown; DriverStatus?: unknown; - Name?: unknown; - OperatingSystem?: unknown; OSType?: unknown; SecurityOptions?: unknown; } @@ -205,19 +87,6 @@ export function imageStorageRequirementBytes(downloadSizeBytes: number): bigint ); } -/** - * Hugging Face downloads need the published repository size plus temporary - * write headroom. A local cache cannot prove which blobs a mutable remote - * revision will reuse, so the estimate deliberately does not subtract it. - */ -export function modelStorageRequirementBytes(downloadSizeBytes: number): bigint { - const downloadBytes = positiveBytes(downloadSizeBytes, "vLLM model download size"); - const tenPercent = (downloadBytes + 9n) / 10n; - const headroom = - tenPercent > MODEL_MINIMUM_HEADROOM_BYTES ? tenPercent : MODEL_MINIMUM_HEADROOM_BYTES; - return downloadBytes + headroom; -} - export function formatStorageBytes(bytes: bigint): string { const roundedTenths = (bytes * 10n + GIB_BYTES / 2n) / GIB_BYTES; const whole = roundedTenths / 10n; @@ -250,6 +119,11 @@ function absoluteString(value: unknown): string | null { return typeof value === "string" && path.isAbsolute(value) ? value : null; } +function isDefaultDockerSocket(endpoint: string): boolean { + const socketPath = endpoint.startsWith("unix://") ? endpoint.slice("unix://".length) : endpoint; + return DEFAULT_DOCKER_SOCKET_PATHS.has(socketPath); +} + type ContainerdRootResult = { ok: true; root: string } | { ok: false; reason: string }; function containerdRootFromConfig(deps: StorageProbeDeps): ContainerdRootResult { @@ -286,59 +160,36 @@ function containerdRootFromConfig(deps: StorageProbeDeps): ContainerdRootResult } } -function nativeDockerHostProblem(info: DockerInfoShape, deps: StorageProbeDeps): string | null { +function localDockerHostProblem(info: DockerInfoShape, deps: StorageProbeDeps): string | null { if (deps.platform !== "linux") return `Docker runs behind a ${deps.platform} host boundary`; - if (/microsoft|wsl/i.test(deps.osRelease)) return "Docker runs behind a WSL host boundary"; - if (deps.clientContainerized) { - return "Docker client runs inside a container, so daemon bind-mount storage cannot be verified"; - } if (info.OSType !== "linux") return "Docker is not using a Linux engine"; - const product = `${String(info.Name ?? "")} ${String(info.OperatingSystem ?? "")}`; - if (/docker desktop|colima|podman/i.test(product)) { - return "Docker runs inside a VM or compatibility layer"; - } - const dockerHost = deps.dockerHost?.trim() ?? ""; - // DOCKER_HOST takes precedence over DOCKER_CONTEXT in the Docker CLI. - const explicitContext = dockerHost ? "" : deps.dockerContext?.trim(); - const reportedContext = - typeof info.ClientInfo?.Context === "string" ? info.ClientInfo.Context.trim() : ""; - const context = explicitContext || reportedContext; - if (!context) return "docker info did not report the effective Docker context"; - if (context !== "default") { - return `Docker uses a named context (${context}) whose host filesystem cannot be verified`; + // An explicit DOCKER_CONTEXT overrides DOCKER_HOST in the Docker CLI. + const explicitContext = deps.dockerContext?.trim() ?? ""; + if (explicitContext) { + if (explicitContext !== "default") { + return `Docker uses a named context (${explicitContext}) whose host filesystem cannot be inspected`; + } + return null; } - const endpoint = dockerHost; - if (endpoint) { - const localSocket = endpoint.startsWith("unix://") || path.isAbsolute(endpoint); - if (!localSocket) return `Docker uses a remote endpoint (${endpoint})`; - if (endpoint !== DEFAULT_DOCKER_SOCKET && endpoint !== `unix://${DEFAULT_DOCKER_SOCKET}`) { - return `Docker uses a non-default socket (${endpoint}) whose daemon host filesystem cannot be verified`; + if (dockerHost) { + if (isDefaultDockerSocket(dockerHost)) return null; + if (dockerHost.startsWith("unix://") || path.isAbsolute(dockerHost)) { + return `Docker uses a non-default socket (${dockerHost}) whose host filesystem cannot be inspected`; } + return `Docker uses a remote endpoint (${dockerHost})`; } - const sharesPeerMountNamespace = deps.dockerSocketPeerSharesMountNamespace(); - if (sharesPeerMountNamespace === false) { - return "Docker client and socket peer use different mount namespaces, so daemon filesystem identity cannot be verified"; - } - if (sharesPeerMountNamespace === null) { - return "Docker socket peer PID or mount namespace could not be verified"; + const reportedContext = + typeof info.ClientInfo?.Context === "string" ? info.ClientInfo.Context.trim() : ""; + if (reportedContext && reportedContext !== "default") { + return `Docker uses a named context (${reportedContext}) whose host filesystem cannot be inspected`; } return null; } -export function probeDockerHostLocality( - overrides: Partial = {}, -): DockerHostLocalityResult { - const deps = { ...defaultStorageProbeDeps(), ...overrides }; - const info = parseDockerInfo(deps.dockerInfo()); - if (!info) return { ok: false, reason: "docker info did not return valid JSON" }; - const hostProblem = nativeDockerHostProblem(info, deps); - return hostProblem ? { ok: false, reason: hostProblem } : { ok: true }; -} - export function resolveDockerStorageLocations( rawInfo: string, overrides: Partial = {}, @@ -346,7 +197,7 @@ export function resolveDockerStorageLocations( const deps = { ...defaultStorageProbeDeps(), ...overrides }; const info = parseDockerInfo(rawInfo); if (!info) return { ok: false, reason: "docker info did not return valid JSON" }; - const hostProblem = nativeDockerHostProblem(info, deps); + const hostProblem = localDockerHostProblem(info, deps); if (hostProblem) return { ok: false, reason: hostProblem }; const dockerRoot = absoluteString(info.DockerRootDir); @@ -385,7 +236,7 @@ export function resolveDockerStorageLocations( return { ok: false, reason: - "rootless Docker detected; managed vLLM cannot verify the containerd image-store location", + "rootless Docker detected; managed vLLM cannot inspect the containerd image-store location", }; } const containerd = containerdRootFromConfig(deps); @@ -420,8 +271,7 @@ function capacityForLocation( export function probeDockerStorage(overrides: Partial = {}): StorageProbeResult { const deps = { ...defaultStorageProbeDeps(), ...overrides }; - const rawInfo = deps.dockerInfo(); - const resolved = resolveDockerStorageLocations(rawInfo, deps); + const resolved = resolveDockerStorageLocations(deps.dockerInfo(), deps); if (!resolved.ok) return resolved; let limiting: StorageCapacity | null = null; @@ -436,62 +286,3 @@ export function probeDockerStorage(overrides: Partial = {}): S ? { ok: true, capacity: limiting } : { ok: false, reason: "docker info did not report a usable image-storage path" }; } - -function nearestExistingPath(target: string, exists: (candidate: string) => boolean): string { - let candidate = path.resolve(target); - while (!exists(candidate)) { - const parent = path.dirname(candidate); - if (parent === candidate) return candidate; - candidate = parent; - } - return candidate; -} - -export function probeDockerBindIdentity( - cacheDir: string, - image: string, - overrides: Partial = {}, -): DockerHostLocalityResult { - const deps = { ...defaultStorageProbeDeps(), ...overrides }; - const info = parseDockerInfo(deps.dockerInfo()); - if (!info) return { ok: false, reason: "docker info did not return valid JSON" }; - const hostProblem = nativeDockerHostProblem(info, deps); - if (hostProblem) return { ok: false, reason: hostProblem }; - - const target = nearestExistingPath(cacheDir, deps.exists); - const token = `nemoclaw-storage-token:${randomUUID()}`; - const sentinelPath = path.join(target, `.nemoclaw-storage-probe-${randomUUID()}`); - try { - fs.writeFileSync(sentinelPath, token, { encoding: "utf8", flag: "wx", mode: 0o600 }); - if (deps.dockerReadBind(image, sentinelPath) !== token) { - return { - ok: false, - reason: - "Docker daemon could not read the client storage sentinel; bind-mount filesystem identity cannot be verified", - }; - } - return { ok: true }; - } catch (err) { - return { - ok: false, - reason: `could not verify Docker bind-mount filesystem identity at ${target}: ${(err as Error).message}`, - }; - } finally { - fs.rmSync(sentinelPath, { force: true }); - } -} - -export function probeModelCacheStorage( - cacheDir: string, - overrides: Partial = {}, -): StorageProbeResult { - const deps = { ...defaultStorageProbeDeps(), ...overrides }; - const target = nearestExistingPath(cacheDir, deps.exists); - return capacityForLocation( - { - path: target, - source: target === path.resolve(cacheDir) ? "model cache" : "model cache filesystem", - }, - deps.statfs, - ); -} diff --git a/src/lib/inference/vllm.test.ts b/src/lib/inference/vllm.test.ts index 5872c6e6a04..a31b3bee5ac 100644 --- a/src/lib/inference/vllm.test.ts +++ b/src/lib/inference/vllm.test.ts @@ -15,10 +15,7 @@ const mocks = vi.hoisted(() => ({ dockerSpawn: vi.fn(), dockerStop: vi.fn(), getGpuIndicesByName: vi.fn<(_pattern: RegExp) => number[]>(() => []), - probeDockerBindIdentity: vi.fn(), - probeDockerHostLocality: vi.fn(), probeDockerStorage: vi.fn(), - probeModelCacheStorage: vi.fn(), runCapture: vi.fn(), })); @@ -44,10 +41,7 @@ vi.mock("./vllm-storage", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - probeDockerBindIdentity: mocks.probeDockerBindIdentity, - probeDockerHostLocality: mocks.probeDockerHostLocality, probeDockerStorage: mocks.probeDockerStorage, - probeModelCacheStorage: mocks.probeModelCacheStorage, }; }); @@ -64,16 +58,10 @@ import { beforeEach(() => { mocks.dockerImageInspectFormat.mockReturnValue(""); - mocks.probeDockerBindIdentity.mockReturnValue({ ok: true }); - mocks.probeDockerHostLocality.mockReturnValue({ ok: true }); mocks.probeDockerStorage.mockReturnValue({ ok: true, capacity: { availableBytes: 1_000_000_000_000n, path: "/docker", source: "Docker" }, }); - mocks.probeModelCacheStorage.mockReturnValue({ - ok: true, - capacity: { availableBytes: 1_000_000_000_000n, path: "/models", source: "model cache" }, - }); }); function mockDockerSpawnSuccess(): EventEmitter & { @@ -136,14 +124,6 @@ function mockInconclusiveDockerStorage(): void { }); } -function mockInconclusiveModelCacheStorage(): void { - mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image"); - mocks.probeModelCacheStorage.mockReturnValue({ - ok: false, - reason: "could not inspect the model cache: permission denied", - }); -} - describe("vLLM served route identity", () => { it("uses one safe served-model override and rejects ambiguous aliases (#6315)", () => { expect(resolveVllmServedModelId("catalog/model", [])).toBe("catalog/model"); @@ -186,6 +166,7 @@ describe("vLLM profile detection", () => { expect(profile!.image).toBe( "nvcr.io/nvidia/vllm@sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2", ); + expect(profile!.imageDownloadSizeBytes).toBe(9_603_085_145); expect(profile!.defaultModel.id).toBe("nvidia/Qwen3.6-35B-A3B-NVFP4"); expect(profile!.defaultModel.envValue).toBe("qwen3.6-35b-a3b-nvfp4"); }); @@ -644,90 +625,27 @@ describe("installVllm model resolution", () => { ); }); - it.each([ - { - name: "inconclusive Docker storage in non-interactive mode", - hasImage: false, - nonInteractive: true, - replies: [], - expectedPromptCalls: 0, - expectedWarning: "Unable to verify Docker storage for the managed vLLM image", - setup: mockInconclusiveDockerStorage, - }, - { - name: "inconclusive Docker storage after an interactive decline", - hasImage: false, - nonInteractive: false, - replies: ["y", "n"], - expectedPromptCalls: 2, - expectedWarning: "Unable to verify Docker storage for the managed vLLM image", - setup: mockInconclusiveDockerStorage, - }, - { - name: "inconclusive model-cache storage in non-interactive mode", - hasImage: true, - nonInteractive: true, - replies: [], - expectedPromptCalls: 0, - expectedWarning: "Unable to verify model-cache storage for managed vLLM", - setup: mockInconclusiveModelCacheStorage, - }, - { - name: "inconclusive model-cache storage after an interactive decline", - hasImage: true, - nonInteractive: false, - replies: ["y", "n"], - expectedPromptCalls: 2, - expectedWarning: "Unable to verify model-cache storage for managed vLLM", - setup: mockInconclusiveModelCacheStorage, - }, - ] as const)("$name stops before guarded downloads (#6757)", async (testCase) => { + it("reports an inconclusive capacity check without blocking the image pull (#6757)", async () => { const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!; process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue; mockSuccessfulVllmInstall(profile.containerName); - testCase.setup(); - const replies = [...testCase.replies]; - const promptFn = vi.fn(async () => replies.shift() ?? ""); + mockInconclusiveDockerStorage(); + const promptFn = vi.fn(); const result = await installVllm(profile, { - hasImage: testCase.hasImage, - nonInteractive: testCase.nonInteractive, + hasImage: false, + nonInteractive: true, promptFn, }); - expect(result).toEqual({ ok: false }); - expect(promptFn).toHaveBeenCalledTimes(testCase.expectedPromptCalls); - expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled(); - expect(mocks.dockerSpawn).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true }); + expect(promptFn).not.toHaveBeenCalled(); + expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1); + expect(mocks.dockerSpawn).toHaveBeenCalledTimes(1); const errors = errSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n"); - expect(errors).toContain(testCase.expectedWarning); + expect(errors).toContain("Unable to verify Docker storage for the managed vLLM image"); expect(errors).toContain("Available: unknown ("); - }); - - it("does not pull an uncached image when a nested PID namespace hides the Docker peer (#6757)", async () => { - const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!; - process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue; - mockSuccessfulVllmInstall(profile.containerName); - mocks.probeDockerStorage.mockReturnValue({ - ok: false, - reason: "Docker socket peer PID or mount namespace could not be verified", - }); - - const result = await installVllm(profile, { - hasImage: false, - nonInteractive: true, - promptFn: vi.fn(), - }); - - expect(result).toEqual({ ok: false }); - expect(mocks.probeDockerStorage).toHaveBeenCalledTimes(1); - expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled(); - expect(mocks.probeDockerBindIdentity).not.toHaveBeenCalled(); - expect(mocks.dockerSpawn).not.toHaveBeenCalled(); - expect(mocks.dockerRunDetached).not.toHaveBeenCalled(); - expect(errSpy).toHaveBeenCalledWith( - expect.stringContaining("Docker socket peer PID or mount namespace could not be verified"), - ); + expect(errors).toContain("Continuing because Docker storage capacity could not be verified"); }); it("honors only the dedicated disk-space override in non-interactive setup (#6757)", async () => { @@ -739,11 +657,6 @@ describe("installVllm model resolution", () => { ok: true, capacity: { availableBytes: 1n, path: "/docker-low", source: "containerd image store" }, }); - mocks.probeModelCacheStorage.mockReturnValue({ - ok: true, - capacity: { availableBytes: 1n, path: "/models-low", source: "model cache" }, - }); - const result = await installVllm(profile, { hasImage: false, nonInteractive: true, @@ -758,30 +671,6 @@ describe("installVllm model resolution", () => { ); }); - it("stops before either download when model-cache capacity is declined (#6757)", async () => { - const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; - process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue; - mockSuccessfulVllmInstall(profile.containerName); - mocks.probeModelCacheStorage.mockReturnValue({ - ok: true, - capacity: { availableBytes: 1n, path: "/models-low", source: "model cache" }, - }); - const replies = ["y", "n"]; - - const result = await installVllm(profile, { - hasImage: false, - nonInteractive: false, - promptFn: vi.fn(async () => replies.shift() ?? ""), - }); - - expect(result).toEqual({ ok: false }); - expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled(); - expect(mocks.dockerSpawn).not.toHaveBeenCalled(); - const errors = errSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n"); - expect(errors).toContain("Insufficient model-cache storage for managed vLLM"); - expect(errors).toContain(profile.defaultModel.id); - }); - it("reuses an authoritatively cached image without a cold-pull capacity check (#6757)", async () => { const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; mockSuccessfulVllmInstall(profile.containerName); @@ -832,176 +721,6 @@ describe("installVllm model resolution", () => { expect(mocks.dockerSpawn).not.toHaveBeenCalled(); }); - it("rechecks model capacity after the image pull before hf download (#6757)", async () => { - const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; - process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue; - mockSuccessfulVllmInstall(profile.containerName); - mocks.probeModelCacheStorage - .mockReturnValueOnce({ - ok: true, - capacity: { - availableBytes: 1_000_000_000_000n, - path: "/shared", - source: "model cache", - }, - }) - .mockReturnValueOnce({ - ok: true, - capacity: { availableBytes: 1n, path: "/shared", source: "model cache" }, - }); - - const result = await installVllm(profile, { - hasImage: false, - nonInteractive: true, - promptFn: vi.fn(), - }); - - expect(result).toEqual({ ok: false }); - expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1); - expect(mocks.probeDockerHostLocality).toHaveBeenCalledTimes(1); - expect(mocks.probeDockerBindIdentity).toHaveBeenCalledTimes(1); - expect(mocks.probeModelCacheStorage).toHaveBeenCalledTimes(2); - expect(mocks.dockerSpawn).not.toHaveBeenCalled(); - }); - - it("rechecks model capacity for a cached image before hf download (#6757)", async () => { - const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; - process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue; - mockSuccessfulVllmInstall(profile.containerName); - mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image"); - mocks.probeModelCacheStorage - .mockReturnValueOnce({ - ok: true, - capacity: { - availableBytes: 1_000_000_000_000n, - path: "/shared", - source: "model cache", - }, - }) - .mockReturnValueOnce({ - ok: true, - capacity: { availableBytes: 1n, path: "/shared", source: "model cache" }, - }); - - const result = await installVllm(profile, { - hasImage: true, - nonInteractive: true, - promptFn: vi.fn(), - }); - - expect(result).toEqual({ ok: false }); - expect(mocks.probeDockerStorage).not.toHaveBeenCalled(); - expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1); - expect(mocks.probeDockerHostLocality).not.toHaveBeenCalled(); - expect(mocks.probeDockerBindIdentity).toHaveBeenCalledTimes(2); - expect(mocks.probeModelCacheStorage).toHaveBeenCalledTimes(2); - expect(mocks.dockerSpawn).not.toHaveBeenCalled(); - }); - - it.each([ - { - reason: "Docker uses a remote endpoint (ssh://builder.example.test)", - selectorDescription: "remote DOCKER_HOST", - selectorName: "DOCKER_HOST" as const, - selectorValue: "ssh://builder.example.test", - }, - { - reason: - "Docker uses a non-default socket (unix:///tmp/forwarded-remote.sock) whose daemon host filesystem cannot be verified", - selectorDescription: "forwarded Unix DOCKER_HOST", - selectorName: "DOCKER_HOST" as const, - selectorValue: "unix:///tmp/forwarded-remote.sock", - }, - { - reason: - "Docker uses a named context (remote-builder) whose host filesystem cannot be verified", - selectorDescription: "named DOCKER_CONTEXT", - selectorName: "DOCKER_CONTEXT" as const, - selectorValue: "remote-builder", - }, - { - reason: - "Docker client runs inside a container, so daemon bind-mount storage cannot be verified", - selectorDescription: "default socket mounted into a client container", - selectorName: "DOCKER_HOST" as const, - selectorValue: "unix:///var/run/docker.sock", - }, - { - reason: - "Docker daemon could not read the client storage sentinel; bind-mount filesystem identity cannot be verified", - selectorDescription: "namespace-local PID 1 bind mismatch", - selectorName: "DOCKER_HOST" as const, - selectorValue: "unix:///var/run/docker.sock", - }, - ])("blocks a cached image for an unverifiable $selectorDescription (#6757)", async ({ - reason, - selectorName, - selectorValue, - }) => { - delete process.env.DOCKER_HOST; - delete process.env.DOCKER_CONTEXT; - process.env[selectorName] = selectorValue; - const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; - mockSuccessfulVllmInstall(profile.containerName); - mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image"); - mocks.probeDockerBindIdentity.mockReturnValue({ - ok: false, - reason, - }); - - const result = await installVllm(profile, { - hasImage: true, - nonInteractive: true, - promptFn: vi.fn(), - }); - - expect(result).toEqual({ ok: false }); - expect(mocks.dockerImageInspectFormat).toHaveBeenCalledWith( - "{{.Id}}", - profile.image, - expect.objectContaining({ - env: expect.objectContaining({ [selectorName]: selectorValue }), - }), - ); - expect(mocks.probeDockerBindIdentity).toHaveBeenCalledWith( - path.join(os.homedir(), ".cache", "huggingface"), - profile.image, - ); - expect(mocks.probeModelCacheStorage).not.toHaveBeenCalled(); - expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled(); - expect(mocks.dockerSpawn).not.toHaveBeenCalled(); - expect(mocks.dockerRunDetached).not.toHaveBeenCalled(); - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(reason)); - }); - - it("allows the dedicated override for an unverifiable cached-image host (#6757)", async () => { - process.env.DOCKER_HOST = "ssh://builder.example.test"; - delete process.env.DOCKER_CONTEXT; - process.env.NEMOCLAW_IGNORE_VLLM_DISK_SPACE = "1"; - const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; - mockSuccessfulVllmInstall(profile.containerName); - mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image"); - mocks.probeDockerBindIdentity.mockReturnValue({ - ok: false, - reason: "Docker uses a remote endpoint (ssh://builder.example.test)", - }); - - const result = await installVllm(profile, { - hasImage: true, - nonInteractive: true, - promptFn: vi.fn(), - }); - - expect(result).toEqual({ ok: true }); - expect(mocks.probeDockerBindIdentity).toHaveBeenCalledTimes(2); - expect(mocks.probeModelCacheStorage).not.toHaveBeenCalled(); - expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1); - expect(mocks.dockerSpawn).toHaveBeenCalledTimes(1); - expect(errSpy).toHaveBeenCalledWith( - expect.stringContaining("NEMOCLAW_IGNORE_VLLM_DISK_SPACE=1"), - ); - }); - it("uses one Docker context throughout a successful managed install (#6757)", async () => { process.env.DOCKER_CONTEXT = "local-test-context"; delete process.env.DOCKER_HOST; diff --git a/src/lib/inference/vllm.ts b/src/lib/inference/vllm.ts index 003dcc6083e..32a5e3023f7 100644 --- a/src/lib/inference/vllm.ts +++ b/src/lib/inference/vllm.ts @@ -35,11 +35,7 @@ import { resolveVllmInstallModel } from "./vllm-prompt"; import { formatStorageBytes, imageStorageRequirementBytes, - modelStorageRequirementBytes, - probeDockerBindIdentity, - probeDockerHostLocality, probeDockerStorage, - probeModelCacheStorage, type StorageProbeResult, VLLM_STORAGE_OVERRIDE_ENV, } from "./vllm-storage"; @@ -672,49 +668,51 @@ function containerStillRunning(profile: VllmProfile): boolean { return out === profile.containerName; } -interface StorageWarning { - item: string; - itemLabel: "Image" | "Model"; - probe: StorageProbeResult; - question: string; - remediation: readonly string[]; - requiredBytes: bigint; - subject: string; -} - -function printStorageWarning(warning: StorageWarning): void { - const insufficient = - warning.probe.ok && warning.probe.capacity.availableBytes < warning.requiredBytes; +function printImageStorageWarning( + profile: VllmProfile, + probe: StorageProbeResult, + requiredBytes: bigint, +): void { + const insufficient = probe.ok && probe.capacity.availableBytes < requiredBytes; console.error(""); - console.error(` ${insufficient ? "Insufficient" : "Unable to verify"} ${warning.subject}.`); + console.error( + ` ${insufficient ? "Insufficient" : "Unable to verify"} Docker storage for the managed vLLM image.`, + ); console.error(""); - console.error(` ${warning.itemLabel}: ${warning.item}`); + console.error(` Image: ${profile.image}`); console.error( ` Available: ${ - warning.probe.ok - ? formatStorageBytes(warning.probe.capacity.availableBytes) - : `unknown (${warning.probe.reason})` + probe.ok ? formatStorageBytes(probe.capacity.availableBytes) : `unknown (${probe.reason})` }`, ); - console.error(` Required: approximately ${formatStorageBytes(warning.requiredBytes)}`); - if (warning.probe.ok) { - console.error(` Storage: ${warning.probe.capacity.source} (${warning.probe.capacity.path})`); - } else if (warning.probe.path) { - console.error(` Storage: ${warning.probe.source ?? "filesystem"} (${warning.probe.path})`); + console.error(` Required: approximately ${formatStorageBytes(requiredBytes)}`); + if (probe.ok) { + console.error(` Storage: ${probe.capacity.source} (${probe.capacity.path})`); + } else if (probe.path) { + console.error(` Storage: ${probe.source ?? "filesystem"} (${probe.path})`); } console.error(""); - for (const line of warning.remediation) console.error(` ${line}`); + if (insufficient) console.error(" Free or expand Docker storage before continuing."); + console.error(" Useful diagnostics:"); + console.error(" docker system df"); + console.error(" docker info --format '{{.DockerRootDir}}'"); } -async function storageWarningAccepted( - warning: StorageWarning, +async function imageStorageAccepted( + profile: VllmProfile, opts: InstallVllmOptions, env: NodeJS.ProcessEnv = process.env, ): Promise { - if (warning.probe.ok && warning.probe.capacity.availableBytes >= warning.requiredBytes) { + const probe = probeDockerStorage(); + const requiredBytes = imageStorageRequirementBytes(profile.imageDownloadSizeBytes); + if (probe.ok && probe.capacity.availableBytes >= requiredBytes) { + return true; + } + printImageStorageWarning(profile, probe, requiredBytes); + if (!probe.ok) { + console.error(" Continuing because Docker storage capacity could not be verified."); return true; } - printStorageWarning(warning); if (env[VLLM_STORAGE_OVERRIDE_ENV] === "1") { console.error(` Continuing because ${VLLM_STORAGE_OVERRIDE_ENV}=1.`); return true; @@ -725,58 +723,7 @@ async function storageWarningAccepted( ); return false; } - return isAffirmativeAnswer(await opts.promptFn(warning.question)); -} - -async function imageStorageAccepted( - profile: VllmProfile, - opts: InstallVllmOptions, -): Promise { - return storageWarningAccepted( - { - item: profile.image, - itemLabel: "Image", - probe: probeDockerStorage(), - question: " Continue with the pull anyway? [y/N]: ", - remediation: [ - "Free or expand Docker storage before continuing.", - "Useful diagnostics:", - " docker system df", - " docker info --format '{{.DockerRootDir}}'", - ], - requiredBytes: imageStorageRequirementBytes(profile.imageDownloadSizeBytes), - subject: "Docker storage for the managed vLLM image", - }, - opts, - ); -} - -async function modelStorageAccepted( - model: VllmModelDef, - opts: InstallVllmOptions, - bindProbeImage?: string, -): Promise { - const cacheDir = hostHfCacheDir(); - const dockerHost = bindProbeImage - ? probeDockerBindIdentity(cacheDir, bindProbeImage) - : probeDockerHostLocality(); - const probe: StorageProbeResult = dockerHost.ok ? probeModelCacheStorage(cacheDir) : dockerHost; - return storageWarningAccepted( - { - item: model.id, - itemLabel: "Model", - probe, - question: " Continue with the model download anyway? [y/N]: ", - remediation: [ - `Free or expand storage for ${cacheDir} before continuing.`, - "Useful diagnostic:", - ' df -h "$HOME/.cache/huggingface"', - ], - requiredBytes: modelStorageRequirementBytes(model.downloadSizeBytes), - subject: "model-cache storage for managed vLLM", - }, - opts, - ); + return isAffirmativeAnswer(await opts.promptFn(" Continue with the pull anyway? [y/N]: ")); } interface InstallVllmOptions { @@ -886,9 +833,6 @@ export async function installVllm( if (!hasImage && !(await imageStorageAccepted(profile, opts))) { return { ok: false }; } - if (!(await modelStorageAccepted(model, opts, hasImage ? profile.image : undefined))) { - return { ok: false }; - } const pull = await pullImage(profile); if (!pull.ok) { @@ -896,12 +840,6 @@ export async function installVllm( return { ok: false }; } - // The image now exists, so this re-probe also proves daemon/client bind - // identity before their individually valid estimates can overcommit storage. - if (!(await modelStorageAccepted(model, opts, profile.image))) { - return { ok: false }; - } - const modelDownload = await downloadModel(profile, model); if (!modelDownload.ok) { console.error(` vLLM install failed: ${String(modelDownload.reason)}`); diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index f916e84c9d3..57873166cec 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -108,6 +108,43 @@ sys.exit(exit_code) }); } + function detectExpressPlatformForProductName(productName: string) { + return spawnSync( + "bash", + [ + "-c", + ` +source "$INSTALLER_UNDER_TEST" >/dev/null +function [ { + if [[ "$#" -eq 3 && "$1" = "-r" && "$2" = "/sys/class/dmi/id/product_name" && "$3" = "]" ]]; then + return 0 + fi + builtin [ "$@" +} +cat() { + if [[ "$#" -eq 1 && "$1" = "/sys/class/dmi/id/product_name" ]]; then + printf "%s" "$EXPRESS_PRODUCT_NAME" + return + fi + command cat "$@" +} +is_wsl_host() { return 1; } +detect_express_platform +`, + ], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-express-platform-detect-")), + PATH: TEST_SYSTEM_PATH, + INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, + EXPRESS_PRODUCT_NAME: productName, + }, + }, + ); + } + it("offers express install when curl-piped stdin still has a controlling TTY", () => { const result = runExpressPromptWithTty("y\n", "pipe"); const output = `${result.stdout}${result.stderr}`; @@ -185,6 +222,22 @@ detect_express_platform expect(result.stdout).toBe("Windows WSL"); }); + it("recognizes Station GB300 OEM firmware as DGX Station", () => { + const result = detectExpressPlatformForProductName("Dell Pro Max with Station GB300"); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(result.stdout).toBe("DGX Station"); + }); + + it("requires both Station and GB300 for the OEM firmware match", () => { + for (const productName of ["Dell Pro Max with Station GB200", "Dell Pro Max with GB300"]) { + const result = detectExpressPlatformForProductName(productName); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(result.stdout).toBe(""); + } + }); + it("maps Windows WSL express install to Windows-host Ollama", () => { const result = runExpressPromptWithTty("\n", "pipe", "Windows WSL"); const output = `${result.stdout}${result.stderr}`;