Skip to content

Commit c8cdb48

Browse files
committed
add containers build to build output
1 parent da31e23 commit c8cdb48

23 files changed

Lines changed: 1023 additions & 209 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
"type:tests": "tsc -p ./tests/tsconfig.json"
3434
},
3535
"dependencies": {
36+
"@cloudflare/build-output-utils": "workspace:*",
3637
"@cloudflare/cli-shared-helpers": "workspace:*",
38+
"@cloudflare/config": "workspace:*",
3739
"@cloudflare/workers-utils": "workspace:*"
3840
},
3941
"devDependencies": {
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import crypto from "node:crypto";
2+
import path from "node:path";
3+
import {
4+
getContainersDir,
5+
writeContainerConfig,
6+
} from "@cloudflare/build-output-utils";
7+
import { removeDir } from "@cloudflare/workers-utils";
8+
import { UserError } from "@cloudflare/workers-utils/errors";
9+
import { cleanupBuiltImages, startContainerBuild } from "./build";
10+
import { verifyDockerInstalled } from "./utils";
11+
import type { BuiltImage } from "./build";
12+
import type { WriteContainerConfigOptions } from "@cloudflare/build-output-utils";
13+
import type {
14+
ParsedConfigExports,
15+
ParsedInputContainerConfig,
16+
ParsedOutputContainerConfig,
17+
} from "@cloudflare/config";
18+
19+
/**
20+
* Builds and writes the Container portion of the Build Output Specification.
21+
*
22+
* Registry references pass through unchanged. Locally built tags are retained
23+
* on success so a later deploy can resolve each emitted `localReference`.
24+
*
25+
* @param options - The validated config exports and Docker build environment.
26+
*/
27+
export async function buildAndWriteContainerOutput(options: {
28+
config: ParsedConfigExports;
29+
root: string;
30+
pathToDocker: string;
31+
}): Promise<void> {
32+
const containers = Object.entries(options.config).filter(
33+
(entry): entry is [string, ParsedInputContainerConfig] =>
34+
entry[1]?.type === "container"
35+
);
36+
const dockerfileCount = countDockerfiles(containers);
37+
if (dockerfileCount > 0) {
38+
await verifyDockerInstalled({
39+
dockerPath: options.pathToDocker,
40+
operation: "building the project",
41+
imageNoun:
42+
dockerfileCount === 1
43+
? "the configured image"
44+
: "the configured images",
45+
});
46+
}
47+
48+
const builtImages: BuiltImage[] = [];
49+
let outputConfigs: WriteContainerConfigOptions[];
50+
try {
51+
outputConfigs = [];
52+
for (const [directoryName, config] of containers) {
53+
outputConfigs.push({
54+
root: options.root,
55+
directoryName,
56+
config: await buildContainerConfig(
57+
config,
58+
options.root,
59+
options.pathToDocker,
60+
builtImages
61+
),
62+
});
63+
}
64+
} catch (error) {
65+
await cleanupBuiltImages(builtImages, options.pathToDocker);
66+
throwBuildError(error);
67+
}
68+
69+
try {
70+
for (const outputConfig of outputConfigs) {
71+
await writeContainerConfig(outputConfig);
72+
}
73+
} catch (error) {
74+
await Promise.all([
75+
removeDir(getContainersDir(options.root)),
76+
cleanupBuiltImages(builtImages, options.pathToDocker),
77+
]);
78+
throw error;
79+
}
80+
}
81+
82+
async function buildContainerConfig(
83+
config: ParsedInputContainerConfig,
84+
root: string,
85+
pathToDocker: string,
86+
builtImages: BuiltImage[]
87+
): Promise<ParsedOutputContainerConfig> {
88+
if (config.schedulingPolicy === "durable-object") {
89+
const images: NonNullable<
90+
Extract<
91+
ParsedOutputContainerConfig,
92+
{ schedulingPolicy: "durable-object" }
93+
>["images"]
94+
> = {};
95+
for (const [imageName, image] of Object.entries(config.images ?? {})) {
96+
images[imageName] = await buildContainerImage(
97+
image,
98+
sanitizeRepositoryName(`${config.name}-${imageName}`),
99+
root,
100+
pathToDocker,
101+
builtImages
102+
);
103+
}
104+
return {
105+
...config,
106+
images: config.images === undefined ? undefined : images,
107+
};
108+
}
109+
110+
return {
111+
...config,
112+
image: await buildContainerImage(
113+
config.image,
114+
sanitizeRepositoryName(config.name),
115+
root,
116+
pathToDocker,
117+
builtImages
118+
),
119+
};
120+
}
121+
122+
async function buildContainerImage(
123+
image: Extract<ParsedInputContainerConfig, { image: unknown }>["image"],
124+
repositoryName: string,
125+
root: string,
126+
pathToDocker: string,
127+
builtImages: BuiltImage[]
128+
): Promise<Extract<ParsedOutputContainerConfig, { image: unknown }>["image"]> {
129+
if ("reference" in image) {
130+
return { reference: image.reference };
131+
}
132+
133+
const pathToDockerfile = path.resolve(root, image.dockerfile);
134+
const localTag = `${repositoryName}:wrangler-${crypto.randomUUID()}`;
135+
const build = await startContainerBuild({
136+
build: {
137+
tag: localTag,
138+
pathToDockerfile,
139+
buildContext:
140+
image.buildContext === undefined
141+
? path.dirname(pathToDockerfile)
142+
: path.resolve(root, image.buildContext),
143+
args: image.buildVars,
144+
platform: "linux/amd64",
145+
},
146+
pathToDocker,
147+
verifyDockerIsRunning: false,
148+
});
149+
await build.ready;
150+
151+
builtImages.push({ localTag });
152+
return { localReference: localTag };
153+
}
154+
155+
function countDockerfiles(
156+
containers: [string, ParsedInputContainerConfig][]
157+
): number {
158+
let count = 0;
159+
for (const [, config] of containers) {
160+
if (config.schedulingPolicy === "durable-object") {
161+
count += Object.values(config.images ?? {}).filter(
162+
(image) => "dockerfile" in image
163+
).length;
164+
} else if ("dockerfile" in config.image) {
165+
count++;
166+
}
167+
}
168+
return count;
169+
}
170+
171+
function sanitizeRepositoryName(value: string): string {
172+
return value
173+
.toLowerCase()
174+
.replace(/[^a-z0-9._-]+/g, "-")
175+
.replace(/^-+|-+$/g, "");
176+
}
177+
178+
function throwBuildError(error: unknown): never {
179+
if (error instanceof Error) {
180+
throw new UserError(error.message, {
181+
cause: error,
182+
telemetryMessage: "container build image operation failed",
183+
});
184+
}
185+
throw new UserError("An unknown error occurred", {
186+
telemetryMessage: "container build unknown error",
187+
});
188+
}

packages/containers-shared/src/build.ts

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -513,59 +513,6 @@ async function checkImagePlatform(
513513
}
514514
}
515515

516-
/**
517-
* Builds a Docker image and optionally pushes it to the Cloudflare managed
518-
* registry.
519-
*
520-
* @param args - Build arguments including tag, Dockerfile path, build context, and platform.
521-
* @param pathToDocker - Path to the Docker CLI executable.
522-
* @param push - Whether to push the built image to the remote registry.
523-
* @param containerConfig - Optional container configuration for limit validation.
524-
* @param verifyDockerIsRunning - Whether to verify Docker before building.
525-
* @param complianceConfig - Compliance configuration used to select the managed registry.
526-
* @returns An {@link ImageRef} describing the built or pushed image.
527-
*/
528-
export async function buildAndMaybePush(
529-
args: BuildArgs,
530-
pathToDocker: string,
531-
push: boolean,
532-
containerConfig?: DockerfileContainerConfig,
533-
verifyDockerIsRunning?: boolean,
534-
complianceConfig?: ComplianceConfig
535-
): Promise<ImageRef> {
536-
try {
537-
const build = await startContainerBuild({
538-
pathToDocker,
539-
verifyDockerIsRunning,
540-
build: args,
541-
});
542-
await build.ready;
543-
544-
if (!push) {
545-
return { newTag: args.tag };
546-
}
547-
548-
return await pushImageIfChanged({
549-
pathToDocker,
550-
sourceTag: args.tag,
551-
targetTag: args.tag,
552-
containerConfig,
553-
complianceConfig,
554-
cleanupSourceTag: true,
555-
});
556-
} catch (error) {
557-
if (error instanceof Error) {
558-
throw new UserError(error.message, {
559-
cause: error,
560-
telemetryMessage: "container build image operation failed",
561-
});
562-
}
563-
throw new UserError("An unknown error occurred", {
564-
telemetryMessage: "container build unknown error",
565-
});
566-
}
567-
}
568-
569516
async function buildContainerImage(
570517
containerConfig: DockerfileContainerConfig,
571518
pathToDocker: string,

0 commit comments

Comments
 (0)