Skip to content

Commit f8a80a8

Browse files
authored
Automatically get docker socket path (and fix socket path config in vite plugin) (#10061)
* try to get docker socket from docker context ls * changeset * fix socket config in vite plugin * only test in ci on linux * PR feedback
1 parent f8f7352 commit f8a80a8

File tree

13 files changed

+232
-56
lines changed

13 files changed

+232
-56
lines changed

.changeset/full-pugs-say.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@cloudflare/containers-shared": patch
3+
"wrangler": patch
4+
---
5+
6+
feat(containers): try to automatically get the socket path that the container engine is listening on.
7+
8+
Currently, if your container engine isn't set up to listen on `unix:///var/run/docker.sock` (or isn't symlinked to that), then you have to manually set this via the `dev.containerEngine` field in your Wrangler config, or via the env vars `WRANGLER_DOCKER_HOST`. This change means that we will try and get the socket of the current context automatically. This should reduce the occurrence of opaque `internal error`s thrown by the runtime when the daemon is not listening on `unix:///var/run/docker.sock`.
9+
10+
In addition to `WRANGLER_DOCKER_HOST`, `DOCKER_HOST` can now also be used to set the container engine socket address.

.changeset/yummy-coins-greet.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/vite-plugin": patch
3+
---
4+
5+
fix: properly set the socket path that the container engine is listening on.
6+
7+
Previously, this was only picking up the value set in Wrangler config under `dev.containerEngine`, but this value can also be set from env vars or automatically read from the current docker context.

packages/containers-shared/src/utils.ts

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execFile, spawn } from "child_process";
1+
import { execFileSync, spawn } from "child_process";
22
import { randomUUID } from "crypto";
33
import { existsSync, statSync } from "fs";
44
import path from "path";
@@ -61,22 +61,15 @@ export const runDockerCmd = (
6161
};
6262
};
6363

64-
export const runDockerCmdWithOutput = async (
65-
dockerPath: string,
66-
args: string[]
67-
): Promise<string> => {
68-
return new Promise((resolve, reject) => {
69-
execFile(dockerPath, args, (error, stdout) => {
70-
if (error) {
71-
return reject(
72-
new Error(
73-
`Failed running docker command: ${error.message}. Command: ${dockerPath} ${args.join(" ")}`
74-
)
75-
);
76-
}
77-
return resolve(stdout.trim());
78-
});
79-
});
64+
export const runDockerCmdWithOutput = (dockerPath: string, args: string[]) => {
65+
try {
66+
const stdout = execFileSync(dockerPath, args, { encoding: "utf8" });
67+
return stdout.trim();
68+
} catch (error) {
69+
throw new Error(
70+
`Failed running docker command: ${(error as Error).message}. Command: ${dockerPath} ${args.join(" ")}`
71+
);
72+
}
8073
};
8174

8275
/** throws when docker is not installed */
@@ -209,7 +202,7 @@ export const getContainerIdsFromImage = async (
209202
dockerPath: string,
210203
ancestorImage: string
211204
) => {
212-
const output = await runDockerCmdWithOutput(dockerPath, [
205+
const output = runDockerCmdWithOutput(dockerPath, [
213206
"ps",
214207
"-a",
215208
"--filter",
@@ -250,3 +243,85 @@ export async function checkExposedPorts(
250243
export function generateContainerBuildId() {
251244
return randomUUID().slice(0, 8);
252245
}
246+
247+
/**
248+
* Output of docker context ls --format json
249+
*/
250+
type DockerContext = {
251+
Current: boolean;
252+
Description: string;
253+
DockerEndpoint: string;
254+
Error: string;
255+
Name: string;
256+
};
257+
258+
/**
259+
* Run `docker context ls` to get the socket from the currently active Docker context
260+
* @returns The socket path or null if we are not able to determine it
261+
*/
262+
export function getDockerSocketFromContext(dockerPath: string): string | null {
263+
try {
264+
const output = runDockerCmdWithOutput(dockerPath, [
265+
"context",
266+
"ls",
267+
"--format",
268+
"json",
269+
]);
270+
271+
// Parse each line as a separate JSON object
272+
const lines = output.trim().split("\n");
273+
const contexts: DockerContext[] = lines.map((line) => JSON.parse(line));
274+
275+
// Find the current context
276+
const currentContext = contexts.find((context) => context.Current === true);
277+
278+
if (currentContext && currentContext.DockerEndpoint) {
279+
return currentContext.DockerEndpoint;
280+
}
281+
} catch {
282+
// Fall back to null if docker context inspection fails so that we can use platform defaults
283+
}
284+
return null;
285+
}
286+
/**
287+
* Resolve Docker host as follows:
288+
* 1. Check WRANGLER_DOCKER_HOST environment variable
289+
* 2. Check DOCKER_HOST environment variable
290+
* 3. Try to get socket from active Docker context
291+
* 4. Fall back to platform-specific defaults
292+
*/
293+
export function resolveDockerHost(dockerPath: string): string {
294+
if (process.env.WRANGLER_DOCKER_HOST) {
295+
return process.env.WRANGLER_DOCKER_HOST;
296+
}
297+
298+
if (process.env.DOCKER_HOST) {
299+
return process.env.DOCKER_HOST;
300+
}
301+
302+
// 3. Try to get socket from by running `docker context ls`
303+
304+
const contextSocket = getDockerSocketFromContext(dockerPath);
305+
if (contextSocket) {
306+
return contextSocket;
307+
}
308+
309+
// 4. Fall back to platform-specific defaults
310+
// (note windows doesn't work yet due to a runtime limitation)
311+
return process.platform === "win32"
312+
? "//./pipe/docker_engine"
313+
: "unix:///var/run/docker.sock";
314+
}
315+
316+
/**
317+
*
318+
* Get docker host from environment variables or platform defaults.
319+
* Does not use the docker context ls command, so we
320+
*/
321+
export const getDockerHostFromEnv = (): string => {
322+
const fromEnv = process.env.WRANGLER_DOCKER_HOST ?? process.env.DOCKER_HOST;
323+
324+
return fromEnv ?? process.platform === "win32"
325+
? "//./pipe/docker_engine"
326+
: "unix:///var/run/docker.sock";
327+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { execFileSync } from "child_process";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { resolveDockerHost } from "../src/utils";
4+
5+
vi.mock("node:child_process");
6+
// We can only really run these tests on Linux, because we build our images for linux/amd64,
7+
// and github runners don't really support container virtualization in any sane way
8+
describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
9+
"resolveDockerHost",
10+
() => {
11+
afterEach(() => {
12+
vi.unstubAllEnvs();
13+
});
14+
15+
it("should return WRANGLER_DOCKER_HOST when set", async () => {
16+
vi.stubEnv(
17+
"WRANGLER_DOCKER_HOST",
18+
"unix:///FROM/WRANGLER/DOCKER/HOST/wrangler/socket"
19+
);
20+
vi.stubEnv("DOCKER_HOST", "unix:///FROM/DOCKER/HOST/docker/socket");
21+
22+
const result = resolveDockerHost("/no/op/docker");
23+
expect(result).toBe("unix:///FROM/WRANGLER/DOCKER/HOST/wrangler/socket");
24+
});
25+
26+
it("should return DOCKER_HOST when WRANGLER_DOCKER_HOST is not set", async () => {
27+
vi.stubEnv("WRANGLER_DOCKER_HOST", undefined);
28+
vi.stubEnv("DOCKER_HOST", "unix:///FROM/DOCKER/HOST/docker/socket");
29+
30+
const result = resolveDockerHost("/no/op/docker");
31+
expect(result).toBe("unix:///FROM/DOCKER/HOST/docker/socket");
32+
});
33+
34+
it("should use Docker context when no env vars are set", async () => {
35+
vi.mocked(execFileSync)
36+
.mockReturnValue(`{"Current":true,"Description":"Current DOCKER_HOST based configuration","DockerEndpoint":"unix:///FROM/CURRENT/CONTEXT/run/docker.sock","Error":"","Name":"default"}
37+
{"Current":false,"Description":"Docker Desktop","DockerEndpoint":"unix:///FROM/OTHER/CONTEXT/run/docker.sock","Error":"","Name":"desktop-linux"}`);
38+
const result = resolveDockerHost("/no/op/docker");
39+
expect(result).toBe("unix:///FROM/CURRENT/CONTEXT/run/docker.sock");
40+
});
41+
42+
it("should fall back to platform default when context fails", () => {
43+
vi.mocked(execFileSync).mockImplementation(() => {
44+
throw new Error("Docker command failed");
45+
});
46+
47+
const result = resolveDockerHost("/no/op/docker");
48+
expect(result).toBe("unix:///var/run/docker.sock");
49+
});
50+
}
51+
);

packages/vite-plugin-cloudflare/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from "node:path";
44
import {
55
generateContainerBuildId,
66
getContainerIdsByImageTags,
7+
resolveDockerHost,
78
} from "@cloudflare/containers-shared/src/utils";
89
import { generateStaticRoutingRuleMatcher } from "@cloudflare/workers-shared/asset-worker/src/utils/rules-engine";
910
import replace from "@rollup/plugin-replace";
@@ -370,9 +371,12 @@ if (import.meta.hot) {
370371
const hasDevContainers =
371372
entryWorkerConfig?.containers?.length &&
372373
entryWorkerConfig.dev.enable_containers;
374+
const dockerPath = getDockerPath();
373375

374376
if (hasDevContainers) {
375377
containerBuildId = generateContainerBuildId();
378+
entryWorkerConfig.dev.container_engine =
379+
resolveDockerHost(dockerPath);
376380
}
377381

378382
const miniflareDevOptions = await getDevMiniflareOptions({
@@ -445,8 +449,6 @@ if (import.meta.hot) {
445449
}
446450

447451
if (hasDevContainers) {
448-
const dockerPath = getDockerPath();
449-
450452
containerImageTagsSeen = await prepareContainerImages({
451453
containersConfig: entryWorkerConfig.containers,
452454
containerBuildId,

packages/wrangler/src/__tests__/dev.test.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import type { Mock, MockInstance } from "vitest";
3434
vi.mock("../api/startDevWorker/ConfigController", (importOriginal) =>
3535
importOriginal()
3636
);
37-
37+
vi.mock("node:child_process");
3838
vi.mock("../dev/hotkeys");
3939

4040
vi.mock("@cloudflare/containers-shared", async (importOriginal) => {
@@ -1245,16 +1245,44 @@ describe.sequential("wrangler dev", () => {
12451245
});
12461246

12471247
describe("container engine", () => {
1248-
it("should default to docker socket", async () => {
1248+
const minimalContainerConfig = {
1249+
durable_objects: {
1250+
bindings: [
1251+
{
1252+
name: "EXAMPLE_DO_BINDING",
1253+
class_name: "ExampleDurableObject",
1254+
},
1255+
],
1256+
},
1257+
migrations: [{ tag: "v1", new_sqlite_classes: ["ExampleDurableObject"] }],
1258+
containers: [
1259+
{
1260+
name: "my-container",
1261+
max_instances: 10,
1262+
class_name: "ExampleDurableObject",
1263+
image: "docker.io/hello:world",
1264+
},
1265+
],
1266+
};
1267+
let mockExecFileSync: ReturnType<typeof vi.fn>;
1268+
const mockedDockerContextLsOutput = `{"Current":true,"Description":"Current DOCKER_HOST based configuration","DockerEndpoint":"unix:///current/run/docker.sock","Error":"","Name":"default"}
1269+
{"Current":false,"Description":"Docker Desktop","DockerEndpoint":"unix:///other/run/docker.sock","Error":"","Name":"desktop-linux"}`;
1270+
1271+
beforeEach(async () => {
1272+
const childProcess = await import("node:child_process");
1273+
mockExecFileSync = vi.mocked(childProcess.execFileSync);
1274+
1275+
mockExecFileSync.mockReturnValue(mockedDockerContextLsOutput);
1276+
});
1277+
it("should default to socket of current docker context", async () => {
12491278
writeWranglerConfig({
12501279
main: "index.js",
1280+
...minimalContainerConfig,
12511281
});
12521282
fs.writeFileSync("index.js", `export default {};`);
12531283
const config = await runWranglerUntilConfig("dev");
12541284
expect(config.dev.containerEngine).toEqual(
1255-
process.platform === "win32"
1256-
? "//./pipe/docker_engine"
1257-
: "unix:///var/run/docker.sock"
1285+
"unix:///current/run/docker.sock"
12581286
);
12591287
});
12601288

@@ -1265,6 +1293,7 @@ describe.sequential("wrangler dev", () => {
12651293
port: 8888,
12661294
container_engine: "test.sock",
12671295
},
1296+
...minimalContainerConfig,
12681297
});
12691298
fs.writeFileSync("index.js", `export default {};`);
12701299

@@ -1277,6 +1306,7 @@ describe.sequential("wrangler dev", () => {
12771306
dev: {
12781307
port: 8888,
12791308
},
1309+
...minimalContainerConfig,
12801310
});
12811311
fs.writeFileSync("index.js", `export default {};`);
12821312
vi.stubEnv("WRANGLER_DOCKER_HOST", "blah.sock");

packages/wrangler/src/api/dev.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import events from "node:events";
22
import { fetch, Request } from "undici";
33
import { startDev } from "../dev";
4-
import {
5-
getDockerHost,
6-
getDockerPath,
7-
} from "../environment-variables/misc-variables";
4+
import { getDockerPath } from "../environment-variables/misc-variables";
85
import { run } from "../experimental-flags";
96
import { logger } from "../logger";
107
import type { Environment } from "../config";
@@ -165,6 +162,8 @@ export async function unstable_dev(
165162
const defaultLogLevel = testMode ? "warn" : "log";
166163
const local = options?.local ?? true;
167164

165+
const dockerPath = options?.experimental?.dockerPath ?? getDockerPath();
166+
168167
const devOptions: StartDevOptions = {
169168
script: script,
170169
inspect: false,
@@ -227,8 +226,8 @@ export async function unstable_dev(
227226
enableIpc: options?.experimental?.enableIpc,
228227
nodeCompat: undefined,
229228
enableContainers: options?.experimental?.enableContainers ?? false,
230-
dockerPath: options?.experimental?.dockerPath ?? getDockerPath(),
231-
containerEngine: options?.experimental?.containerEngine ?? getDockerHost(),
229+
dockerPath,
230+
containerEngine: options?.experimental?.containerEngine,
232231
};
233232

234233
//outside of test mode, rebuilds work fine, but only one instance of wrangler will work at a time

packages/wrangler/src/api/integrations/platform/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveDockerHost } from "@cloudflare/containers-shared";
12
import { kCurrentWorker, Miniflare } from "miniflare";
23
import { getAssetsOptions, NonExistentAssetsDirError } from "../../../assets";
34
import { readConfig } from "../../../config";
@@ -12,7 +13,7 @@ import {
1213
buildSitesOptions,
1314
getImageNameFromDOClassName,
1415
} from "../../../dev/miniflare";
15-
import { getDockerHost } from "../../../environment-variables/misc-variables";
16+
import { getDockerPath } from "../../../environment-variables/misc-variables";
1617
import { logger } from "../../../logger";
1718
import { getSiteAssetPaths } from "../../../sites";
1819
import { dedent } from "../../../utils/dedent";
@@ -460,11 +461,15 @@ export function unstable_getMiniflareWorkerOptions(
460461
? buildAssetOptions({ assets: processedAssetOptions })
461462
: {};
462463

464+
const useContainers =
465+
config.dev?.enable_containers && config.containers?.length;
463466
const workerOptions: SourcelessWorkerOptions = {
464467
compatibilityDate: config.compatibility_date,
465468
compatibilityFlags: config.compatibility_flags,
466469
modulesRules,
467-
containerEngine: config.dev.container_engine ?? getDockerHost(),
470+
containerEngine: useContainers
471+
? config.dev.container_engine ?? resolveDockerHost(getDockerPath())
472+
: undefined,
468473

469474
...bindingOptions,
470475
...sitesOptions,

0 commit comments

Comments
 (0)