Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
157 changes: 157 additions & 0 deletions src/lib/onboard/docker-gpu-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
buildDockerGpuMode,
buildDockerGpuModeCandidates,
collectDockerGpuPatchDiagnostics,
detectSandboxFallbackDns,
dockerReportsNvidiaCdiDevices,
formatDockerInspectNetworkSummary,
getDockerGpuPatchNetworkMode,
Expand Down Expand Up @@ -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"]));
});
});
52 changes: 51 additions & 1 deletion src/lib/onboard/docker-gpu-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -101,6 +102,7 @@ export type DockerGpuPatchResult = {
export type DockerGpuCloneRunOptions = {
networkMode?: string | null;
openshellEndpoint?: string | null;
sandboxFallbackDns?: string | null;
};

export type DockerGpuPatchDiagnostics = {
Expand Down Expand Up @@ -177,6 +179,7 @@ function depsWithDefaults(deps: DockerGpuPatchDeps): Required<
| "sleep"
| "homedir"
| "now"
| "detectSandboxFallbackDns"
>
> &
DockerGpuPatchDeps {
Expand All @@ -193,6 +196,7 @@ function depsWithDefaults(deps: DockerGpuPatchDeps): Required<
},
homedir: os.homedir,
now: () => new Date(),
detectSandboxFallbackDns: () => detectSandboxFallbackDns(),
...deps,
};
}
Expand Down Expand Up @@ -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<string, string | undefined> = process.env,
): "host" | "preserve" {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading