Skip to content

Commit 5162c51

Browse files
feat(containers): add support for handling images that link to the CF registry (#9596)
* add pull * update fixture for easier manual testing * changeset * pr feedback --------- Co-authored-by: emily-shen <[email protected]>
1 parent 3f478af commit 5162c51

File tree

12 files changed

+209
-65
lines changed

12 files changed

+209
-65
lines changed

.changeset/thick-icons-visit.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/containers-shared": patch
3+
"miniflare": patch
4+
"wrangler": patch
5+
---
6+
7+
add ability to pull images for containers local dev

fixtures/container-app/src/index.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,64 @@ export class Container extends DurableObject<Env> {
77
constructor(ctx: DurableObjectState, env: Env) {
88
super(ctx, env);
99
this.container = ctx.container!;
10-
void this.ctx.blockConcurrencyWhile(async () => {
11-
if (!this.container.running) this.container.start();
12-
});
1310
}
1411

1512
async fetch(req: Request) {
16-
try {
17-
return await this.container
18-
.getTcpPort(8080)
19-
.fetch(req.url.replace("https:", "http:"), req);
20-
} catch (err) {
21-
return new Response(`${this.ctx.id.toString()}: ${err.message}`, {
22-
status: 500,
23-
});
13+
const path = new URL(req.url).pathname;
14+
switch (path) {
15+
case "/status":
16+
return new Response(JSON.stringify(this.container.running));
17+
18+
case "/destroy":
19+
if (!this.container.running) {
20+
throw new Error("Container is not running.");
21+
}
22+
await this.container.destroy();
23+
return new Response(JSON.stringify(this.container.running));
24+
25+
case "/start":
26+
this.container.start({
27+
entrypoint: ["node", "app.js"],
28+
env: { A: "B", C: "D", L: "F" },
29+
enableInternet: false,
30+
});
31+
// this doesn't instantly start, so we will need to poll /fetch
32+
return new Response("Container create request sent...");
33+
34+
case "/fetch":
35+
const res = await this.container
36+
.getTcpPort(8080)
37+
// actual request doesn't matter
38+
.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
39+
return new Response(await res.text());
40+
41+
case "/destroy-with-monitor":
42+
// if (!this.container.running) {
43+
// throw new Error("Container is not running.");
44+
// }
45+
const monitor = this.container.monitor();
46+
await this.container.destroy();
47+
await monitor;
48+
return new Response("Container destroyed with monitor.");
49+
50+
default:
51+
return new Response("Hi from Container DO");
2452
}
2553
}
2654
}
2755

2856
export default {
2957
async fetch(request, env): Promise<Response> {
30-
try {
31-
return await env.CONTAINER.get(env.CONTAINER.idFromName("fetcher")).fetch(
32-
request
33-
);
34-
} catch (err) {
35-
console.error("Error fetch:", err.message);
36-
return new Response(err.message, { status: 500 });
58+
const url = new URL(request.url);
59+
if (url.pathname === "/second") {
60+
// This is a second Durable Object that can be used to test multiple DOs
61+
const id = env.CONTAINER.idFromName("second-container");
62+
const stub = env.CONTAINER.get(id);
63+
const query = url.searchParams.get("req");
64+
return stub.fetch("http://example.com/" + query);
3765
}
66+
const id = env.CONTAINER.idFromName("container");
67+
const stub = env.CONTAINER.get(id);
68+
return stub.fetch(request);
3869
},
3970
} satisfies ExportedHandler<Env>;

fixtures/container-app/wrangler.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"migrations": [
2424
{
2525
"tag": "v1",
26-
"new_classes": ["Container"],
26+
"new_sqlite_classes": ["Container"],
2727
},
2828
],
2929
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "container-app-from-registry",
3+
"main": "src/index.ts",
4+
"compatibility_date": "2025-04-03",
5+
"containers": [
6+
{
7+
"configuration": {
8+
"image": "registry.cloudflare.com/8d783f274e1f82dc46744c297b015a2f/ci-container-dont-delete:latest",
9+
},
10+
"class_name": "Container",
11+
"name": "http2",
12+
"max_instances": 2,
13+
},
14+
],
15+
"durable_objects": {
16+
"bindings": [
17+
{
18+
"class_name": "Container",
19+
"name": "CONTAINER",
20+
},
21+
],
22+
},
23+
"migrations": [
24+
{
25+
"tag": "v1",
26+
"new_sqlite_classes": ["Container"],
27+
},
28+
],
29+
}

packages/containers-shared/src/build.ts

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { spawn } from "child_process";
22
import { readFileSync } from "fs";
33
import path from "path";
4-
import { dockerImageInspect } from "./inspect";
5-
import { MF_DEV_CONTAINER_PREFIX } from "./registry";
64
import { BuildArgs, ContainerDevOptions, Logger } from "./types";
7-
import { verifyDockerInstalled } from "./utils";
85

96
export async function constructBuildCommand(
107
options: BuildArgs,
@@ -70,33 +67,7 @@ export function dockerBuild(
7067
});
7168
}
7269

73-
/**
74-
*
75-
* Builds (or pulls - TODO) the container images for local development. This
76-
* will be called before starting the local development server, and by a rebuild
77-
* hotkey during development.
78-
*
79-
* Because this runs when local dev starts, we also do some validation here,
80-
* such as checking if the Docker CLI is installed, and if the container images
81-
* expose any ports.
82-
*/
83-
export async function prepareContainerImagesForDev(
84-
dockerPath: string,
85-
containerOptions: ContainerDevOptions[]
86-
) {
87-
if (process.platform === "win32") {
88-
throw new Error(
89-
"Local development with containers is currently not supported on Windows. You should use WSL instead. You can also set `enable_containers` to false if you do not need to develop the container as part of your application."
90-
);
91-
}
92-
await verifyDockerInstalled(dockerPath);
93-
for (const options of containerOptions) {
94-
await buildContainer(dockerPath, options);
95-
await checkExposedPorts(dockerPath, options.imageTag);
96-
}
97-
}
98-
99-
async function buildContainer(
70+
export async function buildImage(
10071
dockerPath: string,
10172
options: ContainerDevOptions
10273
) {
@@ -111,16 +82,3 @@ async function buildContainer(
11182

11283
await dockerBuild(dockerPath, { buildCmd, dockerfile });
11384
}
114-
115-
async function checkExposedPorts(dockerPath: string, imageTag: string) {
116-
const output = await dockerImageInspect(dockerPath, {
117-
imageTag,
118-
formatString: "{{ len .Config.ExposedPorts }}",
119-
});
120-
if (output === "0" && process.platform !== "linux") {
121-
throw new Error(
122-
`The container "${imageTag.replace(MF_DEV_CONTAINER_PREFIX + "/", "")}" does not expose any ports.\n` +
123-
"To develop containers locally on non-Linux platforms, you must expose any ports that you call with `getTCPPort()` in your Dockerfile."
124-
);
125-
}
126-
}

packages/containers-shared/src/images.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
import { execFile } from "child_process";
2+
import { buildImage } from "./build";
3+
import { isCloudflareRegistryLink } from "./knobs";
4+
import { dockerLoginManagedRegistry } from "./login";
5+
import { ContainerDevOptions } from "./types";
6+
import {
7+
checkExposedPorts,
8+
isDockerfile,
9+
runDockerCmd,
10+
verifyDockerInstalled,
11+
} from "./utils";
212

313
// Returns a list of docker image ids matching the provided repository:[tag]
414
export async function getDockerImageDigest(
@@ -22,3 +32,55 @@ export async function getDockerImageDigest(
2232
);
2333
});
2434
}
35+
36+
export async function pullImage(
37+
dockerPath: string,
38+
options: ContainerDevOptions
39+
) {
40+
await dockerLoginManagedRegistry(dockerPath);
41+
await runDockerCmd(dockerPath, [
42+
"pull",
43+
options.image,
44+
// All containers running on our platform need to be built for amd64 architecture, but by default docker pull seems to look for an image matching the host system, so we need to specify this here
45+
"--platform",
46+
"linux/amd64",
47+
]);
48+
// re-tag image with the expected dev-formatted image tag for consistency
49+
await runDockerCmd(dockerPath, ["tag", options.image, options.imageTag]);
50+
}
51+
52+
/**
53+
*
54+
* Builds or pulls the container images for local development. This
55+
* will be called before starting the local development server, and by a rebuild
56+
* hotkey during development.
57+
*
58+
* Because this runs when local dev starts, we also do some validation here,
59+
* such as checking if the Docker CLI is installed, and if the container images
60+
* expose any ports.
61+
*/
62+
export async function prepareContainerImagesForDev(
63+
dockerPath: string,
64+
containerOptions: ContainerDevOptions[]
65+
) {
66+
if (process.platform === "win32") {
67+
throw new Error(
68+
"Local development with containers is currently not supported on Windows. You should use WSL instead. You can also set `enable_containers` to false if you do not need to develop the container part of your application."
69+
);
70+
}
71+
await verifyDockerInstalled(dockerPath);
72+
for (const options of containerOptions) {
73+
if (isDockerfile(options.image)) {
74+
await buildImage(dockerPath, options);
75+
} else {
76+
if (!isCloudflareRegistryLink(options.image)) {
77+
throw new Error(
78+
`Image "${options.image}" is a registry link but does not point to the Cloudflare container registry.\n` +
79+
`To use an existing image from another repository, see https://developers.cloudflare.com/containers/image-management/#using-existing-images`
80+
);
81+
}
82+
await pullImage(dockerPath, options);
83+
}
84+
await checkExposedPorts(dockerPath, options);
85+
}
86+
}

packages/containers-shared/src/knobs.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@ import { MF_DEV_CONTAINER_PREFIX } from "./registry";
22

33
// default cloudflare managed registry, can be overriden with the env var - CLOUDFLARE_CONTAINER_REGISTRY
44
export const getCloudflareContainerRegistry = () => {
5+
// previously defaulted to registry.cloudchamber.cfdata.org
56
return process.env.CLOUDFLARE_CONTAINER_REGISTRY ?? "registry.cloudflare.com";
67
};
78

9+
/**
10+
* Given a container image that is a registry link, this function
11+
* returns true if the link points the Cloudflare container registry
12+
* (defined as per `getCloudflareContainerRegistry` above)
13+
*/
14+
export function isCloudflareRegistryLink(image: string) {
15+
const cfRegistry = getCloudflareContainerRegistry();
16+
return image.includes(cfRegistry);
17+
}
18+
819
/** Prefixes with the cloudflare-dev namespace. The name should be the container's DO classname, and the tag a build uuid. */
920
export const getDevContainerImageName = (name: string, tag: string) => {
1021
return `${MF_DEV_CONTAINER_PREFIX}/${name.toLowerCase()}:${tag}`;

packages/containers-shared/src/login.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { ImageRegistriesService, ImageRegistryPermissions } from "./client";
33
import { getCloudflareContainerRegistry } from "./knobs";
44

55
/**
6-
* Gets push credentials for cloudflare's managed image registry
7-
* and runs `docker login`, so subsequent image pushes are authenticated
6+
* Gets push and pull credentials for Cloudflare's managed image registry
7+
* and runs `docker login`, so subsequent image pushes or pulls are
8+
* authenticated
89
*/
910
export async function dockerLoginManagedRegistry(pathToDocker: string) {
1011
// how long the credentials should be valid for
@@ -15,7 +16,10 @@ export async function dockerLoginManagedRegistry(pathToDocker: string) {
1516
getCloudflareContainerRegistry(),
1617
{
1718
expiration_minutes: expirationMinutes,
18-
permissions: ["push", "pull"] as ImageRegistryPermissions[],
19+
permissions: [
20+
ImageRegistryPermissions.PUSH,
21+
ImageRegistryPermissions.PULL,
22+
],
1923
}
2024
);
2125

packages/containers-shared/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export type ContainerDevOptions = {
2525
image: string;
2626
/** formatted as cloudflare-dev/workername-DOclassname:build-id */
2727
imageTag: string;
28+
/** container's DO class name */
29+
class_name: string;
2830
imageBuildContext?: string;
2931
/** build time args */
3032
args?: Record<string, string>;

packages/containers-shared/src/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { execFile, spawn, StdioOptions } from "child_process";
22
import { existsSync, statSync } from "fs";
3+
import { dockerImageInspect } from "./inspect";
4+
import { ContainerDevOptions } from "./types";
35

46
/** helper for simple docker command call that don't require any io handling */
57
export const runDockerCmd = async (
@@ -153,3 +155,27 @@ const getContainerIdsFromImage = async (
153155
]);
154156
return output.split("\n").filter((line) => line.trim());
155157
};
158+
159+
/**
160+
* While all ports are exposed in prod, a limitation of local dev with docker is that
161+
* non-linux users will have to manually expose ports in their Dockerfile.
162+
* We want to fail early and clearly if a user tries to develop with a container
163+
* that has no ports exposed and is definitely not accessible.
164+
*
165+
* (A user could still use `getTCPPort()` on a port that is not exposed, but we leave that error for runtime.)
166+
*/
167+
export async function checkExposedPorts(
168+
dockerPath: string,
169+
options: ContainerDevOptions
170+
) {
171+
const output = await dockerImageInspect(dockerPath, {
172+
imageTag: options.imageTag,
173+
formatString: "{{ len .Config.ExposedPorts }}",
174+
});
175+
if (output === "0" && process.platform !== "linux") {
176+
throw new Error(
177+
`The container "${options.class_name}" does not expose any ports.\n` +
178+
"To develop containers locally on non-Linux platforms, you must expose any ports that you call with `getTCPPort()` in your Dockerfile."
179+
);
180+
}
181+
}

0 commit comments

Comments
 (0)