Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 101 additions & 2 deletions src/lib/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,10 @@ describe("assessHost", () => {
const result = assessHost({
platform: "darwin",
env: {},
dockerInfoOutput: "Podman Engine",
dockerInfoOutput: JSON.stringify({
ServerVersion: "5.3.1",
OperatingSystem: "podman-engine",
}),
commandExistsImpl: (name: string) => name === "docker",
});

Expand All @@ -282,7 +285,10 @@ describe("assessHost", () => {
const result = assessHost({
platform: "linux",
env: {},
dockerInfoOutput: "Podman Engine",
dockerInfoOutput: JSON.stringify({
ServerVersion: "5.3.1",
OperatingSystem: "podman-engine",
}),
commandExistsImpl: (name: string) => name === "docker",
});

Expand All @@ -291,6 +297,99 @@ describe("assessHost", () => {
expect(result.dockerReachable).toBe(true);
});

// Regression: NemoClaw #2348. `docker info --format '{{json .}}'` emits a
// zero-value client-side struct (exit 0, non-empty JSON, ServerVersion: "")
// when the daemon is unreachable — e.g. after `colima stop`. Preflight used
// to treat any non-empty output as "daemon reachable".
it("reports docker unreachable when daemon is stopped (zero-value JSON with ServerErrors)", () => {
const colimaStoppedOutput = JSON.stringify({
ID: "",
Containers: 0,
ServerVersion: "",
OperatingSystem: "",
ServerErrors: [
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?",
],
});
const result = assessHost({
platform: "darwin",
env: {},
dockerInfoOutput: colimaStoppedOutput,
commandExistsImpl: (name: string) => name === "docker",
});

expect(result.dockerInstalled).toBe(true);
expect(result.dockerReachable).toBe(false);
expect(result.dockerRunning).toBe(false);
});

it("reports docker unreachable for zero-value JSON even without ServerErrors", () => {
// Defensive: older/alternate docker CLI builds that emit the zero-value
// struct without populating ServerErrors should still be treated as
// unreachable (empty ServerVersion is the canonical tell).
const zeroValueOutput = JSON.stringify({
ID: "",
Containers: 0,
ServerVersion: "",
OperatingSystem: "",
});
const result = assessHost({
platform: "darwin",
env: {},
dockerInfoOutput: zeroValueOutput,
commandExistsImpl: (name: string) => name === "docker",
});

expect(result.dockerReachable).toBe(false);
expect(result.dockerRunning).toBe(false);
});

it("reports docker unreachable when output is not valid JSON object", () => {
const result = assessHost({
platform: "darwin",
env: {},
dockerInfoOutput: "null",
commandExistsImpl: (name: string) => name === "docker",
});

expect(result.dockerReachable).toBe(false);
expect(result.dockerRunning).toBe(false);
});

it("falls back to non-empty heuristic for non-JSON output (older CLI / test stubs)", () => {
// Preserves backward compatibility for callers injecting plain-text
// dockerInfoOutput — real CLI paths always emit JSON under `--format`.
const result = assessHost({
platform: "darwin",
env: {},
dockerInfoOutput: "Server Version: 27.4.0\nOS: Docker Desktop",
commandExistsImpl: (name: string) => name === "docker",
});

expect(result.dockerReachable).toBe(true);
expect(result.dockerRunning).toBe(true);
});

it("treats podman-docker alias (native podman JSON) as reachable", () => {
// When `docker` is an alias for `podman`, `docker info --format '{{json .}}'`
// actually runs `podman info`, whose native schema nests a `version.Version`
// instead of a top-level ServerVersion.
const podmanNative = JSON.stringify({
host: { arch: "arm64", os: "linux" },
version: { APIVersion: "5.3.1", Version: "5.3.1" },
store: { imageStore: {} },
});
const result = assessHost({
platform: "darwin",
env: {},
dockerInfoOutput: podmanNative,
commandExistsImpl: (name: string) => name === "docker",
});

expect(result.dockerReachable).toBe(true);
expect(result.dockerRunning).toBe(true);
});

it("detects linux docker on cgroup v2 without requiring host cgroupns fix", () => {
const result = assessHost({
platform: "linux",
Expand Down
53 changes: 52 additions & 1 deletion src/lib/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,57 @@ function parseDockerInfoSummary(info = ""): string | undefined {
return parts.length > 0 ? parts.join(" · ") : undefined;
}

/**
* Decide whether `docker info --format '{{json .}}'` output reflects an
* actually-responding daemon, not just the Docker CLI emitting a zero-value
* client-side struct.
*
* NemoClaw #2348: when the daemon is unreachable (e.g. `colima stop`),
* docker CLI with `--format '{{json .}}'` still exits 0 and prints an empty
* struct with `ServerVersion: ""` and a populated `ServerErrors` array. A
* naive "output is non-empty" check misreads that as "daemon reachable".
*/
function isDockerDaemonReachable(rawOutput = ""): boolean {
const text = String(rawOutput).trim();
if (!text) return false;

let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
// Output fell outside the `--format '{{json .}}'` contract (older CLI,
// stubbed test input, etc.). The CLI at least produced something, so
// defer to the prior non-empty heuristic for backward compatibility.
return true;
}

if (!parsed || typeof parsed !== "object") return false;
const obj = parsed as Record<string, unknown>;

// Explicit negative signal: docker CLI fills ServerErrors when it could
// not reach the daemon, even when exit code is 0 under `--format`.
if (Array.isArray(obj.ServerErrors) && obj.ServerErrors.length > 0) {
return false;
}

// Canonical positive signal: docker CLI and podman's docker-compat
// layer both populate ServerVersion from the running daemon.
if (typeof obj.ServerVersion === "string" && obj.ServerVersion.length > 0) {
return true;
}

// podman-docker alias path: `docker info --format '{{json .}}'` actually
// runs `podman info`, whose native schema has no top-level ServerVersion
// but nests a `version.Version` instead.
const version = obj.version;
if (version && typeof version === "object") {
const v = (version as Record<string, unknown>).Version;
if (typeof v === "string" && v.length > 0) return true;
}

return false;
}

function readDockerDefaultCgroupnsMode(
readFileImpl: (filePath: string, encoding: BufferEncoding) => string,
): "host" | "private" | "unknown" {
Expand Down Expand Up @@ -271,7 +322,7 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment {
ignoreError: true,
});
}
if (dockerInstalled && String(dockerInfoOutput || "").trim()) {
if (dockerInstalled && isDockerDaemonReachable(dockerInfoOutput)) {
dockerReachable = true;
dockerRunning = true;
}
Expand Down
Loading