diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index ab902284213..f0d5bcc10bd 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -18,7 +18,7 @@ "src/lib/cli/terminal-style.ts": 42, "src/lib/core/json-types.ts": 34, "src/lib/core/ports.ts": 89, - "src/lib/core/shell-quote.ts": 27, + "src/lib/core/shell-quote.ts": 28, "src/lib/core/url-utils.ts": 25, "src/lib/core/wait.ts": 30, "src/lib/credentials/store.ts": 45, diff --git a/src/lib/advisories/checks/host/docker.test.ts b/src/lib/advisories/checks/host/docker.test.ts index 31a5a9f4cf5..e436147491b 100644 --- a/src/lib/advisories/checks/host/docker.test.ts +++ b/src/lib/advisories/checks/host/docker.test.ts @@ -49,6 +49,16 @@ describe("Docker host advisories (#3213)", () => { expect(result.advisories.map((advisory) => advisory.id)).toEqual([expectedId]); }); + it("reports a docker info timeout instead of a docker-group remediation (#10645)", () => { + const result = runAdvisories( + DOCKER_HOST_ADVISORY_CHECKS, + host({ dockerServiceActive: true, dockerInfoTimedOut: true }), + { phase: "preflight.host" }, + ); + + expect(result.advisories.map((advisory) => advisory.id)).toEqual(["docker_info_timeout"]); + }); + it("reports an invalid DOCKER_HOST instead of a docker-group remediation (#7731)", () => { const result = runAdvisories( DOCKER_HOST_ADVISORY_CHECKS, @@ -163,6 +173,7 @@ describe("Docker host advisories (#3213)", () => { "enable_docker_desktop_wsl_integration", "install_docker", "invalid_docker_host", + "docker_info_timeout", "docker_group_permission", "start_docker", "docker_desktop_credential_store_headless", diff --git a/src/lib/advisories/checks/host/docker.ts b/src/lib/advisories/checks/host/docker.ts index 024e3e5926e..7bef24b6a99 100644 --- a/src/lib/advisories/checks/host/docker.ts +++ b/src/lib/advisories/checks/host/docker.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { shellQuote } from "../../../core/shell-quote"; import { DOCKER_DESKTOP_CREDENTIAL_STORE_NAMES } from "../../../domain/docker-host"; import type { HostAssessment, PackageManager } from "../../../onboard/preflight"; import type { AdvisoryCheck } from "../../types"; @@ -95,6 +96,36 @@ export const invalidDockerHost: AdvisoryCheck = { }, }; +export const dockerInfoTimeout: AdvisoryCheck = { + id: "docker_info_timeout", + phase: "preflight.host", + severity: "blocking", + resumeSafe: false, + check(host) { + if ( + host.dockerHostInvalid || + !host.dockerInstalled || + host.dockerReachable || + host.dockerInfoTimedOut !== true + ) { + return null; + } + return hostAdvisory(dockerInfoTimeout, { + title: "Docker did not answer the preflight probe in time", + kind: "manual", + reason: + "Docker is installed, but `docker info` did not finish within the bounded preflight timeout. " + + "This usually means the configured Docker authority accepted the connection but never returned a response. " + + "Retry after confirming Docker is healthy, or correct DOCKER_HOST if it points at a stalled socket or proxy.", + commands: [ + `printf 'DOCKER_HOST=%s\\n' ${shellQuote(host.dockerHostAuthority ?? "")}`, + "docker info", + "nemoclaw onboard", + ], + }); + }, +}; + export const addUserToDockerGroup: AdvisoryCheck = { id: "docker_group_permission", phase: "preflight.host", @@ -105,6 +136,7 @@ export const addUserToDockerGroup: AdvisoryCheck = { host.dockerHostInvalid || !host.dockerInstalled || host.dockerReachable || + host.dockerInfoTimedOut === true || host.isWsl || host.platform !== "linux" || host.dockerServiceActive !== true @@ -141,6 +173,7 @@ export const startDocker: AdvisoryCheck = { host.dockerHostInvalid || !host.dockerInstalled || host.dockerReachable || + host.dockerInfoTimedOut === true || host.isWsl || likelyGroupIssue ) @@ -199,6 +232,7 @@ export const DOCKER_HOST_ADVISORY_CHECKS = Object.freeze([ enableDockerDesktopWslIntegration, installDocker, invalidDockerHost, + dockerInfoTimeout, addUserToDockerGroup, startDocker, dockerDesktopCredentialStoreHeadless, diff --git a/src/lib/advisories/checks/host/index.test.ts b/src/lib/advisories/checks/host/index.test.ts index 5d5263dc21a..d0deea582c5 100644 --- a/src/lib/advisories/checks/host/index.test.ts +++ b/src/lib/advisories/checks/host/index.test.ts @@ -45,6 +45,7 @@ describe("host advisory registry (#3213)", () => { "enable_docker_desktop_wsl_integration", "install_docker", "invalid_docker_host", + "docker_info_timeout", "docker_group_permission", "start_docker", "docker_desktop_credential_store_headless", diff --git a/src/lib/onboard/preflight-docker-info-timeout.test.ts b/src/lib/onboard/preflight-docker-info-timeout.test.ts new file mode 100644 index 00000000000..57afa543ac1 --- /dev/null +++ b/src/lib/onboard/preflight-docker-info-timeout.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { assessHost, planHostAdvisories } from "./preflight"; +import { printRemediationActions } from "./remediation"; + +describe("assessHost docker info timeout (#10645)", () => { + it("flags a bounded docker info timeout instead of a docker-group remediation", () => { + const probeCalls: Array<{ + command: readonly string[]; + options?: { timeout?: number }; + }> = []; + const assessment = assessHost({ + platform: "linux", + env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, + commandExistsImpl: (name: string) => name === "docker" || name === "systemctl", + runCaptureExImpl: (command, options) => { + probeCalls.push({ command, options }); + return { stdout: "", exitCode: null, timedOut: true }; + }, + runCaptureImpl: (command: readonly string[]) => + command.includes("is-active") ? "active" : "", + }); + + expect(probeCalls).toEqual([ + { + command: ["docker", "info", "--format", "{{json .}}"], + options: { timeout: 3_000 }, + }, + ]); + expect(assessment.dockerInfoTimedOut).toBe(true); + expect(assessment.dockerReachable).toBe(false); + + const ids = planHostAdvisories(assessment).map((action) => action.id); + expect(ids).toContain("docker_info_timeout"); + expect(ids).not.toContain("docker_group_permission"); + expect(ids).not.toContain("start_docker"); + }); + + it("names the configured Docker authority in the timeout remediation", () => { + const assessment = assessHost({ + platform: "linux", + env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, + dockerInfoTimedOut: true, + dockerInfoOutput: "", + commandExistsImpl: (name: string) => name === "docker" || name === "systemctl", + runCaptureImpl: (command: readonly string[]) => + command.includes("is-active") ? "active" : "", + }); + + const lines: string[] = []; + const err = console.error; + console.error = (line: string) => { + lines.push(line); + }; + try { + printRemediationActions(planHostAdvisories(assessment)); + } finally { + console.error = err; + } + + expect(lines.join("\n")).toContain("docker_info_timeout"); + expect(lines.join("\n")).toContain("printf 'DOCKER_HOST=%s\\n' 'unix:///var/run/docker.sock'"); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index e8e401557ad..ba4b55c2dbd 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -28,7 +28,7 @@ import { isDockerDaemonReachable, isSupportedGatewayDockerHost, } from "../domain/docker-host"; -import { classifyDockerVersionIdentity } from "../platform"; +import { classifyDockerVersionIdentity, DOCKER_PROBE_TIMEOUT_MS } from "../platform"; import { resolveOpenshell } from "../readiness/openshell-resolver"; import { MIN_RECOMMENDED_DOCKER_CPUS, @@ -48,12 +48,16 @@ export { getNvidiaCdiSpecPath, parseDockerCdiSpecDirs } from "./docker-cdi"; export { isWslDockerDesktopRuntime } from "./wsl-docker-desktop-gpu"; // runner.ts still uses CommonJS-style exports — use require here. -const { run, runCapture } = require("../runner"); +const { run, runCapture, runCaptureEx } = require("../runner"); const DOCKER_HOST_ADVISORY_IDS = new Set(DOCKER_HOST_ADVISORY_CHECKS.map(({ id }) => id)); type RunCaptureFn = typeof import("../runner").runCapture; type RunFn = typeof import("../runner").run; type RunCaptureOpts = Parameters[1]; +type RunCaptureExFn = ( + command: readonly string[], + options?: { timeout?: number }, +) => import("../runner").CaptureResult; type NullableRunCaptureFn = ( command: Parameters[0], options?: RunCaptureOpts, @@ -129,9 +133,13 @@ export interface HostAssessment { dockerServiceActive?: boolean | null; dockerServiceEnabled?: boolean | null; dockerHostInvalid?: boolean; + /** Docker authority assessed by the bounded preflight probe. */ + dockerHostAuthority?: string; dockerInstalled: boolean; dockerRunning: boolean; dockerReachable: boolean; + /** True when the bounded preflight `docker info` probe timed out (#10645). */ + dockerInfoTimedOut?: boolean; nodeInstalled: boolean; openshellInstalled: boolean; dockerInfoSummary?: string; @@ -182,11 +190,13 @@ export interface AssessHostOpts { release?: string; procVersion?: string; dockerInfoOutput?: string; + dockerInfoTimedOut?: boolean; dockerInfoError?: string; dockerVersionOutput?: string; readFileImpl?: (filePath: string, encoding: BufferEncoding) => string; readdirImpl?: (dir: string) => string[]; runCaptureImpl?: RunCaptureFn; + runCaptureExImpl?: RunCaptureExFn; resolveOpenshellImpl?: () => string | null; commandExistsImpl?: (commandName: string) => boolean; gpuProbeImpl?: () => boolean; @@ -566,12 +576,22 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { const dockerHostInvalid = !isSupportedGatewayDockerHost(env.DOCKER_HOST); let dockerInfoOutput = opts.dockerInfoOutput; + let dockerInfoTimedOut = opts.dockerInfoTimedOut === true; let dockerReachable = false; let dockerRunning = false; if (dockerInstalled && !dockerHostInvalid && dockerInfoOutput === undefined) { - dockerInfoOutput = runCaptureImpl(["docker", "info", "--format", "{{json .}}"], { - ignoreError: true, - }); + const dockerInfoCapture = (opts.runCaptureExImpl ?? runCaptureEx)( + ["docker", "info", "--format", "{{json .}}"], + { + timeout: DOCKER_PROBE_TIMEOUT_MS, + }, + ); + if (dockerInfoCapture.timedOut) { + dockerInfoTimedOut = true; + dockerInfoOutput = ""; + } else { + dockerInfoOutput = dockerInfoCapture.stdout; + } } if (dockerInstalled && isDockerDaemonReachable(dockerInfoOutput)) { dockerReachable = true; @@ -701,9 +721,11 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerServiceActive, dockerServiceEnabled, dockerHostInvalid, + dockerHostAuthority: env.DOCKER_HOST ?? "", dockerInstalled, dockerRunning, dockerReachable, + dockerInfoTimedOut, nodeInstalled, openshellInstalled, dockerInfoSummary: parseDockerInfoSummary(dockerInfoOutput), diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 54d9e7b0d70..f77a59c9afa 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -85,7 +85,7 @@ export function windowsProcessListensOnlyOnLoopback( ); } -const DOCKER_PROBE_TIMEOUT_MS = 3_000; +export const DOCKER_PROBE_TIMEOUT_MS = 3_000; const DOCKER_PROBE_MAX_BUFFER_BYTES = 1024 * 1024; const DOCKER_PROBE_ENV_NAMES = ["HOME", "USER", "LOGNAME", "PATH"] as const;