diff --git a/src/lib/oclif-dispatch.ts b/src/lib/oclif-dispatch.ts index c797f95a401..bdad4074bd7 100644 --- a/src/lib/oclif-dispatch.ts +++ b/src/lib/oclif-dispatch.ts @@ -37,213 +37,275 @@ export type DispatchResult = | UnknownSubcommandDispatch | UnknownActionDispatch; +type FlatSandboxRoute = { + commandId: string; + helpUsage?: string; +}; + +type NestedSandboxRoute = { + parentCommandId: string; + helpUsage?: string; + subcommands: Record; + defaultSubcommand?: string; + unknown?: "oclif-parent" | "channels-unknown" | "usage"; + usageLines?: string[]; +}; + function hasHelpFlag(args: readonly string[]): boolean { return args.includes("--help") || args.includes("-h"); } +function oclif(commandId: string, args: string[]): OclifDispatch { + return { kind: "oclif", commandId, args }; +} + +const GLOBAL_ROUTES: Readonly> = { + onboard: "onboard", + setup: "setup", + "setup-spark": "setup-spark", + deploy: "deploy", + start: "start", + stop: "stop", + status: "status", + debug: "debug", + uninstall: "uninstall", + list: "list", + "backup-all": "backup-all", + "upgrade-sandboxes": "upgrade-sandboxes", + gc: "gc", +}; + +const FLAT_SANDBOX_ROUTES: Readonly> = { + connect: { commandId: "sandbox:connect", helpUsage: "connect" }, + status: { commandId: "sandbox:status", helpUsage: "status" }, + logs: { + commandId: "sandbox:logs", + helpUsage: "logs [--follow] [--tail |-n ] [--since ]", + }, + doctor: { commandId: "sandbox:doctor", helpUsage: "doctor [--json]" }, + "policy-add": { + commandId: "sandbox:policy:add", + helpUsage: + "policy-add [preset] [--yes|-y] [--dry-run] [--from-file ] [--from-dir ]", + }, + "policy-remove": { + commandId: "sandbox:policy:remove", + helpUsage: "policy-remove [preset] [--yes|-y] [--dry-run]", + }, + "policy-list": { commandId: "sandbox:policy:list", helpUsage: "policy-list" }, + destroy: { commandId: "sandbox:destroy", helpUsage: "destroy [--yes|-y|--force]" }, + "gateway-token": { + commandId: "sandbox:gateway-token", + helpUsage: "gateway-token [--quiet|-q]", + }, + rebuild: { + commandId: "sandbox:rebuild", + helpUsage: "rebuild [--yes|-y|--force] [--verbose|-v]", + }, + recover: { commandId: "sandbox:recover", helpUsage: "recover" }, +}; + +const NESTED_SANDBOX_ROUTES: Readonly> = { + skill: { + parentCommandId: "sandbox:skill", + subcommands: { + install: { commandId: "sandbox:skill:install" }, + }, + unknown: "oclif-parent", + }, + share: { + parentCommandId: "sandbox:share", + helpUsage: "share ", + subcommands: { + mount: { + commandId: "sandbox:share:mount", + helpUsage: "share mount [sandbox-path] [local-mount-point]", + }, + unmount: { + commandId: "sandbox:share:unmount", + helpUsage: "share unmount [local-mount-point]", + }, + status: { + commandId: "sandbox:share:status", + helpUsage: "share status [local-mount-point]", + }, + }, + unknown: "oclif-parent", + }, + snapshot: { + parentCommandId: "sandbox:snapshot", + subcommands: { + list: { commandId: "sandbox:snapshot:list", helpUsage: "snapshot list" }, + create: { + commandId: "sandbox:snapshot:create", + helpUsage: "snapshot create [--name ]", + }, + restore: { + commandId: "sandbox:snapshot:restore", + helpUsage: "snapshot restore [selector] [--to ]", + }, + }, + unknown: "oclif-parent", + }, + shields: { + parentCommandId: "sandbox:shields", + subcommands: { + down: { + commandId: "sandbox:shields:down", + helpUsage: "shields down [--timeout 5m] [--reason 'text'] [--policy permissive]", + }, + up: { commandId: "sandbox:shields:up", helpUsage: "shields up" }, + status: { commandId: "sandbox:shields:status", helpUsage: "shields status" }, + }, + unknown: "usage", + usageLines: [ + "shields ", + " down [--timeout 5m] [--reason 'text'] [--policy permissive]", + " up Restore policy from snapshot", + " status Show current shields state", + ], + }, + channels: { + parentCommandId: "sandbox:channels:list", + defaultSubcommand: "list", + subcommands: { + list: { commandId: "sandbox:channels:list", helpUsage: "channels list" }, + add: { commandId: "sandbox:channels:add", helpUsage: "channels add [--dry-run]" }, + remove: { + commandId: "sandbox:channels:remove", + helpUsage: "channels remove [--dry-run]", + }, + stop: { commandId: "sandbox:channels:stop", helpUsage: "channels stop [--dry-run]" }, + start: { + commandId: "sandbox:channels:start", + helpUsage: "channels start [--dry-run]", + }, + }, + unknown: "channels-unknown", + }, + config: { + parentCommandId: "sandbox:config:get", + subcommands: { + get: { + commandId: "sandbox:config:get", + helpUsage: "config get [--key dotpath] [--format json|yaml]", + }, + set: { + commandId: "sandbox:config:set", + helpUsage: "config set --key --value [--restart] [--config-accept-new-path]", + }, + }, + unknown: "usage", + usageLines: [ + "config ", + "get [--key dotpath] [--format json|yaml]", + "set --key --value [--restart] [--config-accept-new-path]", + ], + }, +}; + export function resolveGlobalOclifDispatch(cmd: string, args: string[]): DispatchResult { - switch (cmd) { - case "onboard": - case "setup": - case "setup-spark": - case "deploy": - case "start": - case "stop": - case "status": - case "debug": - case "uninstall": - case "list": - case "backup-all": - case "upgrade-sandboxes": - case "gc": - return { kind: "oclif", commandId: cmd, args }; - case "tunnel": { - const sub = args[0]; - if (sub === "start" || sub === "stop") { - return { kind: "oclif", commandId: `tunnel:${sub}`, args: args.slice(1) }; - } - return { kind: "usageError", lines: ["tunnel "] }; + const globalCommandId = GLOBAL_ROUTES[cmd]; + if (globalCommandId) { + return oclif(globalCommandId, args); + } + + if (cmd === "tunnel") { + const sub = args[0]; + if (sub === "start" || sub === "stop") { + return oclif(`tunnel:${sub}`, args.slice(1)); } - case "credentials": { - const sub = args[0]; - if (!sub || sub === "help" || sub === "--help" || sub === "-h") { - return { kind: "oclif", commandId: "credentials", args: [] }; - } - if (sub === "list") { - return { kind: "oclif", commandId: "credentials:list", args: args.slice(1) }; - } - if (sub === "reset") { - return { kind: "oclif", commandId: "credentials:reset", args: args.slice(1) }; - } - return { kind: "unknownSubcommand", command: "credentials", subcommand: sub }; + return { kind: "usageError", lines: ["tunnel "] }; + } + + if (cmd === "credentials") { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + return oclif("credentials", []); } - case "--version": - case "-v": - return { kind: "oclif", commandId: "root:version", args: [] }; - default: - return { kind: "usageError", lines: [] }; + if (sub === "list" || sub === "reset") { + return oclif(`credentials:${sub}`, args.slice(1)); + } + return { kind: "unknownSubcommand", command: "credentials", subcommand: sub }; + } + + if (cmd === "--version" || cmd === "-v") { + return oclif("root:version", []); } + + return { kind: "usageError", lines: [] }; } -export function resolveSandboxOclifDispatch( +function resolveFlatSandboxRoute( sandboxName: string, - action: string, + route: FlatSandboxRoute, actionArgs: string[], ): DispatchResult { - switch (action) { - case "connect": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "connect" }; - return { kind: "oclif", commandId: "sandbox:connect", args: [sandboxName, ...actionArgs] }; - case "status": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "status" }; - return { kind: "oclif", commandId: "sandbox:status", args: [sandboxName, ...actionArgs] }; - case "logs": - if (hasHelpFlag(actionArgs)) { - return { - kind: "help", - usage: "logs [--follow] [--tail |-n ] [--since ]", - }; - } - return { kind: "oclif", commandId: "sandbox:logs", args: [sandboxName, ...actionArgs] }; - case "doctor": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "doctor [--json]" }; - return { kind: "oclif", commandId: "sandbox:doctor", args: [sandboxName, ...actionArgs] }; - case "policy-add": - if (hasHelpFlag(actionArgs)) { - return { - kind: "help", - usage: "policy-add [preset] [--yes|-y] [--dry-run] [--from-file ] [--from-dir ]", - }; - } - return { kind: "oclif", commandId: "sandbox:policy:add", args: [sandboxName, ...actionArgs] }; - case "policy-remove": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "policy-remove [preset] [--yes|-y] [--dry-run]" }; - return { kind: "oclif", commandId: "sandbox:policy:remove", args: [sandboxName, ...actionArgs] }; - case "policy-list": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "policy-list" }; - return { kind: "oclif", commandId: "sandbox:policy:list", args: [sandboxName, ...actionArgs] }; - case "destroy": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "destroy [--yes|-y|--force]" }; - return { kind: "oclif", commandId: "sandbox:destroy", args: [sandboxName, ...actionArgs] }; - case "gateway-token": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "gateway-token [--quiet|-q]" }; - return { kind: "oclif", commandId: "sandbox:gateway-token", args: [sandboxName, ...actionArgs] }; - case "skill": { - const skillSub = actionArgs[0]; - const skillArgs = actionArgs.slice(1); - if (!skillSub || skillSub === "help" || skillSub === "--help" || skillSub === "-h") { - return { kind: "oclif", commandId: "sandbox:skill", args: [sandboxName, ...actionArgs] }; - } - if (skillSub === "install") { - if (hasHelpFlag(skillArgs)) return { kind: "oclif", commandId: "sandbox:skill", args: [sandboxName, ...actionArgs] }; - return { kind: "oclif", commandId: "sandbox:skill:install", args: [sandboxName, ...skillArgs] }; - } - return { kind: "oclif", commandId: "sandbox:skill", args: [sandboxName, ...actionArgs] }; - } - case "rebuild": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "rebuild [--yes|-y|--force] [--verbose|-v]" }; - return { kind: "oclif", commandId: "sandbox:rebuild", args: [sandboxName, ...actionArgs] }; - case "recover": - if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "recover" }; - return { kind: "oclif", commandId: "sandbox:recover", args: [sandboxName, ...actionArgs] }; - case "share": { - const shareSub = actionArgs[0]; - const shareArgs = actionArgs.slice(1); - if (shareSub === "--help" || shareSub === "-h") { - return { kind: "help", usage: "share " }; - } - if (!shareSub) { - return { kind: "oclif", commandId: "sandbox:share", args: [sandboxName] }; - } - if (shareSub === "mount") { - if (hasHelpFlag(shareArgs)) { - return { kind: "help", usage: "share mount [sandbox-path] [local-mount-point]" }; - } - return { kind: "oclif", commandId: "sandbox:share:mount", args: [sandboxName, ...shareArgs] }; - } - if (shareSub === "unmount") { - if (hasHelpFlag(shareArgs)) { - return { kind: "help", usage: "share unmount [local-mount-point]" }; - } - return { kind: "oclif", commandId: "sandbox:share:unmount", args: [sandboxName, ...shareArgs] }; - } - if (shareSub === "status") { - if (hasHelpFlag(shareArgs)) { - return { kind: "help", usage: "share status [local-mount-point]" }; - } - return { kind: "oclif", commandId: "sandbox:share:status", args: [sandboxName, ...shareArgs] }; - } - return { kind: "oclif", commandId: "sandbox:share", args: [sandboxName, ...actionArgs] }; - } - case "snapshot": { - const snapshotSub = actionArgs[0]; - const snapshotArgs = actionArgs.slice(1); - if (!snapshotSub || snapshotSub === "--help" || snapshotSub === "-h") { - return { kind: "oclif", commandId: "sandbox:snapshot", args: [sandboxName] }; - } - if (snapshotSub === "list") { - if (hasHelpFlag(snapshotArgs)) return { kind: "help", usage: "snapshot list" }; - return { kind: "oclif", commandId: "sandbox:snapshot:list", args: [sandboxName, ...snapshotArgs] }; - } - if (snapshotSub === "create") { - if (hasHelpFlag(snapshotArgs)) return { kind: "help", usage: "snapshot create [--name ]" }; - return { kind: "oclif", commandId: "sandbox:snapshot:create", args: [sandboxName, ...snapshotArgs] }; - } - if (snapshotSub === "restore") { - if (hasHelpFlag(snapshotArgs)) return { kind: "help", usage: "snapshot restore [selector] [--to ]" }; - return { kind: "oclif", commandId: "sandbox:snapshot:restore", args: [sandboxName, ...snapshotArgs] }; - } - return { kind: "oclif", commandId: "sandbox:snapshot", args: [sandboxName, ...actionArgs] }; + if (route.helpUsage && hasHelpFlag(actionArgs)) { + return { kind: "help", usage: route.helpUsage }; + } + return oclif(route.commandId, [sandboxName, ...actionArgs]); +} + +function resolveNestedSandboxRoute( + sandboxName: string, + route: NestedSandboxRoute, + actionArgs: string[], +): DispatchResult { + const subcommand = actionArgs[0] || route.defaultSubcommand; + const subArgs = actionArgs.slice(actionArgs[0] ? 1 : 0); + + if (route.parentCommandId === "sandbox:skill") { + if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + return oclif(route.parentCommandId, [sandboxName, ...actionArgs]); } - case "shields": { - const shieldsSub = actionArgs[0]; - const shieldsArgs = actionArgs.slice(1); - if (shieldsSub === "down") { - if (hasHelpFlag(shieldsArgs)) return { kind: "help", usage: "shields down [--timeout 5m] [--reason 'text'] [--policy permissive]" }; - return { kind: "oclif", commandId: "sandbox:shields:down", args: [sandboxName, ...shieldsArgs] }; - } - if (shieldsSub === "up") { - if (hasHelpFlag(shieldsArgs)) return { kind: "help", usage: "shields up" }; - return { kind: "oclif", commandId: "sandbox:shields:up", args: [sandboxName, ...shieldsArgs] }; - } - if (shieldsSub === "status") { - if (hasHelpFlag(shieldsArgs)) return { kind: "help", usage: "shields status" }; - return { kind: "oclif", commandId: "sandbox:shields:status", args: [sandboxName, ...shieldsArgs] }; - } - return { - kind: "usageError", - lines: ["shields ", " down [--timeout 5m] [--reason 'text'] [--policy permissive]", " up Restore policy from snapshot", " status Show current shields state"], - }; + if (subcommand === "install" && hasHelpFlag(subArgs)) { + return oclif(route.parentCommandId, [sandboxName, ...actionArgs]); } - case "channels": { - const channelsSub = actionArgs[0]; - const channelsArgs = actionArgs.slice(1); - if (channelsSub === "list") { - if (hasHelpFlag(channelsArgs)) return { kind: "help", usage: "channels list" }; - return { kind: "oclif", commandId: "sandbox:channels:list", args: [sandboxName, ...channelsArgs] }; - } - if (!channelsSub) return { kind: "oclif", commandId: "sandbox:channels:list", args: [sandboxName] }; - if (channelsSub === "--help" || channelsSub === "-h") return { kind: "help", usage: "channels list" }; - if (["add", "remove", "stop", "start"].includes(channelsSub)) { - if (hasHelpFlag(channelsArgs)) return { kind: "help", usage: `channels ${channelsSub} [--dry-run]` }; - return { kind: "oclif", commandId: `sandbox:channels:${channelsSub}`, args: [sandboxName, ...channelsArgs] }; - } - return { kind: "unknownSubcommand", command: "channels", subcommand: channelsSub }; + } + + if (!subcommand) { + return oclif(route.parentCommandId, [sandboxName]); + } + + if (subcommand === "--help" || subcommand === "-h") { + if (route.helpUsage) { + return { kind: "help", usage: route.helpUsage }; } - case "config": { - const configSub = actionArgs[0]; - if (configSub === "get") { - if (hasHelpFlag(actionArgs.slice(1))) return { kind: "help", usage: "config get [--key dotpath] [--format json|yaml]" }; - return { kind: "oclif", commandId: "sandbox:config:get", args: [sandboxName, ...actionArgs.slice(1)] }; - } - if (configSub === "set") { - if (hasHelpFlag(actionArgs.slice(1))) return { kind: "help", usage: "config set --key --value [--restart] [--config-accept-new-path]" }; - return { kind: "oclif", commandId: "sandbox:config:set", args: [sandboxName, ...actionArgs.slice(1)] }; - } - if (configSub === "--help" || configSub === "-h") return { kind: "help", usage: "config " }; - return { kind: "usageError", lines: ["config ", "get [--key dotpath] [--format json|yaml]", "set --key --value [--restart] [--config-accept-new-path]"] }; + return oclif(route.parentCommandId, [sandboxName]); + } + + const subRoute = route.subcommands[subcommand]; + if (subRoute) { + if (subRoute.helpUsage && hasHelpFlag(subArgs)) { + return { kind: "help", usage: subRoute.helpUsage }; } - default: - return { kind: "unknownAction", action }; + return oclif(subRoute.commandId, [sandboxName, ...subArgs]); + } + + if (route.unknown === "channels-unknown") { + return { kind: "unknownSubcommand", command: "channels", subcommand }; + } + if (route.unknown === "usage") { + return { kind: "usageError", lines: route.usageLines ?? [] }; } + return oclif(route.parentCommandId, [sandboxName, ...actionArgs]); +} + +export function resolveSandboxOclifDispatch( + sandboxName: string, + action: string, + actionArgs: string[], +): DispatchResult { + const flatRoute = FLAT_SANDBOX_ROUTES[action]; + if (flatRoute) { + return resolveFlatSandboxRoute(sandboxName, flatRoute, actionArgs); + } + + const nestedRoute = NESTED_SANDBOX_ROUTES[action]; + if (nestedRoute) { + return resolveNestedSandboxRoute(sandboxName, nestedRoute, actionArgs); + } + + return { kind: "unknownAction", action }; } diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts index 621340cdac7..5125a5aa93c 100644 --- a/src/lib/preflight.test.ts +++ b/src/lib/preflight.test.ts @@ -14,7 +14,10 @@ import { isDockerUnderProvisioned, MIN_RECOMMENDED_DOCKER_CPUS, MIN_RECOMMENDED_DOCKER_MEM_GIB, - parseDockerInfo, + parseDockerInfoCpus, + parseDockerInfoMemTotalBytes, + parseDockerStorageDriver, + parseDockerUsesContainerdSnapshotter, planHostRemediation, probeContainerDns, } from "../../dist/lib/preflight"; @@ -452,6 +455,50 @@ describe("assessHost", () => { }); }); +describe("parseDockerStorageDriver", () => { + it("extracts the Driver field from JSON docker info output", () => { + expect(parseDockerStorageDriver('{"Driver":"overlayfs","Other":"x"}')).toBe("overlayfs"); + expect(parseDockerStorageDriver('{"Driver":"overlay2"}')).toBe("overlay2"); + }); + + it("returns undefined for empty or non-matching input", () => { + expect(parseDockerStorageDriver("")).toBeUndefined(); + expect(parseDockerStorageDriver("not json at all")).toBeUndefined(); + }); + + it("falls back to the plain-text 'Storage Driver: ' form", () => { + // Future callers passing raw `docker info` output (no `--format` flag) + // should still get the conflict detected. + const fixture = [ + "Server:", + " Containers: 7", + " Storage Driver: overlayfs", + " driver-type: io.containerd.snapshotter.v1", + "", + ].join("\n"); + expect(parseDockerStorageDriver(fixture)).toBe("overlayfs"); + }); +}); + +describe("parseDockerUsesContainerdSnapshotter", () => { + it("returns true when DriverStatus mentions io.containerd.snapshotter.v1", () => { + const fixture = JSON.stringify({ + Driver: "overlayfs", + DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], + }); + expect(parseDockerUsesContainerdSnapshotter(fixture)).toBe(true); + }); + + it("returns false for legacy overlay2 driver output without the snapshotter marker", () => { + const fixture = JSON.stringify({ Driver: "overlay2" }); + expect(parseDockerUsesContainerdSnapshotter(fixture)).toBe(false); + }); + + it("returns false for empty input", () => { + expect(parseDockerUsesContainerdSnapshotter("")).toBe(false); + }); +}); + describe("planHostRemediation", () => { it("recommends starting docker when installed but unreachable and service inactive", () => { const actions = planHostRemediation({ @@ -910,86 +957,43 @@ describe("getDockerBridgeGatewayIp", () => { }); }); -describe("parseDockerInfo", () => { - it("extracts every field from a JSON docker info payload in one pass", () => { - const info = JSON.stringify({ - ServerVersion: "27.4.0", - OperatingSystem: "Colima", - CgroupVersion: "2", - Driver: "overlayfs", - DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]], - NCPU: 6, - MemTotal: 12 * 1024 ** 3, - }); - - const result = parseDockerInfo(info); - expect(result.serverVersion).toBe("27.4.0"); - expect(result.operatingSystem).toBe("Colima"); - expect(result.cgroupVersion).toBe("v2"); - expect(result.storageDriver).toBe("overlayfs"); - expect(result.usesContainerdSnapshotter).toBe(true); - expect(result.cpus).toBe(6); - expect(result.memTotalBytes).toBe(12 * 1024 ** 3); +describe("parseDockerInfoCpus", () => { + it("extracts NCPU from JSON docker info output", () => { + expect(parseDockerInfoCpus('{"NCPU":6}')).toBe(6); + expect(parseDockerInfoCpus('{"ServerVersion":"x","NCPU":12,"Other":"y"}')).toBe(12); }); - it("returns a struct with all undefined fields for empty input", () => { - const result = parseDockerInfo(""); - expect(result.serverVersion).toBeUndefined(); - expect(result.operatingSystem).toBeUndefined(); - expect(result.cgroupVersion).toBeUndefined(); - expect(result.storageDriver).toBeUndefined(); - expect(result.usesContainerdSnapshotter).toBe(false); - expect(result.cpus).toBeUndefined(); - expect(result.memTotalBytes).toBeUndefined(); + it("falls back to plain-text 'CPUs: ' form", () => { + const fixture = ["Server:", " Containers: 0", " CPUs: 8", ""].join("\n"); + expect(parseDockerInfoCpus(fixture)).toBe(8); }); - it("falls back to plain-text fields when JSON.parse fails", () => { - const fixture = [ - "Server:", - " Server Version: 27.4.0", - " Storage Driver: overlay2", - " Cgroup Version: 2", - " Operating System: Ubuntu 24.04", - " CPUs: 8", - " Total Memory: 15.5GiB", - "", - ].join("\n"); - - const result = parseDockerInfo(fixture); - expect(result.serverVersion).toBe("27.4.0"); - expect(result.operatingSystem).toBe("Ubuntu 24.04"); - expect(result.cgroupVersion).toBe("v2"); - expect(result.storageDriver).toBe("overlay2"); - expect(result.cpus).toBe(8); - expect(result.memTotalBytes).toBeGreaterThan(15 * 1024 ** 3); + it("returns undefined for empty or non-matching input", () => { + expect(parseDockerInfoCpus("")).toBeUndefined(); + expect(parseDockerInfoCpus("nothing useful here")).toBeUndefined(); }); - it("ignores nested NCPU keys that would falsely match a regex", () => { - const info = JSON.stringify({ - ServerVersion: "27.4.0", - // top-level NCPU absent; nested key would falsely match a `"NCPU":N` regex - Plugins: { Storage: { NCPU: 99 } }, - }); - - const result = parseDockerInfo(info); - expect(result.cpus).toBeUndefined(); + it("returns undefined for zero or negative values", () => { + expect(parseDockerInfoCpus('{"NCPU":0}')).toBeUndefined(); }); +}); - it("rejects zero or negative scalar values", () => { - const result = parseDockerInfo(JSON.stringify({ NCPU: 0, MemTotal: 0 })); - expect(result.cpus).toBeUndefined(); - expect(result.memTotalBytes).toBeUndefined(); +describe("parseDockerInfoMemTotalBytes", () => { + it("extracts MemTotal from JSON docker info output", () => { + expect(parseDockerInfoMemTotalBytes('{"MemTotal":2054303744}')).toBe(2054303744); }); -}); -describe("parseDockerInfo — plain-text memory parsing", () => { it("parses plain-text 'Total Memory: GiB' form", () => { const fixture = ["Server:", " Total Memory: 7.756GiB", ""].join("\n"); - const result = parseDockerInfo(fixture).memTotalBytes; + const result = parseDockerInfoMemTotalBytes(fixture); expect(result).toBeDefined(); expect(result).toBeGreaterThan(7 * 1024 ** 3); expect(result).toBeLessThan(8 * 1024 ** 3); }); + + it("returns undefined for empty input", () => { + expect(parseDockerInfoMemTotalBytes("")).toBeUndefined(); + }); }); describe("isDockerUnderProvisioned", () => { diff --git a/src/lib/preflight.ts b/src/lib/preflight.ts index 7545b88e68a..8832d10ebe5 100644 --- a/src/lib/preflight.ts +++ b/src/lib/preflight.ts @@ -176,122 +176,86 @@ function inferContainerRuntime(info = ""): ContainerRuntime { return "unknown"; } -const TEXT_UNIT_MULTIPLIERS: Record = { - gib: 1 << 30, - gb: 1_000_000_000, - mib: 1 << 20, - mb: 1_000_000, - kib: 1 << 10, - kb: 1_000, -}; - -export interface DockerInfo { - parsedAs: "json" | "text"; - serverVersion?: string; - operatingSystem?: string; - cgroupVersion?: "v1" | "v2"; - storageDriver?: string; - usesContainerdSnapshotter: boolean; - cpus?: number; - memTotalBytes?: number; -} - -function safeParseDockerInfoJson(info: string): Record | null { - if (!info.trim().startsWith("{")) return null; - try { - const parsed = JSON.parse(info); - return parsed && typeof parsed === "object" ? (parsed as Record) : null; - } catch { - return null; +function parseDockerCgroupVersion(info = ""): "v1" | "v2" | "unknown" { + if (/"CgroupVersion"\s*:\s*"2"/.test(info) || /CgroupVersion["=: ]+2/i.test(info)) { + return "v2"; + } + if (/"CgroupVersion"\s*:\s*"1"/.test(info) || /CgroupVersion["=: ]+1/i.test(info)) { + return "v1"; } + return "unknown"; } -function pickString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; +function parseDockerInfoSummary(info = ""): string | undefined { + const versionMatch = info.match(/"ServerVersion"\s*:\s*"([^"]+)"/); + const osMatch = info.match(/"OperatingSystem"\s*:\s*"([^"]+)"/); + const parts = [versionMatch?.[1], osMatch?.[1]].filter(Boolean); + return parts.length > 0 ? parts.join(" · ") : undefined; } -function pickPositiveInt(value: unknown): number | undefined { - if (typeof value !== "number") return undefined; - return Number.isFinite(value) && value > 0 ? Math.trunc(value) : undefined; +export function parseDockerStorageDriver(info = ""): string | undefined { + // JSON form (`docker info --format '{{json .}}'`) is the canonical caller + // path inside this file, but accept the plain-text `Storage Driver: ` + // form too so future callers that pass raw `docker info` don't silently + // miss the conflict and bypass the auto-fix. + const jsonMatch = info.match(/"Driver"\s*:\s*"([^"]+)"/); + if (jsonMatch) return jsonMatch[1]; + const textMatch = info.match(/^\s*Storage Driver:\s*(\S+)\s*$/m); + return textMatch?.[1]; } -function matchTextField(info: string, regex: RegExp): string | undefined { - const match = info.match(regex); - return match?.[1]; +export function parseDockerUsesContainerdSnapshotter(info = ""): boolean { + // Docker 26+ defaults fresh installs to the containerd image store, surfaced + // via `docker info` DriverStatus entries that name the containerd snapshotter + // v1 plugin. Match either JSON or text form so we handle `--format '{{json + // .}}'` output and plain `docker info` alike. + return /io\.containerd\.snapshotter\.v1/.test(info); } -function pickJsonCgroupVersion(value: unknown): "v1" | "v2" | undefined { - if (value === "1" || value === 1) return "v1"; - if (value === "2" || value === 2) return "v2"; +export function parseDockerInfoCpus(info = ""): number | undefined { + const jsonMatch = info.match(/"NCPU"\s*:\s*(\d+)/); + if (jsonMatch) { + const n = parseInt(jsonMatch[1], 10); + return Number.isFinite(n) && n > 0 ? n : undefined; + } + const textMatch = info.match(/^\s*CPUs:\s*(\d+)\s*$/m); + if (textMatch) { + const n = parseInt(textMatch[1], 10); + return Number.isFinite(n) && n > 0 ? n : undefined; + } return undefined; } -function jsonHasContainerdSnapshotter(driverStatus: unknown): boolean { - if (!Array.isArray(driverStatus)) return false; - return driverStatus.some( - (entry) => - Array.isArray(entry) && - entry.some((cell) => /io\.containerd\.snapshotter\.v1/.test(String(cell))), - ); -} - -function parseDockerInfoFromJson(json: Record): DockerInfo { - return { - parsedAs: "json", - serverVersion: pickString(json.ServerVersion), - operatingSystem: pickString(json.OperatingSystem), - cgroupVersion: pickJsonCgroupVersion(json.CgroupVersion), - storageDriver: pickString(json.Driver), - usesContainerdSnapshotter: jsonHasContainerdSnapshotter(json.DriverStatus), - cpus: pickPositiveInt(json.NCPU), - memTotalBytes: pickPositiveInt(json.MemTotal), - }; -} - -function parseTextCgroupVersion(info: string): "v1" | "v2" | undefined { - if (/Cgroup\s*Version["=: ]+2/i.test(info)) return "v2"; - if (/Cgroup\s*Version["=: ]+1/i.test(info)) return "v1"; +export function parseDockerInfoMemTotalBytes(info = ""): number | undefined { + const jsonMatch = info.match(/"MemTotal"\s*:\s*(\d+)/); + if (jsonMatch) { + const n = parseInt(jsonMatch[1], 10); + return Number.isFinite(n) && n > 0 ? n : undefined; + } + const textMatch = info.match(/^\s*Total Memory:\s*([\d.]+)\s*([GMK]i?B)\s*$/im); + if (textMatch) { + const value = parseFloat(textMatch[1]); + if (!Number.isFinite(value) || value <= 0) return undefined; + const unit = textMatch[2].toLowerCase(); + const multiplier = + unit === "gib" + ? 1024 ** 3 + : unit === "gb" + ? 1000 ** 3 + : unit === "mib" + ? 1024 ** 2 + : unit === "mb" + ? 1000 ** 2 + : unit === "kib" + ? 1024 + : unit === "kb" + ? 1000 + : 1; + return Math.round(value * multiplier); + } return undefined; } -function parseTextCpus(info: string): number | undefined { - const match = matchTextField(info, /^\s*CPUs:\s*(\d+)\s*$/m); - return match ? pickPositiveInt(parseInt(match, 10)) : undefined; -} - -function parseTextMemTotal(info: string): number | undefined { - const match = info.match(/^\s*Total Memory:\s*([\d.]+)\s*([GMK]i?B)\s*$/im); - if (!match) return undefined; - const value = parseFloat(match[1]); - if (!Number.isFinite(value) || value <= 0) return undefined; - const multiplier = TEXT_UNIT_MULTIPLIERS[match[2].toLowerCase()] ?? 1; - return Math.round(value * multiplier); -} - -function parseDockerInfoFromText(info: string): DockerInfo { - return { - parsedAs: "text", - serverVersion: matchTextField(info, /^\s*Server Version:\s*(.+)$/m), - operatingSystem: matchTextField(info, /^\s*Operating System:\s*(.+)$/m), - cgroupVersion: parseTextCgroupVersion(info), - storageDriver: matchTextField(info, /^\s*Storage Driver:\s*(\S+)\s*$/m), - usesContainerdSnapshotter: /io\.containerd\.snapshotter\.v1/.test(info), - cpus: parseTextCpus(info), - memTotalBytes: parseTextMemTotal(info), - }; -} - -/** - * Parse `docker info` output into a typed view. JSON form - * (`docker info --format '{{json .}}'`) is parsed structurally; otherwise the - * raw plain-text form is matched line-by-line. Fields that do not match are - * left undefined. - */ -export function parseDockerInfo(info = ""): DockerInfo { - const json = safeParseDockerInfoJson(info); - return json ? parseDockerInfoFromJson(json) : parseDockerInfoFromText(info); -} - export const MIN_RECOMMENDED_DOCKER_CPUS = 4; export const MIN_RECOMMENDED_DOCKER_MEM_GIB = 8; @@ -406,12 +370,19 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { if (dockerReachable && runtime === "unknown" && platform === "linux") { runtime = "docker"; } - const docker = dockerReachable ? parseDockerInfo(dockerInfoOutput) : null; - const dockerCgroupVersion = docker?.cgroupVersion ?? "unknown"; - const dockerStorageDriver = docker?.storageDriver; - const dockerUsesContainerdSnapshotter = docker?.usesContainerdSnapshotter ?? false; - const dockerCpus = docker?.cpus; - const dockerMemTotalBytes = docker?.memTotalBytes; + const dockerCgroupVersion = dockerReachable + ? parseDockerCgroupVersion(dockerInfoOutput) + : "unknown"; + const dockerStorageDriver = dockerReachable + ? parseDockerStorageDriver(dockerInfoOutput) + : undefined; + const dockerUsesContainerdSnapshotter = dockerReachable + ? parseDockerUsesContainerdSnapshotter(dockerInfoOutput) + : false; + const dockerCpus = dockerReachable ? parseDockerInfoCpus(dockerInfoOutput) : undefined; + const dockerMemTotalBytes = dockerReachable + ? parseDockerInfoMemTotalBytes(dockerInfoOutput) + : undefined; const isContainerRuntimeUnderProvisioned = isDockerUnderProvisioned( dockerCpus, dockerMemTotalBytes, @@ -463,9 +434,7 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerReachable, nodeInstalled, openshellInstalled, - dockerInfoSummary: docker - ? [docker.serverVersion, docker.operatingSystem].filter(Boolean).join(" · ") || undefined - : undefined, + dockerInfoSummary: parseDockerInfoSummary(dockerInfoOutput), dockerCgroupVersion, dockerDefaultCgroupnsMode, dockerStorageDriver, @@ -491,11 +460,6 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { if (assessment.dockerInfoSummary) { assessment.notes.push(`Docker: ${assessment.dockerInfoSummary}`); } - if (docker?.parsedAs === "text" && (dockerInfoOutput ?? "").trim().length > 0) { - assessment.notes.push( - "docker info JSON unavailable; fell back to plain-text scraping. Reported runtime stats may be inaccurate.", - ); - } return assessment; }