Skip to content

Commit 80cd82e

Browse files
emily-shenjamesopstad
authored andcommitted
add containers build to build output
1 parent 6842066 commit 80cd82e

18 files changed

Lines changed: 891 additions & 103 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cloudflare/containers-shared": patch
3+
"@cloudflare/vite-plugin": minor
4+
"wrangler": minor
5+
---
6+
7+
Build Containers when emitting experimental Build Output
8+
9+
Wrangler and the Cloudflare Vite plugin now build Dockerfile-backed Container images when experimental Build Output is enabled. Container configs are emitted under `.cloudflare/output/v0/containers` with local image references, while existing registry references pass through unchanged.

packages/containers-shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./src/client";
22
export { request } from "./src/client/core/request";
33
export * from "./src/build";
4+
export * from "./src/build-output";
45
export * from "./src/context";
56
export * from "./src/deploy";
67
export * from "./src/diff";

packages/containers-shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
},
3535
"dependencies": {
3636
"@cloudflare/cli-shared-helpers": "workspace:*",
37+
"@cloudflare/config": "workspace:*",
3738
"@cloudflare/workers-utils": "workspace:*"
3839
},
3940
"devDependencies": {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import crypto from "node:crypto";
2+
import path from "node:path";
3+
import { buildAndMaybePush, cleanupBuiltImages } from "./build";
4+
import { verifyDockerInstalled } from "./utils";
5+
import type { BuiltImage } from "./build";
6+
import type {
7+
ParsedInputContainerConfig,
8+
ParsedOutputContainerConfig,
9+
} from "@cloudflare/config";
10+
11+
/** An input Container config paired with its Build Output directory name. */
12+
export interface NamedInputContainerConfig {
13+
directoryName: string;
14+
config: ParsedInputContainerConfig;
15+
}
16+
17+
/** An output Container config paired with its Build Output directory name. */
18+
export interface NamedOutputContainerConfig {
19+
directoryName: string;
20+
config: ParsedOutputContainerConfig;
21+
}
22+
23+
/** Built Container output configs and the local image tags they reference. */
24+
export interface BuildOutputContainerConfigsResult {
25+
containers: NamedOutputContainerConfig[];
26+
builtImages: BuiltImage[];
27+
}
28+
29+
type InputContainerImage = Extract<
30+
ParsedInputContainerConfig,
31+
{ image: unknown }
32+
>["image"];
33+
34+
type OutputContainerImage = Extract<
35+
ParsedOutputContainerConfig,
36+
{ image: unknown }
37+
>["image"];
38+
39+
/**
40+
* Builds Dockerfile-backed images referenced by Build Output Container configs.
41+
*
42+
* Registry references pass through unchanged. Locally built tags are retained
43+
* on success so a later deploy can resolve each emitted `localReference`.
44+
*
45+
* @param options - The Container configs and Docker build environment.
46+
* @returns Output-schema Container configs and the local tags built for them.
47+
*/
48+
export async function buildOutputContainerConfigs(options: {
49+
containers: NamedInputContainerConfig[];
50+
root: string;
51+
pathToDocker: string;
52+
}): Promise<BuildOutputContainerConfigsResult> {
53+
const dockerfileCount = countDockerfiles(options.containers);
54+
if (dockerfileCount > 0) {
55+
await verifyDockerInstalled({
56+
dockerPath: options.pathToDocker,
57+
operation: "building the project",
58+
imageNoun:
59+
dockerfileCount === 1
60+
? "the configured image"
61+
: "the configured images",
62+
});
63+
}
64+
65+
const builtImages: BuiltImage[] = [];
66+
try {
67+
const containers: NamedOutputContainerConfig[] = [];
68+
for (const container of options.containers) {
69+
containers.push({
70+
directoryName: container.directoryName,
71+
config: await buildContainerConfig(
72+
container.config,
73+
options.root,
74+
options.pathToDocker,
75+
builtImages
76+
),
77+
});
78+
}
79+
return { containers, builtImages };
80+
} catch (error) {
81+
await cleanupBuiltImages(builtImages, options.pathToDocker);
82+
throw error;
83+
}
84+
}
85+
86+
async function buildContainerConfig(
87+
config: ParsedInputContainerConfig,
88+
root: string,
89+
pathToDocker: string,
90+
builtImages: BuiltImage[]
91+
): Promise<ParsedOutputContainerConfig> {
92+
if (config.schedulingPolicy === "durable-object") {
93+
let images: Record<string, OutputContainerImage> | undefined;
94+
if (config.images) {
95+
images = {};
96+
for (const [imageName, image] of Object.entries(config.images)) {
97+
images[imageName] = await buildContainerImage(
98+
image,
99+
sanitizeRepositoryName(`${config.name}-${imageName}`),
100+
root,
101+
pathToDocker,
102+
builtImages
103+
);
104+
}
105+
}
106+
return { ...config, images };
107+
}
108+
109+
return {
110+
...config,
111+
image: await buildContainerImage(
112+
config.image,
113+
config.name.toLowerCase(),
114+
root,
115+
pathToDocker,
116+
builtImages
117+
),
118+
};
119+
}
120+
121+
async function buildContainerImage(
122+
image: InputContainerImage,
123+
repositoryName: string,
124+
root: string,
125+
pathToDocker: string,
126+
builtImages: BuiltImage[]
127+
): Promise<OutputContainerImage> {
128+
if ("reference" in image) {
129+
return { reference: image.reference };
130+
}
131+
132+
const pathToDockerfile = path.resolve(root, image.dockerfile);
133+
const localTag = `${repositoryName}:wrangler-${crypto.randomUUID()}`;
134+
const imageRef = await buildAndMaybePush(
135+
{
136+
tag: localTag,
137+
pathToDockerfile,
138+
buildContext:
139+
image.buildContext === undefined
140+
? path.dirname(pathToDockerfile)
141+
: path.resolve(root, image.buildContext),
142+
args: image.buildVars,
143+
platform: "linux/amd64",
144+
},
145+
pathToDocker,
146+
false,
147+
undefined,
148+
false
149+
);
150+
if (!("newTag" in imageRef)) {
151+
throw new Error("Expected a locally built Container image tag.");
152+
}
153+
154+
builtImages.push({ localTag: imageRef.newTag });
155+
return { localReference: imageRef.newTag };
156+
}
157+
158+
function countDockerfiles(containers: NamedInputContainerConfig[]): number {
159+
let count = 0;
160+
for (const { config } of containers) {
161+
if (config.schedulingPolicy === "durable-object") {
162+
count += Object.values(config.images ?? {}).filter(
163+
(image) => "dockerfile" in image
164+
).length;
165+
} else if ("dockerfile" in config.image) {
166+
count++;
167+
}
168+
}
169+
return count;
170+
}
171+
172+
function sanitizeRepositoryName(value: string): string {
173+
return value
174+
.toLowerCase()
175+
.replace(/[^a-z0-9._-]+/g, "-")
176+
.replace(/^-+|-+$/g, "");
177+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import crypto from "node:crypto";
2+
import path from "node:path";
3+
import { InputContainerSchema } from "@cloudflare/config";
4+
import { beforeEach, describe, it, vi } from "vitest";
5+
import {
6+
buildAndMaybePush,
7+
buildOutputContainerConfigs,
8+
cleanupBuiltImages,
9+
verifyDockerInstalled,
10+
} from "../index";
11+
12+
vi.mock("../src/build", async (importOriginal) => ({
13+
...(await importOriginal()),
14+
buildAndMaybePush: vi.fn(),
15+
cleanupBuiltImages: vi.fn(),
16+
}));
17+
vi.mock("../src/utils", async (importOriginal) => ({
18+
...(await importOriginal()),
19+
verifyDockerInstalled: vi.fn(),
20+
}));
21+
22+
const UUIDS: `${string}-${string}-${string}-${string}-${string}`[] = [
23+
"11111111-1111-4111-8111-111111111111",
24+
"22222222-2222-4222-8222-222222222222",
25+
];
26+
27+
describe("buildOutputContainerConfigs", () => {
28+
beforeEach(() => {
29+
vi.restoreAllMocks();
30+
vi.clearAllMocks();
31+
vi.spyOn(crypto, "randomUUID")
32+
.mockReturnValueOnce(UUIDS[0])
33+
.mockReturnValueOnce(UUIDS[1]);
34+
vi.mocked(buildAndMaybePush).mockImplementation(async (args) => ({
35+
newTag: args.tag,
36+
}));
37+
});
38+
39+
it("preserves remote references without invoking Docker", async ({
40+
expect,
41+
}) => {
42+
const config = InputContainerSchema.parse({
43+
type: "container",
44+
name: "remote-container",
45+
image: { reference: "registry.example.com/app:latest" },
46+
});
47+
48+
await expect(
49+
buildOutputContainerConfigs({
50+
containers: [{ directoryName: "remote", config }],
51+
root: "/project",
52+
pathToDocker: "docker",
53+
})
54+
).resolves.toEqual({
55+
containers: [
56+
{
57+
directoryName: "remote",
58+
config: {
59+
...config,
60+
image: { reference: "registry.example.com/app:latest" },
61+
},
62+
},
63+
],
64+
builtImages: [],
65+
});
66+
expect(verifyDockerInstalled).not.toHaveBeenCalled();
67+
expect(buildAndMaybePush).not.toHaveBeenCalled();
68+
});
69+
70+
it("builds a standard Container with resolved paths and build variables", async ({
71+
expect,
72+
}) => {
73+
const config = InputContainerSchema.parse({
74+
type: "container",
75+
name: "My-Container",
76+
image: {
77+
dockerfile: "./container/Dockerfile",
78+
buildContext: "./container",
79+
buildVars: { VERSION: "1" },
80+
},
81+
});
82+
83+
const result = await buildOutputContainerConfigs({
84+
containers: [{ directoryName: "app", config }],
85+
root: "/project",
86+
pathToDocker: "/usr/bin/docker",
87+
});
88+
89+
expect(verifyDockerInstalled).toHaveBeenCalledOnce();
90+
expect(buildAndMaybePush).toHaveBeenCalledWith(
91+
{
92+
tag: `my-container:wrangler-${UUIDS[0]}`,
93+
pathToDockerfile: path.resolve("/project", "container/Dockerfile"),
94+
buildContext: path.resolve("/project", "container"),
95+
args: { VERSION: "1" },
96+
platform: "linux/amd64",
97+
},
98+
"/usr/bin/docker",
99+
false,
100+
undefined,
101+
false
102+
);
103+
expect(result.containers[0]?.config).toEqual({
104+
...config,
105+
image: { localReference: `my-container:wrangler-${UUIDS[0]}` },
106+
});
107+
expect(result.builtImages).toEqual([
108+
{ localTag: `my-container:wrangler-${UUIDS[0]}` },
109+
]);
110+
});
111+
112+
it("builds each Durable Object named image with a sanitized repository", async ({
113+
expect,
114+
}) => {
115+
const config = InputContainerSchema.parse({
116+
type: "container",
117+
name: "Session Container",
118+
schedulingPolicy: "durable-object",
119+
images: {
120+
"Primary Image": { dockerfile: "./primary/Dockerfile" },
121+
fallback: { reference: "registry.example.com/fallback:latest" },
122+
worker: { dockerfile: "./worker/Dockerfile" },
123+
},
124+
});
125+
126+
const result = await buildOutputContainerConfigs({
127+
containers: [{ directoryName: "sessions", config }],
128+
root: "/project",
129+
pathToDocker: "docker",
130+
});
131+
132+
expect(verifyDockerInstalled).toHaveBeenCalledOnce();
133+
expect(buildAndMaybePush).toHaveBeenCalledTimes(2);
134+
expect(result.containers[0]?.config).toEqual({
135+
...config,
136+
images: {
137+
"Primary Image": {
138+
localReference: `session-container-primary-image:wrangler-${UUIDS[0]}`,
139+
},
140+
fallback: { reference: "registry.example.com/fallback:latest" },
141+
worker: {
142+
localReference: `session-container-worker:wrangler-${UUIDS[1]}`,
143+
},
144+
},
145+
});
146+
});
147+
148+
it("cleans images built before a later build fails", async ({ expect }) => {
149+
const first = InputContainerSchema.parse({
150+
type: "container",
151+
name: "first",
152+
image: { dockerfile: "./first/Dockerfile" },
153+
});
154+
const second = InputContainerSchema.parse({
155+
type: "container",
156+
name: "second",
157+
image: { dockerfile: "./second/Dockerfile" },
158+
});
159+
vi.mocked(buildAndMaybePush)
160+
.mockResolvedValueOnce({ newTag: `first:wrangler-${UUIDS[0]}` })
161+
.mockRejectedValueOnce(new Error("build failed"));
162+
163+
await expect(
164+
buildOutputContainerConfigs({
165+
containers: [
166+
{ directoryName: "first", config: first },
167+
{ directoryName: "second", config: second },
168+
],
169+
root: "/project",
170+
pathToDocker: "docker",
171+
})
172+
).rejects.toThrow("build failed");
173+
expect(cleanupBuiltImages).toHaveBeenCalledWith(
174+
[{ localTag: `first:wrangler-${UUIDS[0]}` }],
175+
"docker"
176+
);
177+
});
178+
});

packages/vite-plugin-cloudflare/playground/containers/__tests__/containers.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { test, vi } from "vitest";
22
import {
33
getTextResponse,
4-
isCINonLinux,
54
isLocalWithoutDockerRunning,
65
viteTestUrl,
76
WAIT_FOR_OPTIONS,
@@ -18,7 +17,7 @@ const isDevProdTestingAccount =
1817
// We can only really run these tests on Linux, because we build our images for linux/amd64,
1918
// and github runners don't really support container virtualization in any sane way.
2019
const skipContainerTests =
21-
isCINonLinux ||
20+
process.platform !== "linux" ||
2221
// If the test is being run locally and docker is not running we just skip these tests
2322
isLocalWithoutDockerRunning;
2423

0 commit comments

Comments
 (0)