diff --git a/.changeset/early-grapes-care.md b/.changeset/early-grapes-care.md new file mode 100644 index 000000000000..4f5a5ec80be5 --- /dev/null +++ b/.changeset/early-grapes-care.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Add build and push helper sub-commands under the cloudchamber command. diff --git a/packages/wrangler/src/__tests__/cloudchamber/build.test.ts b/packages/wrangler/src/__tests__/cloudchamber/build.test.ts new file mode 100644 index 000000000000..f48e1e31ade2 --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/build.test.ts @@ -0,0 +1,45 @@ +import { constructBuildCommand } from "../../cloudchamber/build"; + +describe("cloudchamber build", () => { + describe("build command generation", () => { + it("should work with no build command set", async () => { + const bc = await constructBuildCommand({ + imageTag: "test-registry/no-bc:v1", + pathToDockerfile: "bogus/path", + }); + expect(bc).toEqual( + "docker build -t registry.cloudchamber.cfdata.org/test-registry/no-bc:v1 --platform linux/amd64 bogus/path" + ); + }); + + it("should error if dockerfile provided without a tag", async () => { + await expect( + constructBuildCommand({ + pathToDockerfile: "bogus/path", + }) + ).rejects.toThrowError(); + }); + + it("should respect a custom path to docker", async () => { + const bc = await constructBuildCommand({ + pathToDocker: "/my/special/path/docker", + imageTag: "test-registry/no-bc:v1", + pathToDockerfile: "bogus/path", + }); + expect(bc).toEqual( + "/my/special/path/docker build -t registry.cloudchamber.cfdata.org/test-registry/no-bc:v1 --platform linux/amd64 bogus/path" + ); + }); + + it("should respect passed in platform", async () => { + const bc = await constructBuildCommand({ + imageTag: "test-registry/no-bc:v1", + pathToDockerfile: "bogus/path", + platform: "linux/arm64", + }); + expect(bc).toEqual( + "docker build -t registry.cloudchamber.cfdata.org/test-registry/no-bc:v1 --platform linux/arm64 bogus/path" + ); + }); + }); +}); diff --git a/packages/wrangler/src/cloudchamber/build.ts b/packages/wrangler/src/cloudchamber/build.ts new file mode 100644 index 000000000000..c494cbc85e4d --- /dev/null +++ b/packages/wrangler/src/cloudchamber/build.ts @@ -0,0 +1,209 @@ +import { spawn } from "child_process"; +import { logRaw } from "@cloudflare/cli"; +import { ImageRegistriesService } from "./client"; +import type { Config } from "../config"; +import type { + CommonYargsArgvJSON, + StrictYargsOptionsToInterfaceJSON, +} from "../yargs-types"; +import type { ImageRegistryPermissions } from "./client"; + +// default cloudflare managed registry +const domain = "registry.cloudchamber.cfdata.org"; + +export async function dockerLoginManagedRegistry(options: { + pathToDocker?: string; +}) { + const dockerPath = options.pathToDocker ?? "docker"; + const expirationMinutes = 15; + + await ImageRegistriesService.generateImageRegistryCredentials(domain, { + expiration_minutes: expirationMinutes, + permissions: ["push"] as ImageRegistryPermissions[], + }).then(async (credentials) => { + const child = spawn( + dockerPath, + ["login", "--password-stdin", "--username", "v1", domain], + { stdio: ["pipe", "inherit", "inherit"] } + ).on("error", (err) => { + throw err; + }); + child.stdin.write(credentials.password); + child.stdin.end(); + await new Promise((resolve) => { + child.on("close", resolve); + }); + }); +} + +export async function constructBuildCommand(options: { + imageTag?: string; + pathToDocker?: string; + pathToDockerfile?: string; + platform?: string; +}) { + // require a tag if we provide dockerfile + if ( + typeof options.pathToDockerfile !== "undefined" && + options.pathToDockerfile !== "" && + (typeof options.imageTag === "undefined" || options.imageTag === "") + ) { + throw new Error("must provide an image tag if providing a docker file"); + } + const dockerFilePath = options.pathToDockerfile; + const dockerPath = options.pathToDocker ?? "docker"; + const imageTag = domain + "/" + options.imageTag; + const platform = options.platform ? options.platform : "linux/amd64"; + const defaultBuildCommand = [ + dockerPath, + "build", + "-t", + imageTag, + "--platform", + platform, + dockerFilePath, + ].join(" "); + + return defaultBuildCommand; +} + +// Function for building +export async function dockerBuild(options: { buildCmd: string }) { + const buildCmd = options.buildCmd.split(" ").slice(1); + const buildExec = options.buildCmd.split(" ").shift(); + const child = spawn(String(buildExec), buildCmd, { stdio: "inherit" }).on( + "error", + (err) => { + throw err; + } + ); + await new Promise((resolve) => { + child.on("close", resolve); + }); +} + +async function tagImage(original: string, newTag: string, dockerPath: string) { + const child = spawn(dockerPath, ["tag", original, newTag]).on( + "error", + (err) => { + throw err; + } + ); + await new Promise((resolve) => { + child.on("close", resolve); + }); +} + +export async function push(options: { + imageTag?: string; + pathToDocker?: string; +}) { + if (typeof options.imageTag === "undefined") { + throw new Error("Must provide an image tag when pushing"); + } + // TODO: handle non-managed registry? + const imageTag = domain + "/" + options.imageTag; + const dockerPath = options.pathToDocker ?? "docker"; + await tagImage(options.imageTag, imageTag, dockerPath); + const child = spawn(dockerPath, ["image", "push", imageTag], { + stdio: "inherit", + }).on("error", (err) => { + throw err; + }); + await new Promise((resolve) => { + child.on("close", resolve); + }); +} + +export function buildYargs(yargs: CommonYargsArgvJSON) { + return yargs + .positional("PATH", { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }) + .option("tag", { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }) + .option("path-to-docker", { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }) + .option("push", { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }) + .option("platform", { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + }); +} + +export function pushYargs(yargs: CommonYargsArgvJSON) { + return yargs + .option("path-to-docker", { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }) + .positional("TAG", { type: "string", demandOption: true }); +} + +export async function buildCommand( + args: StrictYargsOptionsToInterfaceJSON, + _: Config +) { + try { + await constructBuildCommand({ + imageTag: args.tag, + pathToDockerfile: args.PATH, + pathToDocker: args.pathToDocker, + }) + .then(async (bc) => dockerBuild({ buildCmd: bc })) + .then(async () => { + if (args.push) { + await dockerLoginManagedRegistry({ + pathToDocker: args.pathToDocker, + }).then(async () => { + await push({ imageTag: args.tag }); + }); + } + }); + } catch (error) { + if (error instanceof Error) { + logRaw(error.message); + } else { + logRaw("An unknown error occurred"); + } + } +} + +export async function pushCommand( + args: StrictYargsOptionsToInterfaceJSON, + _: Config +) { + try { + await dockerLoginManagedRegistry({ + pathToDocker: args.pathToDocker, + }).then(async () => { + await push({ imageTag: args.TAG }); + }); + } catch (error) { + if (error instanceof Error) { + logRaw(error.message); + } else { + logRaw("An unknown error occurred"); + } + } +} diff --git a/packages/wrangler/src/cloudchamber/index.ts b/packages/wrangler/src/cloudchamber/index.ts index ab7084edeaf7..92167d7fc4da 100644 --- a/packages/wrangler/src/cloudchamber/index.ts +++ b/packages/wrangler/src/cloudchamber/index.ts @@ -1,4 +1,5 @@ import { applyCommand, applyCommandOptionalYargs } from "./apply"; +import { buildCommand, buildYargs, pushCommand, pushYargs } from "./build"; import { handleFailure } from "./common"; import { createCommand, createCommandOptionalYargs } from "./create"; import { curlCommand, yargsCurl } from "./curl"; @@ -68,5 +69,17 @@ export const cloudchamber = ( "apply the changes in the container applications to deploy", (args) => applyCommandOptionalYargs(args), (args) => handleFailure(applyCommand)(args) + ) + .command( + "build [PATH]", + "build a dockerfile", + (args) => buildYargs(args), + (args) => handleFailure(buildCommand)(args) + ) + .command( + "push [TAG]", + "push a tagged image to a Cloudflare managed registry, which is automatically integrated with your account", + (args) => pushYargs(args), + (args) => handleFailure(pushCommand)(args) ); };