diff --git a/src/lib/onboard/fatal-runtime-preflight.test.ts b/src/lib/onboard/fatal-runtime-preflight.test.ts new file mode 100644 index 00000000000..b394e32f3e5 --- /dev/null +++ b/src/lib/onboard/fatal-runtime-preflight.test.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { isLinuxDockerDriverGatewayEnabled } from "./docker-driver-platform"; +import { rejectUnsupportedContainerRuntime } from "./fatal-runtime-preflight"; +import type { HostAssessment } from "./preflight"; + +function hostWithRuntime(runtime: HostAssessment["runtime"]): HostAssessment { + return { + platform: process.platform, + isWsl: false, + runtime, + dockerInstalled: true, + dockerRunning: true, + dockerReachable: true, + nodeInstalled: true, + openshellInstalled: true, + isContainerRuntimeUnderProvisioned: false, + hasNestedOverlayConflict: false, + requiresHostCgroupnsFix: false, + isUnsupportedRuntime: runtime === "podman", + isHeadlessLikely: false, + hasNvidiaGpu: false, + dockerCdiSpecDirs: [], + cdiNvidiaGpuSpecMissing: false, + nvidiaContainerToolkitInstalled: false, + notes: [], + } as HostAssessment; +} + +describe("rejectUnsupportedContainerRuntime (#7320)", () => { + // The Docker-driver gateway path is forced on Linux and Apple Silicon macOS; + // the reject gate only fires there. Gate the test on the same predicate via + // it.skipIf (not an in-body `if`) so it runs on the Linux CI runner. + it.skipIf(!isLinuxDockerDriverGatewayEnabled())( + "exits when Podman is detected on a Docker-driver gateway platform", + () => { + const exit = vi.fn(() => { + throw new Error("exit"); + }); + expect(() => + rejectUnsupportedContainerRuntime(hostWithRuntime("podman"), exit as never), + ).toThrow("exit"); + expect(exit).toHaveBeenCalledWith(1); + }, + ); + + it("does not exit for a supported Docker runtime", () => { + const exit = vi.fn(); + rejectUnsupportedContainerRuntime(hostWithRuntime("docker"), exit as never); + expect(exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts index f416401ceec..0bfd678e3a5 100644 --- a/src/lib/onboard/preflight-messages.test.ts +++ b/src/lib/onboard/preflight-messages.test.ts @@ -72,6 +72,9 @@ describe("onboard preflight severity messages (#6004)", () => { expect(lines(err)[0]).toContain("✗"); expect(lines(err)[0]).toContain("Docker driver"); expect(lines(err).join("\n")).toContain("Switch to Docker Engine"); + // macOS reporters use Docker Desktop or Colima, not native Docker Engine (#7320). + expect(lines(err).join("\n")).toContain("Docker Desktop"); + expect(lines(err).join("\n")).toContain("Colima"); }); it("prints the under-provisioned warning to stderr with a ⚠ marker and colima resize", () => { diff --git a/src/lib/onboard/preflight-messages.ts b/src/lib/onboard/preflight-messages.ts index a8dff7330b8..6f2dc8af087 100644 --- a/src/lib/onboard/preflight-messages.ts +++ b/src/lib/onboard/preflight-messages.ts @@ -24,7 +24,7 @@ export function printDockerNotReachableError(): void { export function printUnsupportedRuntimeError(): void { console.error(failLine(`${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`)); console.error(` Podman is not supported for this ${cliDisplayName()} integration path.`); - console.error(" Switch to Docker Engine and rerun onboarding."); + console.error(" Switch to Docker Engine, Docker Desktop, or Colima, then rerun onboarding."); } export interface UnderProvisionedRuntimeWarning { diff --git a/src/lib/onboard/preflight-podman-compat.test.ts b/src/lib/onboard/preflight-podman-compat.test.ts new file mode 100644 index 00000000000..ac8e48235bc --- /dev/null +++ b/src/lib/onboard/preflight-podman-compat.test.ts @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +// Import source directly so tests cannot pass against a stale build. +import { assessHost } from "./preflight"; + +// Regression: NemoClaw #7320. On Apple Silicon macOS a Docker CLI routed to +// Podman's docker-compat socket reaches the forced Docker-driver gateway and +// exits EADDRNOTAVAIL binding Podman's VM-only bridge IP (10.89.1.1, from +// Podman's DefaultAddressPools base 10.89.0.0/16). Podman's docker-compat +// `/info` mimics Docker (no "podman" marker: ServerVersion "5.6.2", +// OperatingSystem "fedora", plus "docker" strings like DockerRootDir), so +// `docker info` alone misclassifies it as `docker`. `docker version` still +// names the "Podman Engine" component, which reclassifies it as unsupported so +// the preflight gate rejects it before any gateway launch. +// +// Fixtures below are trimmed from live captures on an Apple Silicon macOS host +// running `podman machine` behind `/var/run/docker.sock`. +const PODMAN_COMPAT_DOCKER_INFO = JSON.stringify({ + ServerVersion: "5.6.2", + OperatingSystem: "fedora", + OSType: "linux", + Architecture: "arm64", + Name: "localhost.localdomain", + DefaultRuntime: "crun", + ProductLicense: "Apache-2.0", + DockerRootDir: "/var/lib/containers/storage", + DefaultAddressPools: [{ Base: "10.89.0.0/16", Size: 24 }], + ClientInfo: { Platform: { Name: "Docker Engine - Community" }, Context: "default" }, +}); +const PODMAN_COMPAT_DOCKER_VERSION = JSON.stringify({ + Client: { Platform: { Name: "Docker Engine - Community" }, Version: "29.3.1" }, + Server: { + Platform: { Name: "linux/arm64/fedora-42" }, + Version: "5.6.2", + Components: [ + { Name: "Podman Engine", Version: "5.6.2" }, + { Name: "Conmon", Version: "conmon version 2.1.13" }, + { Name: "OCI Runtime (crun)", Version: "crun version 1.23.1" }, + ], + }, +}); + +describe("assessHost Podman docker-compat detection (#7320)", () => { + it("reclassifies Podman behind the Docker compatibility socket as unsupported", () => { + const result = assessHost({ + platform: "darwin", + env: {}, + dockerInfoOutput: PODMAN_COMPAT_DOCKER_INFO, + dockerVersionOutput: PODMAN_COMPAT_DOCKER_VERSION, + commandExistsImpl: (name: string) => name === "docker", + }); + + expect(result.dockerReachable).toBe(true); + expect(result.runtime).toBe("podman"); + expect(result.isUnsupportedRuntime).toBe(true); + }); + + it("reclassifies Podman from docker info ProductLicense when the version probe is empty", () => { + const result = assessHost({ + platform: "darwin", + env: {}, + dockerInfoOutput: PODMAN_COMPAT_DOCKER_INFO, + dockerVersionOutput: "", + commandExistsImpl: (name: string) => name === "docker", + }); + + expect(result.dockerReachable).toBe(true); + expect(result.runtime).toBe("podman"); + expect(result.isUnsupportedRuntime).toBe(true); + }); + + it("keeps real Docker classified as supported (no Podman false positive)", () => { + const realDockerInfo = JSON.stringify({ + ServerVersion: "29.6.2", + OperatingSystem: "Ubuntu 24.04.3 LTS", + OSType: "linux", + Architecture: "x86_64", + DefaultRuntime: "runc", + DockerRootDir: "/var/lib/docker", + DefaultAddressPools: [{ Base: "192.168.240.0/20", Size: 24 }], + ClientInfo: { Platform: { Name: "Docker Engine - Community" } }, + }); + const realDockerVersion = JSON.stringify({ + Client: { Platform: { Name: "Docker Engine - Community" } }, + Server: { + Platform: { Name: "Docker Engine - Community" }, + Version: "29.6.2", + Components: [ + { Name: "Engine", Version: "29.6.2" }, + { Name: "containerd", Version: "v2.2.6" }, + { Name: "runc", Version: "1.3.6" }, + { Name: "docker-init", Version: "0.19.0" }, + ], + }, + }); + const result = assessHost({ + platform: "darwin", + env: {}, + dockerInfoOutput: realDockerInfo, + dockerVersionOutput: realDockerVersion, + commandExistsImpl: (name: string) => name === "docker", + }); + + expect(result.dockerReachable).toBe(true); + expect(result.runtime).toBe("docker"); + expect(result.isUnsupportedRuntime).toBe(false); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index 6b06adef5bb..77c7b003aa2 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -169,6 +169,7 @@ export interface AssessHostOpts { procVersion?: string; dockerInfoOutput?: string; dockerInfoError?: string; + dockerVersionOutput?: string; readFileImpl?: (filePath: string, encoding: BufferEncoding) => string; readdirImpl?: (dir: string) => string[]; runCaptureImpl?: RunCaptureFn; @@ -215,6 +216,83 @@ function inferContainerRuntime(info = ""): ContainerRuntime { return "unknown"; } +/** + * Detect Podman fronting the Docker CLI compatibility socket from the explicit + * `docker version --format '{{json .}}'` engine banner. + * + * Podman's docker-compat `/info` endpoint mimics Docker so closely that + * `docker info` carries no "podman" marker (observed on Apple Silicon macOS: + * `ServerVersion: "5.6.2"`, `OperatingSystem: "fedora"`, no "podman" + * substring), so `inferContainerRuntime` misclassifies it as plain Docker. + * The docker-compat `/version` payload still names the engine: a + * `Server.Components[].Name` of "Podman Engine" and a `Server.Platform.Name` + * like "linux/arm64/fedora-42". Real Docker reports + * `Server.Platform.Name: "Docker Engine - Community"` and components + * `Engine`/`containerd`/`runc`, so this stays false on Docker Engine, + * Docker Desktop, and Colima (#7320). + */ +function dockerVersionReportsPodman(versionOutput = ""): boolean { + const text = String(versionOutput || "").trim(); + if (!text) return false; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + // Plain-text `docker version` still prints the "Podman Engine" server banner. + return /podman/i.test(text); + } + const server = (parsed as Record | null)?.Server; + if (!server || typeof server !== "object") return false; + const s = server as Record; + const platformName = (s.Platform as Record | undefined)?.Name; + if (typeof platformName === "string" && /podman/i.test(platformName)) return true; + const components = s.Components; + return ( + Array.isArray(components) && + components.some((component) => { + const name = + component && typeof component === "object" + ? (component as Record).Name + : undefined; + return typeof name === "string" && /podman/i.test(name); + }) + ); +} + +/** + * Backstop Podman signal from `docker info --format '{{json .}}'` when the + * `docker version` probe is unavailable: Podman's docker-compat `/info` reports + * `ProductLicense: "Apache-2.0"` (Podman is Apache-2.0 licensed), whereas Docker + * Engine omits `ProductLicense` (Docker CE) or reports a Docker license string. + * Observed Apache-2.0 on the reporter-equivalent Podman machine (#7320). + */ +function dockerInfoReportsPodmanCompat(infoOutput = ""): boolean { + const text = String(infoOutput || "").trim(); + if (!text) return false; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return false; + } + if (!parsed || typeof parsed !== "object") return false; + const license = (parsed as Record).ProductLicense; + return typeof license === "string" && license.trim().toLowerCase() === "apache-2.0"; +} + +/** + * Reclassify a Docker CLI routed to Podman's compatibility socket as `podman` + * so the unsupported-runtime gate fires before the forced macOS/Linux + * Docker-driver gateway binds Podman's VM-only bridge address and exits + * `EADDRNOTAVAIL` (#7320). + */ +function isDockerCompatPodman(dockerInfoOutput = "", dockerVersionOutput = ""): boolean { + return ( + dockerVersionReportsPodman(dockerVersionOutput) || + dockerInfoReportsPodmanCompat(dockerInfoOutput) + ); +} + function parseDockerCgroupVersion(info = ""): "v1" | "v2" | "unknown" { if (/"CgroupVersion"\s*:\s*"2"/.test(info) || /CgroupVersion["=: ]+2/i.test(info)) { return "v2"; @@ -536,6 +614,16 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerRunning = true; } + // Capture the docker-compat engine banner so Podman fronting the Docker CLI + // socket is reclassified below. Only probed when the daemon is reachable so a + // down/absent Docker never pays for the extra call (#7320). + let dockerVersionOutput = opts.dockerVersionOutput; + if (dockerReachable && dockerVersionOutput === undefined) { + dockerVersionOutput = runCaptureImpl(["docker", "version", "--format", "{{json .}}"], { + ignoreError: true, + }); + } + const release = opts.release ?? os.release(); const procVersion = opts.procVersion ?? @@ -547,6 +635,18 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { } })(); let runtime = inferContainerRuntime(dockerInfoOutput); + // Podman fronting the Docker CLI compatibility socket mimics Docker in + // `docker info`, so the grep above misclassifies it as `docker`. Reclassify + // from the explicit docker-compat signals so the unsupported-runtime gate + // fires before the forced Docker-driver gateway binds Podman's VM-only + // bridge IP and exits EADDRNOTAVAIL (#7320). + if ( + dockerReachable && + runtime !== "podman" && + isDockerCompatPodman(dockerInfoOutput, dockerVersionOutput) + ) { + runtime = "podman"; + } if (dockerReachable && runtime === "unknown" && platform === "linux") { runtime = "docker"; }