Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 10 additions & 0 deletions .changeset/full-pugs-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudflare/containers-shared": patch
"wrangler": patch
---

feat: try to automatically get path of docker socket
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few questions:

Should "feat" imply a minor release?

Should "docker socket" be "container engine socket" (and use "container engine" below and in the other changeset rather than "docker" or "container tool")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our general rule of thumb apparently is features on experimental products get releases as patches, so i'll probably keep this as is.

good point on the docker specific language, i'll update the changesets


Currently, if your container tool isn't set up to listen at `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 at `unix:///var/run/docker.sock`.

You can still override this with `WRANGLER_DOCKER_HOST`, and we also now read `DOCKER_HOST` too.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
You can still override this with `WRANGLER_DOCKER_HOST`, and we also now read `DOCKER_HOST` too.
In addition to `WRANGLER_DOCKER_HOST`, `DOCKER_HOST` can now also be used to set the container engine socket address.

7 changes: 7 additions & 0 deletions .changeset/yummy-coins-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/vite-plugin": patch
---

fix: properly set the docker socket path in the vite plugin

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.
114 changes: 96 additions & 18 deletions packages/containers-shared/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFile, spawn } from "child_process";
import { execFileSync, spawn } from "child_process";
import { randomUUID } from "crypto";
import { existsSync, statSync } from "fs";
import path from "path";
Expand Down Expand Up @@ -61,22 +61,15 @@ export const runDockerCmd = (
};
};

export const runDockerCmdWithOutput = async (
dockerPath: string,
args: string[]
): Promise<string> => {
return new Promise((resolve, reject) => {
execFile(dockerPath, args, (error, stdout) => {
if (error) {
return reject(
new Error(
`Failed running docker command: ${error.message}. Command: ${dockerPath} ${args.join(" ")}`
)
);
}
return resolve(stdout.trim());
});
});
export const runDockerCmdWithOutput = (dockerPath: string, args: string[]) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity what what this changed from async to sync? (not mentioned in the PR description).

Does it deserves a comment explaining why it should not be async?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the unstable_getMiniflareWorkerOptions api needs to use this to resolve the socket path, and that function is sync (and part of wranglers public api surface) so i wanted to avoid changing that.

try {
const stdout = execFileSync(dockerPath, args, { encoding: "utf8" });
return stdout.trim();
} catch (error) {
throw new Error(
`Failed running docker command: ${(error as Error).message}. Command: ${dockerPath} ${args.join(" ")}`
);
}
};

/** throws when docker is not installed */
Expand Down Expand Up @@ -209,7 +202,7 @@ export const getContainerIdsFromImage = async (
dockerPath: string,
ancestorImage: string
) => {
const output = await runDockerCmdWithOutput(dockerPath, [
const output = runDockerCmdWithOutput(dockerPath, [
"ps",
"-a",
"--filter",
Expand Down Expand Up @@ -250,3 +243,88 @@ export async function checkExposedPorts(
export function generateContainerBuildId() {
return randomUUID().slice(0, 8);
}

type DockerContext = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is parsed from docker context ls, maybe add a comment.

Would it be worth adding some validations (zod or whatever) on top of JSON.parse

Copy link
Contributor Author

@emily-shen emily-shen Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't really want to add zod just to validate this one thing, if it fails we have a sensible fallback. i mean, i would love to add zod to validate very many things across wrangler, but this probably isn't the place to start 😅

but comment added though :)

Current: boolean;
Description: string;
DockerEndpoint: string;
Error: string;
Name: string;
};

/**
* Run `docker context ls` to get the Docker socket from the currently active Docker context
*/
export function getDockerSocketFromContext(dockerPath: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe:

  • add an explicit return type
  • (and/or) add a JSDoc comment about the return value
  • specify if it can throw in the JSDoc (i.e. when the socket is not found)

try {
const output = runDockerCmdWithOutput(dockerPath, [
"context",
"ls",
"--format",
"json",
]);

// Parse each line as a separate JSON object
const lines = output.trim().split("\n");
const contexts: DockerContext[] = lines.map((line) => JSON.parse(line));

// Find the current context
const currentContext = contexts.find((context) => context.Current === true);

if (
currentContext &&
currentContext.DockerEndpoint &&
typeof currentContext.DockerEndpoint === "string"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type says DockerEndpoint: string; so either this code is not needed or the type is wrong.

cf my comment about validation earlier

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

) {
return currentContext.DockerEndpoint;
}
} catch {
// Fall back to null if docker context inspection fails
}
return null;
}
/**
* Resolve Docker host as follows:
* 1. Check WRANGLER_DOCKER_HOST environment variable
* 2. Check DOCKER_HOST environment variable
* 3. Try to get socket from active Docker context
* 4. Fall back to platform-specific defaults
*/
export function resolveDockerHost(dockerPath: string) {
if (process.env.WRANGLER_DOCKER_HOST) {
return process.env.WRANGLER_DOCKER_HOST;
}

if (process.env.DOCKER_HOST) {
return process.env.DOCKER_HOST;
}

// 3. Try to get socket from by running `docker context ls`
try {
const contextSocket = getDockerSocketFromContext(dockerPath);
if (contextSocket) {
return contextSocket;
}
} catch {}

// 4. Fall back to platform-specific defaults
// (note windows doesn't work yet due to a runtime limitation)
return process.platform === "win32"
? "//./pipe/docker_engine"
: "unix:///var/run/docker.sock";
}

/**
*
* Get docker host from environment variables or platform defaults.
* Does not use the docker context ls command, so we
*/
export const getDockerHostFromEnv = () => {
const fromEnv = process.env.WRANGLER_DOCKER_HOST ?? process.env.DOCKER_HOST;
if (fromEnv) {
return fromEnv;
}
return process.platform === "win32"
? "//./pipe/docker_engine"
: "unix:///var/run/docker.sock";
};
65 changes: 65 additions & 0 deletions packages/containers-shared/tests/docker-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveDockerHost } from "../src/utils";

const mockedDockerContextLsOutput = `{"Current":true,"Description":"Current DOCKER_HOST based configuration","DockerEndpoint":"unix:///current/run/docker.sock","Error":"","Name":"default"}
{"Current":false,"Description":"Docker Desktop","DockerEndpoint":"unix:///other/run/docker.sock","Error":"","Name":"desktop-linux"}`;

vi.mock("node:child_process");

describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process.platform !== "linux" && process.env.CI === "true"

If think I have already seen this condition but I can't remember why it is.

Would factoring the code into an helper function with some comment about the rationale help understanding/maintaining this code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a helper would be great, but the other places this is used is fixtures and wrangler, and i don't know if it would be good to be importing helpers between these three contexts?

i have added a comment here though

"resolveDockerHost",
() => {
let mockExecFileSync: ReturnType<typeof vi.fn>;

beforeEach(async () => {
vi.clearAllMocks();
const childProcess = await import("node:child_process");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shame on me but I am not so familiar with vitest mock.

Is vi.mock("node:child_process"); further still needed with that?
If yes, they would benefit from being closer in the source code (cf my earlier comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah i am also not great with vitest mocks, but that makes sense and it works. thanks!

mockExecFileSync = vi.mocked(childProcess.execFileSync);

mockExecFileSync.mockReturnValue(mockedDockerContextLsOutput);
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("should return WRANGLER_DOCKER_HOST when set", async () => {
vi.stubEnv("WRANGLER_DOCKER_HOST", "unix:///foo/wrangler/socket");
vi.stubEnv("DOCKER_HOST", "unix:///bar/docker/socket");

const result = await resolveDockerHost("/no/op/docker");
expect(result).toBe("unix:///foo/wrangler/socket");

expect(mockExecFileSync).not.toHaveBeenCalled();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have 0 idea of what this is testing without going back to the implementation of the function. Maybe there should be a comment on the variable or a more descriptive name.

});

it("should return DOCKER_HOST when WRANGLER_DOCKER_HOST is not set", async () => {
expect(process.env.WRANGLER_DOCKER_HOST).toBeUndefined();
vi.stubEnv("DOCKER_HOST", "unix:///bar/docker/socket");

const result = await resolveDockerHost("/no/op/docker");
expect(result).toBe("unix:///bar/docker/socket");

expect(mockExecFileSync).not.toHaveBeenCalled();
});

it("should use Docker context when no env vars are set", async () => {
const result = await resolveDockerHost("/no/op/docker");
expect(result).toBe("unix:///current/run/docker.sock");
expect(mockExecFileSync).toHaveBeenCalledWith(
"/no/op/docker",
["context", "ls", "--format", "json"],
{ encoding: "utf8" }
);
});

it("should fall back to platform default on Unix when context fails", async () => {
mockExecFileSync.mockImplementation(() => {
throw new Error("Docker command failed");
});

const result = await resolveDockerHost("/no/op/docker");
expect(result).toBe("unix:///var/run/docker.sock");
});
}
);
6 changes: 4 additions & 2 deletions packages/vite-plugin-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from "node:path";
import {
generateContainerBuildId,
getContainerIdsByImageTags,
resolveDockerHost,
} from "@cloudflare/containers-shared/src/utils";
import { generateStaticRoutingRuleMatcher } from "@cloudflare/workers-shared/asset-worker/src/utils/rules-engine";
import replace from "@rollup/plugin-replace";
Expand Down Expand Up @@ -370,9 +371,12 @@ if (import.meta.hot) {
const hasDevContainers =
entryWorkerConfig?.containers?.length &&
entryWorkerConfig.dev.enable_containers;
const dockerPath = getDockerPath();

if (hasDevContainers) {
containerBuildId = generateContainerBuildId();
entryWorkerConfig.dev.container_engine =
resolveDockerHost(dockerPath);
}

const miniflareDevOptions = await getDevMiniflareOptions({
Expand Down Expand Up @@ -445,8 +449,6 @@ if (import.meta.hot) {
}

if (hasDevContainers) {
const dockerPath = getDockerPath();

containerImageTagsSeen = await prepareContainerImages({
containersConfig: entryWorkerConfig.containers,
containerBuildId,
Expand Down
40 changes: 35 additions & 5 deletions packages/wrangler/src/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type { Mock, MockInstance } from "vitest";
vi.mock("../api/startDevWorker/ConfigController", (importOriginal) =>
importOriginal()
);

vi.mock("node:child_process");
vi.mock("../dev/hotkeys");

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

describe("container engine", () => {
it("should default to docker socket", async () => {
const minimalContainerConfig = {
durable_objects: {
bindings: [
{
name: "EXAMPLE_DO_BINDING",
class_name: "ExampleDurableObject",
},
],
},
migrations: [{ tag: "v1", new_sqlite_classes: ["ExampleDurableObject"] }],
containers: [
{
name: "my-container",
max_instances: 10,
class_name: "ExampleDurableObject",
image: "docker.io/hello:world",
},
],
};
let mockExecFileSync: ReturnType<typeof vi.fn>;
const mockedDockerContextLsOutput = `{"Current":true,"Description":"Current DOCKER_HOST based configuration","DockerEndpoint":"unix:///current/run/docker.sock","Error":"","Name":"default"}
{"Current":false,"Description":"Docker Desktop","DockerEndpoint":"unix:///other/run/docker.sock","Error":"","Name":"desktop-linux"}`;

beforeEach(async () => {
const childProcess = await import("node:child_process");
mockExecFileSync = vi.mocked(childProcess.execFileSync);

mockExecFileSync.mockReturnValue(mockedDockerContextLsOutput);
});
it("should default to socket of current docker context", async () => {
writeWranglerConfig({
main: "index.js",
...minimalContainerConfig,
});
fs.writeFileSync("index.js", `export default {};`);
const config = await runWranglerUntilConfig("dev");
expect(config.dev.containerEngine).toEqual(
process.platform === "win32"
? "//./pipe/docker_engine"
: "unix:///var/run/docker.sock"
"unix:///current/run/docker.sock"
);
});

Expand All @@ -1265,6 +1293,7 @@ describe.sequential("wrangler dev", () => {
port: 8888,
container_engine: "test.sock",
},
...minimalContainerConfig,
});
fs.writeFileSync("index.js", `export default {};`);

Expand All @@ -1277,6 +1306,7 @@ describe.sequential("wrangler dev", () => {
dev: {
port: 8888,
},
...minimalContainerConfig,
});
fs.writeFileSync("index.js", `export default {};`);
vi.stubEnv("WRANGLER_DOCKER_HOST", "blah.sock");
Expand Down
11 changes: 5 additions & 6 deletions packages/wrangler/src/api/dev.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import events from "node:events";
import { fetch, Request } from "undici";
import { startDev } from "../dev";
import {
getDockerHost,
getDockerPath,
} from "../environment-variables/misc-variables";
import { getDockerPath } from "../environment-variables/misc-variables";
import { run } from "../experimental-flags";
import { logger } from "../logger";
import type { Environment } from "../config";
Expand Down Expand Up @@ -165,6 +162,8 @@ export async function unstable_dev(
const defaultLogLevel = testMode ? "warn" : "log";
const local = options?.local ?? true;

const dockerPath = options?.experimental?.dockerPath ?? getDockerPath();

const devOptions: StartDevOptions = {
script: script,
inspect: false,
Expand Down Expand Up @@ -227,8 +226,8 @@ export async function unstable_dev(
enableIpc: options?.experimental?.enableIpc,
nodeCompat: undefined,
enableContainers: options?.experimental?.enableContainers ?? false,
dockerPath: options?.experimental?.dockerPath ?? getDockerPath(),
containerEngine: options?.experimental?.containerEngine ?? getDockerHost(),
dockerPath,
containerEngine: options?.experimental?.containerEngine,
Comment on lines +229 to +230
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the diff on the first line is only moving code around.

Is the second line diff expected ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep - i don't want to try and get the socket path unless we are sure there are containers configured and enableContainers is true, but i don't think we have read the config yet here so we can't do that. instead i set it later on in configController.

};

//outside of test mode, rebuilds work fine, but only one instance of wrangler will work at a time
Expand Down
9 changes: 7 additions & 2 deletions packages/wrangler/src/api/integrations/platform/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveDockerHost } from "@cloudflare/containers-shared";
import { kCurrentWorker, Miniflare } from "miniflare";
import { getAssetsOptions, NonExistentAssetsDirError } from "../../../assets";
import { readConfig } from "../../../config";
Expand All @@ -12,7 +13,7 @@ import {
buildSitesOptions,
getImageNameFromDOClassName,
} from "../../../dev/miniflare";
import { getDockerHost } from "../../../environment-variables/misc-variables";
import { getDockerPath } from "../../../environment-variables/misc-variables";
import { logger } from "../../../logger";
import { getSiteAssetPaths } from "../../../sites";
import { dedent } from "../../../utils/dedent";
Expand Down Expand Up @@ -460,11 +461,15 @@ export function unstable_getMiniflareWorkerOptions(
? buildAssetOptions({ assets: processedAssetOptions })
: {};

const useContainers =
config.dev?.enable_containers && config.containers?.length;
const workerOptions: SourcelessWorkerOptions = {
compatibilityDate: config.compatibility_date,
compatibilityFlags: config.compatibility_flags,
modulesRules,
containerEngine: config.dev.container_engine ?? getDockerHost(),
containerEngine: useContainers
? config.dev.container_engine ?? resolveDockerHost(getDockerPath())
: undefined,

...bindingOptions,
...sitesOptions,
Expand Down
Loading
Loading