Skip to content

Commit 35b2c56

Browse files
authored
containers: Add container and test Containers interceptOutboundHttp (#12649)
1 parent 23a365a commit 35b2c56

File tree

13 files changed

+176
-8
lines changed

13 files changed

+176
-8
lines changed

.changeset/empty-radios-happen.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@cloudflare/vite-plugin": minor
3+
"@cloudflare/containers-shared": minor
4+
"@cloudflare/workers-utils": minor
5+
"miniflare": minor
6+
"wrangler": minor
7+
---
8+
9+
Add experimental support for containers to workers communication with interceptOutboundHttp
10+
11+
This feature is experimental and requires adding the "experimental"
12+
compatibility flag to your Wrangler configuration.

packages/containers-shared/src/images.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ import type {
1717
WranglerLogger,
1818
} from "./types";
1919

20+
const DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE =
21+
"cloudflare/proxy-everything:4dc6c7f@sha256:9621ef445ef120409e5d95bbd845ab2fa0f613636b59a01d998f5704f4096ae2";
22+
23+
export function getEgressInterceptorImage(): string {
24+
return (
25+
process.env.MINIFLARE_CONTAINER_EGRESS_IMAGE ??
26+
DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE
27+
);
28+
}
29+
30+
export async function pullEgressInterceptorImage(
31+
dockerPath: string
32+
): Promise<void> {
33+
const image = getEgressInterceptorImage();
34+
await runDockerCmd(dockerPath, ["pull", image, "--platform", "linux/amd64"]);
35+
}
36+
2037
export async function pullImage(
2138
dockerPath: string,
2239
options: Exclude<ContainerDevOptions, DockerfileConfig>,
@@ -97,6 +114,7 @@ export async function prepareContainerImagesForDev(args: {
97114
}) => void;
98115
logger: WranglerLogger | ViteLogger;
99116
isVite: boolean;
117+
compatibilityFlags?: string[];
100118
}): Promise<void> {
101119
const {
102120
dockerPath,
@@ -152,6 +170,13 @@ export async function prepareContainerImagesForDev(args: {
152170
await checkExposedPorts(dockerPath, options);
153171
}
154172
}
173+
174+
// Pull the egress interceptor image if experimental flag is enabled.
175+
// This image is used to intercept outbound HTTP from containers and
176+
// route it back to workerd (e.g. for interceptOutboundHttp).
177+
if (!aborted && args.compatibilityFlags?.includes("experimental")) {
178+
await pullEgressInterceptorImage(dockerPath);
179+
}
155180
}
156181

157182
/**

packages/miniflare/src/plugins/core/index.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ const CoreOptionsSchemaInput = z.intersection(
192192
containerEngine: z
193193
.union([
194194
z.object({
195-
localDocker: z.object({ socketPath: z.string() }),
195+
localDocker: z.object({
196+
socketPath: z.string(),
197+
containerEgressInterceptorImage: z.string().optional(),
198+
}),
196199
}),
197200
z.string(),
198201
])
@@ -905,7 +908,10 @@ export const CORE_PLUGIN: Plugin<
905908
);
906909
}
907910
),
908-
containerEngine: getContainerEngine(options.containerEngine),
911+
containerEngine: getContainerEngine(
912+
options.containerEngine,
913+
options.compatibilityFlags
914+
),
909915
},
910916
});
911917
}
@@ -1210,13 +1216,28 @@ function getWorkerScript(
12101216
}
12111217
}
12121218

1219+
const DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE =
1220+
"cloudflare/proxy-everything:4dc6c7f@sha256:9621ef445ef120409e5d95bbd845ab2fa0f613636b59a01d998f5704f4096ae2";
1221+
1222+
/**
1223+
* Returns the default containerEgressInterceptorImage. It's used for
1224+
* container network interception for local dev.
1225+
*/
1226+
function getContainerEgressInterceptorImage(): string {
1227+
return (
1228+
process.env.MINIFLARE_CONTAINER_EGRESS_IMAGE ??
1229+
DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE
1230+
);
1231+
}
1232+
12131233
/**
12141234
* Returns the Container engine configuration
12151235
* @param engineOrSocketPath Either a full engine config or a unix socket
12161236
* @returns The container engine, defaulting to the default docker socket located on linux/macOS at `unix:///var/run/docker.sock`
12171237
*/
12181238
function getContainerEngine(
1219-
engineOrSocketPath: Worker_ContainerEngine | string | undefined
1239+
engineOrSocketPath: Worker_ContainerEngine | string | undefined,
1240+
compatibilityFlags?: string[]
12201241
): Worker_ContainerEngine {
12211242
if (!engineOrSocketPath) {
12221243
// TODO: workerd does not support win named pipes
@@ -1226,11 +1247,31 @@ function getContainerEngine(
12261247
: "unix:///var/run/docker.sock";
12271248
}
12281249

1250+
// TODO: Once the feature becomes GA, we should remove the experimental requirement.
1251+
// Egress interceptor is to support direct connectivity between the Container and Workers,
1252+
// it spawns a container in the same network namespace as the local dev container and
1253+
// intercepts traffic to redirect to Workerd.
1254+
const egressImage = compatibilityFlags?.includes("experimental")
1255+
? getContainerEgressInterceptorImage()
1256+
: undefined;
1257+
12291258
if (typeof engineOrSocketPath === "string") {
1230-
return { localDocker: { socketPath: engineOrSocketPath } };
1259+
return {
1260+
localDocker: {
1261+
socketPath: engineOrSocketPath,
1262+
containerEgressInterceptorImage: egressImage,
1263+
},
1264+
};
12311265
}
12321266

1233-
return engineOrSocketPath;
1267+
return {
1268+
localDocker: {
1269+
...engineOrSocketPath.localDocker,
1270+
containerEgressInterceptorImage:
1271+
engineOrSocketPath.localDocker.containerEgressInterceptorImage ??
1272+
egressImage,
1273+
},
1274+
};
12341275
}
12351276

12361277
export * from "./errors";

packages/miniflare/src/runtime/config/generated/workerd.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2734,7 +2734,7 @@ export class Worker_DockerConfiguration extends $.Struct {
27342734
static readonly _capnp = {
27352735
displayName: "DockerConfiguration",
27362736
id: "e62f96c20d9fb872",
2737-
size: new $.ObjectSize(0, 1),
2737+
size: new $.ObjectSize(0, 2),
27382738
};
27392739
/**
27402740
* Path to the Docker socket.
@@ -2746,6 +2746,19 @@ export class Worker_DockerConfiguration extends $.Struct {
27462746
set socketPath(value: string) {
27472747
$.utils.setText(0, value, this);
27482748
}
2749+
/**
2750+
* Docker image name for the container egress interceptor sidecar.
2751+
* This sidecar intercepts outbound traffic from containers and routes it
2752+
* through workerd for egress mappings (setEgressHttp bindings).
2753+
* You can find this image in repositories like DockerHub: https://hub.docker.com/r/cloudflare/proxy-everything
2754+
*
2755+
*/
2756+
get containerEgressInterceptorImage(): string {
2757+
return $.utils.getText(1, this);
2758+
}
2759+
set containerEgressInterceptorImage(value: string) {
2760+
$.utils.setText(1, value, this);
2761+
}
27492762
toString(): string {
27502763
return "Worker_DockerConfiguration_" + super.toString();
27512764
}

packages/miniflare/src/runtime/config/workerd.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface ServiceDesignator {
5252

5353
export type Worker_DockerConfiguration = {
5454
socketPath: string;
55+
containerEgressInterceptorImage?: string;
5556
};
5657

5758
export type Worker_ContainerEngine = {

packages/vite-plugin-cloudflare/src/plugins/dev.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ export const devPlugin = createPlugin("dev", (ctx) => {
208208
onContainerImagePreparationEnd: () => {},
209209
logger: viteDevServer.config.logger,
210210
isVite: true,
211+
compatibilityFlags: ctx.allWorkerConfigs.flatMap(
212+
(c) => c.compatibility_flags
213+
),
211214
});
212215

213216
containerImageTags = new Set(containerTagToOptionsMap.keys());

packages/vite-plugin-cloudflare/src/plugins/preview.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export const previewPlugin = createPlugin("preview", (ctx) => {
4949
onContainerImagePreparationEnd: () => {},
5050
logger: vitePreviewServer.config.logger,
5151
isVite: true,
52+
compatibilityFlags: ctx.allWorkerConfigs.flatMap(
53+
(c) => c.compatibility_flags
54+
),
5255
});
5356

5457
const containerImageTags = new Set(containerTagToOptionsMap.keys());

packages/vitest-pool-workers/test/global-setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default async function ({ provide }: GlobalSetupContext) {
2828
await stop();
2929

3030
console.log("Cleaning up temporary directory...");
31-
removeDir(projectPath, { fireAndForget: true });
31+
void removeDir(projectPath, { fireAndForget: true });
3232
};
3333
}
3434

packages/workers-utils/src/config/environment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,8 @@ export interface CacheOptions {
14641464
export type DockerConfiguration = {
14651465
/** Socket used by miniflare to communicate with Docker */
14661466
socketPath: string;
1467+
/** Docker image name for the container egress interceptor sidecar */
1468+
containerEgressInterceptorImage?: string;
14671469
};
14681470

14691471
export type ContainerEngine =

packages/wrangler/e2e/containers.dev.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ for (const source of imageSource) {
4747
name: `${workerName}`,
4848
main: "src/index.ts",
4949
compatibility_date: "2025-04-03",
50+
compatibility_flags: ["experimental", "enable_ctx_exports"],
5051
containers: [
5152
{
5253
image: "./Dockerfile",
@@ -72,7 +73,13 @@ for (const source of imageSource) {
7273
await helper.seed({
7374
"wrangler.json": JSON.stringify(wranglerConfig),
7475
"src/index.ts": dedent`
75-
import { DurableObject } from "cloudflare:workers";
76+
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
77+
78+
export class TestService extends WorkerEntrypoint {
79+
async fetch(req: Request) {
80+
return new Response("hello from worker");
81+
}
82+
}
7683
7784
export class E2EContainer extends DurableObject<Env> {
7885
container: globalThis.Container;
@@ -101,6 +108,22 @@ for (const source of imageSource) {
101108
.getTcpPort(8080)
102109
.fetch("http://foo/bar/baz");
103110
return new Response(await res.text());
111+
112+
case "/setup-intercept":
113+
await this.container.interceptOutboundHttp(
114+
"11.0.0.1:80",
115+
this.ctx.exports.TestService({ props: {} })
116+
);
117+
return new Response("Intercept setup done");
118+
119+
case "/fetch-intercept":
120+
const interceptRes = await this.container
121+
.getTcpPort(8080)
122+
.fetch("http://foo/intercept", {
123+
headers: { "x-host": "11.0.0.1:80" },
124+
});
125+
return new Response(await interceptRes.text());
126+
104127
default:
105128
return new Response("Hi from Container DO");
106129
}
@@ -126,6 +149,23 @@ for (const source of imageSource) {
126149
const { createServer } = require("http");
127150
128151
const server = createServer(function (req, res) {
152+
if (req.url === "/intercept") {
153+
const targetHost = req.headers["x-host"] || "11.0.0.1";
154+
fetch("http://" + targetHost)
155+
.then(function (result) { return result.text(); })
156+
.then(function (body) {
157+
res.writeHead(200);
158+
res.write(body);
159+
res.end();
160+
})
161+
.catch(function (err) {
162+
res.writeHead(500);
163+
res.write(targetHost + " " + err.message);
164+
res.end();
165+
});
166+
return;
167+
}
168+
129169
res.writeHead(200, { "Content-Type": "text/plain" });
130170
res.write("Hello World! Have an env var! " + process.env.MESSAGE);
131171
res.end();
@@ -241,6 +281,29 @@ for (const source of imageSource) {
241281
{ timeout: 5_000 }
242282
);
243283

284+
// Set up egress HTTP interception so the container can call back to the worker
285+
response = await fetch(`${ready.url}/setup-intercept`, {
286+
signal: AbortSignal.timeout(5_000),
287+
headers: { "MF-Disable-Pretty-Error": "true" },
288+
});
289+
text = await response.text();
290+
expect(response.status).toBe(200);
291+
expect(text).toBe("Intercept setup done");
292+
293+
// Fetch through the container's /intercept route which curls back to the worker
294+
await vi.waitFor(
295+
async () => {
296+
response = await fetch(`${ready.url}/fetch-intercept`, {
297+
signal: AbortSignal.timeout(5_000),
298+
headers: { "MF-Disable-Pretty-Error": "true" },
299+
});
300+
text = await response.text();
301+
expect(response.status).toBe(200);
302+
expect(text).toBe("hello from worker");
303+
},
304+
{ timeout: 10_000 }
305+
);
306+
244307
// Check that a container is running using `docker ps`
245308
const ids = getContainerIds("e2econtainer");
246309
expect(ids.length).toBe(1);

0 commit comments

Comments
 (0)