diff --git a/src/lib/onboard/docker-gpu-patch.test.ts b/src/lib/onboard/docker-gpu-patch.test.ts index 1807488fb53..77c91b9284d 100644 --- a/src/lib/onboard/docker-gpu-patch.test.ts +++ b/src/lib/onboard/docker-gpu-patch.test.ts @@ -13,6 +13,7 @@ import { buildDockerGpuMode, buildDockerGpuModeCandidates, collectDockerGpuPatchDiagnostics, + detectSandboxFallbackDns, dockerReportsNvidiaCdiDevices, formatDockerInspectNetworkSummary, getDockerGpuPatchNetworkMode, @@ -447,3 +448,159 @@ describe("docker-gpu-patch", () => { expect(runOpenshell).not.toHaveBeenCalled(); }); }); + +describe("docker-gpu-patch sandbox DNS fallback (#3579)", () => { + it("returns the systemd-resolved upstream when /etc/resolv.conf is loopback-only", () => { + const readFile = (p: string): string | null => { + if (p === "/etc/resolv.conf") return "nameserver 127.0.0.53\nsearch lan\n"; + if (p === "/run/systemd/resolve/resolv.conf") { + return "# Generated by systemd-resolved\nnameserver 8.8.8.8\nnameserver 1.1.1.1\n"; + } + return null; + }; + expect(detectSandboxFallbackDns({ readFile })).toBe("8.8.8.8"); + }); + + it("returns null when /etc/resolv.conf has a non-loopback resolver", () => { + const readFile = (_p: string): string | null => "nameserver 192.168.1.1\n"; + expect(detectSandboxFallbackDns({ readFile })).toBeNull(); + }); + + it("returns null when /etc/resolv.conf is missing", () => { + expect(detectSandboxFallbackDns({ readFile: () => null })).toBeNull(); + }); + + it("returns null when /etc/resolv.conf is loopback-only but systemd upstream is missing", () => { + const readFile = (p: string): string | null => { + if (p === "/etc/resolv.conf") return "nameserver 127.0.0.53\n"; + return null; + }; + expect(detectSandboxFallbackDns({ readFile })).toBeNull(); + }); + + it("injects sandboxFallbackDns via --dns on non-host networks", () => { + const inspect = inspectFixture(); + const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("gpus"), { + sandboxFallbackDns: "8.8.8.8", + }); + expect(args).toEqual(expect.arrayContaining(["--dns", "8.8.8.8"])); + }); + + it("does not inject sandboxFallbackDns when OpenShell already configured --dns", () => { + const inspect = inspectFixture(); + inspect.HostConfig = { ...inspect.HostConfig, Dns: ["10.43.0.10"] }; + const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("gpus"), { + sandboxFallbackDns: "8.8.8.8", + }); + expect(args).toEqual(expect.arrayContaining(["--dns", "10.43.0.10"])); + expect(args).not.toEqual(expect.arrayContaining(["--dns", "8.8.8.8"])); + }); + + it("does not inject --dns when network mode is host (Docker ignores --dns on host)", () => { + const inspect = inspectFixture(); + const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("gpus"), { + networkMode: "host", + sandboxFallbackDns: "8.8.8.8", + }); + expect(args).not.toEqual(expect.arrayContaining(["--dns", "8.8.8.8"])); + }); + + it("plumbs detectSandboxFallbackDns through recreateOpenShellDockerSandboxWithGpu into clone args", () => { + // Wire-through test: the production callsite at docker-gpu-patch.ts + // calls d.detectSandboxFallbackDns() and merges the result into + // cloneOptions.sandboxFallbackDns before building the run args. Stub + // the deps hook and verify --dns lands in the final dockerRunDetached call. + const dockerCapture = vi.fn((args: readonly string[]) => { + if (args[0] === "ps") return "old-container-id\n"; + if (args[0] === "inspect") return JSON.stringify([inspectFixture()]); + if (args[0] === "info") return ""; + return ""; + }); + const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" })); + const detectSandboxFallbackDnsStub = vi.fn(() => "9.9.9.9"); + + recreateOpenShellDockerSandboxWithGpu( + { sandboxName: "alpha", timeoutSecs: 1 }, + { + dockerCapture, + dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })), + dockerRunDetached, + dockerRename: vi.fn(() => ({ status: 0 })), + dockerStop: vi.fn(() => ({ status: 0 })), + dockerRm: vi.fn(() => ({ status: 0 })), + runOpenshell: vi.fn(() => ({ status: 0 })), + sleep: vi.fn(), + now: () => new Date("2026-05-15T00:00:00Z"), + detectSandboxFallbackDns: detectSandboxFallbackDnsStub, + }, + ); + + expect(detectSandboxFallbackDnsStub).toHaveBeenCalled(); + expect(dockerRunDetached).toHaveBeenCalledWith( + expect.arrayContaining(["--dns", "9.9.9.9"]), + expect.objectContaining({ ignoreError: true }), + ); + }); + + it("does not inject --dns through recreate when fallback detection returns null", () => { + const dockerCapture = vi.fn((args: readonly string[]) => { + if (args[0] === "ps") return "old-container-id\n"; + if (args[0] === "inspect") return JSON.stringify([inspectFixture()]); + if (args[0] === "info") return ""; + return ""; + }); + const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" })); + + recreateOpenShellDockerSandboxWithGpu( + { sandboxName: "alpha", timeoutSecs: 1 }, + { + dockerCapture, + dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })), + dockerRunDetached, + dockerRename: vi.fn(() => ({ status: 0 })), + dockerStop: vi.fn(() => ({ status: 0 })), + dockerRm: vi.fn(() => ({ status: 0 })), + runOpenshell: vi.fn(() => ({ status: 0 })), + sleep: vi.fn(), + now: () => new Date("2026-05-15T00:00:00Z"), + detectSandboxFallbackDns: () => null, + }, + ); + + // No --dns from the fallback path (and inspectFixture() does not preset host.Dns). + expect(dockerRunDetached).not.toHaveBeenCalledWith( + expect.arrayContaining(["--dns"]), + expect.anything(), + ); + }); + + it("regression manifest: host.openshell.internal + google.com + gateway.discord.gg + integrate.api.nvidia.com (#3579 manager spec)", () => { + // The four hostnames called out in #3579's manager-provided spec: + // host.openshell.internal → resolved via --add-host (mount namespace) + // google.com → public DNS via embedded Docker resolver + // gateway.discord.gg → public DNS via embedded Docker resolver + // integrate.api.nvidia.com → public DNS via embedded Docker resolver + // + // Unit-testable invariants that together cover all four: + // 1. --add-host preserves the host.openshell.internal mapping + // 2. Network mode is NOT "host" by default (so Docker's embedded DNS + // at 127.0.0.11 kicks in for the three public hostnames) + // 3. When the host has a loopback-only resolver, the real upstream + // is injected via --dns so DNS works even if the daemon's + // embedded resolver can't reach the upstream by itself. + const inspect = inspectFixture(); + const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("gpus"), { + sandboxFallbackDns: "8.8.8.8", + }); + + // host.openshell.internal + expect(args).toEqual( + expect.arrayContaining(["--add-host", "host.openshell.internal:172.17.0.1"]), + ); + // google.com / gateway.discord.gg / integrate.api.nvidia.com — covered by + // (a) not pinning --network=host and (b) injecting --dns when the host + // has a loopback-only resolver. + expect(args).not.toEqual(expect.arrayContaining(["--network", "host"])); + expect(args).toEqual(expect.arrayContaining(["--dns", "8.8.8.8"])); + }); +}); diff --git a/src/lib/onboard/docker-gpu-patch.ts b/src/lib/onboard/docker-gpu-patch.ts index a04700143df..6b62185fade 100644 --- a/src/lib/onboard/docker-gpu-patch.ts +++ b/src/lib/onboard/docker-gpu-patch.ts @@ -64,6 +64,7 @@ export type DockerGpuPatchDeps = { sleep?: (seconds: number) => void; homedir?: () => string; now?: () => Date; + detectSandboxFallbackDns?: () => string | null; }; export type DockerGpuPatchModeKind = "gpus" | "nvidia-runtime" | "cdi"; @@ -101,6 +102,7 @@ export type DockerGpuPatchResult = { export type DockerGpuCloneRunOptions = { networkMode?: string | null; openshellEndpoint?: string | null; + sandboxFallbackDns?: string | null; }; export type DockerGpuPatchDiagnostics = { @@ -177,6 +179,7 @@ function depsWithDefaults(deps: DockerGpuPatchDeps): Required< | "sleep" | "homedir" | "now" + | "detectSandboxFallbackDns" > > & DockerGpuPatchDeps { @@ -193,6 +196,7 @@ function depsWithDefaults(deps: DockerGpuPatchDeps): Required< }, homedir: os.homedir, now: () => new Date(), + detectSandboxFallbackDns: () => detectSandboxFallbackDns(), ...deps, }; } @@ -354,6 +358,43 @@ export function buildDockerGpuCloneRunOptions( return { networkMode: "host", openshellEndpoint: hostEndpoint }; } +function parseResolvConfNameservers(content: string): string[] { + return content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("nameserver")) + .map((line) => line.split(/\s+/)[1]) + .filter((ip): ip is string => Boolean(ip)); +} + +// #3579: when the host's /etc/resolv.conf points only at 127.0.0.x (e.g. +// 127.0.0.53 from systemd-resolved), a sandbox in its own network namespace +// can't reach that resolver — systemd-resolved listens in the host namespace +// only. Return the first non-loopback nameserver from +// /run/systemd/resolve/resolv.conf so the caller can inject it via --dns +// rather than relying on inherited /etc/resolv.conf. +export function detectSandboxFallbackDns( + deps: { readFile?: (path: string) => string | null } = {}, +): string | null { + const readFile = + deps.readFile ?? + ((p: string): string | null => { + try { + return fs.readFileSync(p, "utf-8"); + } catch { + return null; + } + }); + const resolvConf = readFile("/etc/resolv.conf"); + if (!resolvConf) return null; + const nameservers = parseResolvConfNameservers(resolvConf); + if (nameservers.length === 0) return null; + if (!nameservers.every((ip) => /^127\./.test(ip))) return null; + const upstreamFile = readFile("/run/systemd/resolve/resolv.conf"); + if (!upstreamFile) return null; + return parseResolvConfNameservers(upstreamFile).find((ip) => !/^127\./.test(ip)) ?? null; +} + export function getDockerGpuPatchNetworkMode( env: Record = process.env, ): "host" | "preserve" { @@ -456,8 +497,15 @@ export function buildDockerGpuCloneRunArgs( for (const hostEntry of stringArray(host.ExtraHosts)) args.push("--add-host", hostEntry); for (const group of stringArray(host.GroupAdd)) args.push("--group-add", group); if (networkMode !== "host") { - for (const dns of stringArray(host.Dns)) args.push("--dns", dns); + const dnsServers = stringArray(host.Dns); + for (const dns of dnsServers) args.push("--dns", dns); for (const dnsSearch of stringArray(host.DnsSearch)) args.push("--dns-search", dnsSearch); + // #3579: when the host has only a loopback resolver (systemd-resolved), + // inject the real upstream so the sandbox doesn't inherit an unreachable + // 127.0.0.53. Only kicks in if OpenShell didn't already set --dns. + if (dnsServers.length === 0 && options.sandboxFallbackDns) { + args.push("--dns", options.sandboxFallbackDns); + } } pushNumberFlag(args, "--memory", host.Memory); @@ -763,6 +811,8 @@ export function recreateOpenShellDockerSandboxWithGpu( } const cloneOptions = buildDockerGpuCloneRunOptions(inspect); + const sandboxFallbackDns = d.detectSandboxFallbackDns(); + if (sandboxFallbackDns) cloneOptions.sandboxFallbackDns = sandboxFallbackDns; const cloneArgs = buildDockerGpuCloneRunArgs(inspect, selection.mode, cloneOptions); const runResult = d.dockerRunDetached(cloneArgs, { ignoreError: true,