Skip to content

Commit 59c7c8e

Browse files
authoredFeb 3, 2025··
Add build and push subcommands to cloudchamber (#7378)
1 parent 4ecabf1 commit 59c7c8e

File tree

4 files changed

+272
-0
lines changed

4 files changed

+272
-0
lines changed
 

‎.changeset/early-grapes-care.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add build and push helper sub-commands under the cloudchamber command.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { constructBuildCommand } from "../../cloudchamber/build";
2+
3+
describe("cloudchamber build", () => {
4+
describe("build command generation", () => {
5+
it("should work with no build command set", async () => {
6+
const bc = await constructBuildCommand({
7+
imageTag: "test-registry/no-bc:v1",
8+
pathToDockerfile: "bogus/path",
9+
});
10+
expect(bc).toEqual(
11+
"docker build -t registry.cloudchamber.cfdata.org/test-registry/no-bc:v1 --platform linux/amd64 bogus/path"
12+
);
13+
});
14+
15+
it("should error if dockerfile provided without a tag", async () => {
16+
await expect(
17+
constructBuildCommand({
18+
pathToDockerfile: "bogus/path",
19+
})
20+
).rejects.toThrowError();
21+
});
22+
23+
it("should respect a custom path to docker", async () => {
24+
const bc = await constructBuildCommand({
25+
pathToDocker: "/my/special/path/docker",
26+
imageTag: "test-registry/no-bc:v1",
27+
pathToDockerfile: "bogus/path",
28+
});
29+
expect(bc).toEqual(
30+
"/my/special/path/docker build -t registry.cloudchamber.cfdata.org/test-registry/no-bc:v1 --platform linux/amd64 bogus/path"
31+
);
32+
});
33+
34+
it("should respect passed in platform", async () => {
35+
const bc = await constructBuildCommand({
36+
imageTag: "test-registry/no-bc:v1",
37+
pathToDockerfile: "bogus/path",
38+
platform: "linux/arm64",
39+
});
40+
expect(bc).toEqual(
41+
"docker build -t registry.cloudchamber.cfdata.org/test-registry/no-bc:v1 --platform linux/arm64 bogus/path"
42+
);
43+
});
44+
});
45+
});
+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { spawn } from "child_process";
2+
import { logRaw } from "@cloudflare/cli";
3+
import { ImageRegistriesService } from "./client";
4+
import type { Config } from "../config";
5+
import type {
6+
CommonYargsArgvJSON,
7+
StrictYargsOptionsToInterfaceJSON,
8+
} from "../yargs-types";
9+
import type { ImageRegistryPermissions } from "./client";
10+
11+
// default cloudflare managed registry
12+
const domain = "registry.cloudchamber.cfdata.org";
13+
14+
export async function dockerLoginManagedRegistry(options: {
15+
pathToDocker?: string;
16+
}) {
17+
const dockerPath = options.pathToDocker ?? "docker";
18+
const expirationMinutes = 15;
19+
20+
await ImageRegistriesService.generateImageRegistryCredentials(domain, {
21+
expiration_minutes: expirationMinutes,
22+
permissions: ["push"] as ImageRegistryPermissions[],
23+
}).then(async (credentials) => {
24+
const child = spawn(
25+
dockerPath,
26+
["login", "--password-stdin", "--username", "v1", domain],
27+
{ stdio: ["pipe", "inherit", "inherit"] }
28+
).on("error", (err) => {
29+
throw err;
30+
});
31+
child.stdin.write(credentials.password);
32+
child.stdin.end();
33+
await new Promise((resolve) => {
34+
child.on("close", resolve);
35+
});
36+
});
37+
}
38+
39+
export async function constructBuildCommand(options: {
40+
imageTag?: string;
41+
pathToDocker?: string;
42+
pathToDockerfile?: string;
43+
platform?: string;
44+
}) {
45+
// require a tag if we provide dockerfile
46+
if (
47+
typeof options.pathToDockerfile !== "undefined" &&
48+
options.pathToDockerfile !== "" &&
49+
(typeof options.imageTag === "undefined" || options.imageTag === "")
50+
) {
51+
throw new Error("must provide an image tag if providing a docker file");
52+
}
53+
const dockerFilePath = options.pathToDockerfile;
54+
const dockerPath = options.pathToDocker ?? "docker";
55+
const imageTag = domain + "/" + options.imageTag;
56+
const platform = options.platform ? options.platform : "linux/amd64";
57+
const defaultBuildCommand = [
58+
dockerPath,
59+
"build",
60+
"-t",
61+
imageTag,
62+
"--platform",
63+
platform,
64+
dockerFilePath,
65+
].join(" ");
66+
67+
return defaultBuildCommand;
68+
}
69+
70+
// Function for building
71+
export async function dockerBuild(options: { buildCmd: string }) {
72+
const buildCmd = options.buildCmd.split(" ").slice(1);
73+
const buildExec = options.buildCmd.split(" ").shift();
74+
const child = spawn(String(buildExec), buildCmd, { stdio: "inherit" }).on(
75+
"error",
76+
(err) => {
77+
throw err;
78+
}
79+
);
80+
await new Promise((resolve) => {
81+
child.on("close", resolve);
82+
});
83+
}
84+
85+
async function tagImage(original: string, newTag: string, dockerPath: string) {
86+
const child = spawn(dockerPath, ["tag", original, newTag]).on(
87+
"error",
88+
(err) => {
89+
throw err;
90+
}
91+
);
92+
await new Promise((resolve) => {
93+
child.on("close", resolve);
94+
});
95+
}
96+
97+
export async function push(options: {
98+
imageTag?: string;
99+
pathToDocker?: string;
100+
}) {
101+
if (typeof options.imageTag === "undefined") {
102+
throw new Error("Must provide an image tag when pushing");
103+
}
104+
// TODO: handle non-managed registry?
105+
const imageTag = domain + "/" + options.imageTag;
106+
const dockerPath = options.pathToDocker ?? "docker";
107+
await tagImage(options.imageTag, imageTag, dockerPath);
108+
const child = spawn(dockerPath, ["image", "push", imageTag], {
109+
stdio: "inherit",
110+
}).on("error", (err) => {
111+
throw err;
112+
});
113+
await new Promise((resolve) => {
114+
child.on("close", resolve);
115+
});
116+
}
117+
118+
export function buildYargs(yargs: CommonYargsArgvJSON) {
119+
return yargs
120+
.positional("PATH", {
121+
type: "string",
122+
describe: "Path for the directory containing the Dockerfile to build",
123+
demandOption: true,
124+
})
125+
.option("tag", {
126+
alias: "t",
127+
type: "string",
128+
demandOption: true,
129+
describe: 'Name and optionally a tag (format: "name:tag")',
130+
})
131+
.option("path-to-docker", {
132+
type: "string",
133+
default: "docker",
134+
describe: "Path to your docker binary if it's not on $PATH",
135+
demandOption: false,
136+
})
137+
.option("push", {
138+
alias: "p",
139+
type: "boolean",
140+
describe: "Push the built image to Cloudflare's managed registry",
141+
default: false,
142+
})
143+
.option("platform", {
144+
type: "string",
145+
default: "linux/amd64",
146+
describe:
147+
"Platform to build for. Defaults to the architecture support by Workers (linux/amd64)",
148+
demandOption: false,
149+
});
150+
}
151+
152+
export function pushYargs(yargs: CommonYargsArgvJSON) {
153+
return yargs
154+
.option("path-to-docker", {
155+
type: "string",
156+
default: "docker",
157+
describe: "Path to your docker binary if it's not on $PATH",
158+
demandOption: false,
159+
})
160+
.positional("TAG", { type: "string", demandOption: true });
161+
}
162+
163+
export async function buildCommand(
164+
args: StrictYargsOptionsToInterfaceJSON<typeof buildYargs>,
165+
_: Config
166+
) {
167+
try {
168+
await constructBuildCommand({
169+
imageTag: args.tag,
170+
pathToDockerfile: args.PATH,
171+
pathToDocker: args.pathToDocker,
172+
})
173+
.then(async (bc) => dockerBuild({ buildCmd: bc }))
174+
.then(async () => {
175+
if (args.push) {
176+
await dockerLoginManagedRegistry({
177+
pathToDocker: args.pathToDocker,
178+
}).then(async () => {
179+
await push({ imageTag: args.tag });
180+
});
181+
}
182+
});
183+
} catch (error) {
184+
if (error instanceof Error) {
185+
logRaw(error.message);
186+
} else {
187+
logRaw("An unknown error occurred");
188+
}
189+
}
190+
}
191+
192+
export async function pushCommand(
193+
args: StrictYargsOptionsToInterfaceJSON<typeof pushYargs>,
194+
_: Config
195+
) {
196+
try {
197+
await dockerLoginManagedRegistry({
198+
pathToDocker: args.pathToDocker,
199+
}).then(async () => {
200+
await push({ imageTag: args.TAG });
201+
});
202+
} catch (error) {
203+
if (error instanceof Error) {
204+
logRaw(error.message);
205+
} else {
206+
logRaw("An unknown error occurred");
207+
}
208+
}
209+
}

‎packages/wrangler/src/cloudchamber/index.ts

+13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { applyCommand, applyCommandOptionalYargs } from "./apply";
2+
import { buildCommand, buildYargs, pushCommand, pushYargs } from "./build";
23
import { handleFailure } from "./common";
34
import { createCommand, createCommandOptionalYargs } from "./create";
45
import { curlCommand, yargsCurl } from "./curl";
@@ -68,5 +69,17 @@ export const cloudchamber = (
6869
"apply the changes in the container applications to deploy",
6970
(args) => applyCommandOptionalYargs(args),
7071
(args) => handleFailure(applyCommand)(args)
72+
)
73+
.command(
74+
"build [PATH]",
75+
"build a dockerfile",
76+
(args) => buildYargs(args),
77+
(args) => handleFailure(buildCommand)(args)
78+
)
79+
.command(
80+
"push [TAG]",
81+
"push a tagged image to a Cloudflare managed registry, which is automatically integrated with your account",
82+
(args) => pushYargs(args),
83+
(args) => handleFailure(pushCommand)(args)
7184
);
7285
};

0 commit comments

Comments
 (0)
Please sign in to comment.