From 60bdd919441822b371fe2f910230f068a8c09c90 Mon Sep 17 00:00:00 2001 From: Greg Anders Date: Wed, 20 Nov 2024 16:16:38 -0600 Subject: [PATCH] Add default image for cloudchamber create Add a default image when using "wrangler cloudchamber create". This image is docker.io/cloudflare/hello-world which is a simple HTTP server that runs on Cloudflare's container platform. This commit also augments validation to both the `cloudchamber create` and `cloudchamber modify` commands to ensure the provided image contains a tag and adheres to the format in the OCI specification. --- .changeset/six-apes-pull.md | 5 ++ .../src/__tests__/cloudchamber/common.test.ts | 56 +++++++++++++++++++ .../src/__tests__/cloudchamber/create.test.ts | 16 ++++++ packages/wrangler/src/cloudchamber/common.ts | 38 +++++++++++++ packages/wrangler/src/cloudchamber/create.ts | 28 +++++++--- packages/wrangler/src/cloudchamber/modify.ts | 10 ++-- 6 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 .changeset/six-apes-pull.md create mode 100644 packages/wrangler/src/__tests__/cloudchamber/common.test.ts diff --git a/.changeset/six-apes-pull.md b/.changeset/six-apes-pull.md new file mode 100644 index 000000000000..aec03b538ec3 --- /dev/null +++ b/.changeset/six-apes-pull.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Add a default image for cloudchamber create and modify commands diff --git a/packages/wrangler/src/__tests__/cloudchamber/common.test.ts b/packages/wrangler/src/__tests__/cloudchamber/common.test.ts new file mode 100644 index 000000000000..ac63009baf44 --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/common.test.ts @@ -0,0 +1,56 @@ +import { parseImageName } from "../../cloudchamber/common"; + +describe("parseImageName", () => { + it("works", () => { + type TestCase = [ + input: string, + expected: { name?: string; tag?: string; err?: boolean }, + ]; + const cases: TestCase[] = [ + // Multiple domains + [ + "docker.io/cloudflare/hello-world:1.0", + { name: "docker.io/cloudflare/hello-world", tag: "1.0" }, + ], + + // Domain with port + [ + "localhost:7777/web:local", + { name: "localhost:7777/web", tag: "local" }, + ], + + // No domain + ["hello-world:1.0", { name: "hello-world", tag: "1.0" }], + + // With sha256 digest + ["hello/world:1.0@sha256:abcdef0123456789", { name: "hello/world", tag: "1.0" }], + + // Invalid name + ["bad image name:1", { err: true }], + + // Missing tag + ["no-tag", { err: true }], + ["no-tag:", { err: true }], + + // sha256 digest but no tag + ["no-tag@sha256:abcdef0123456789", { err: true }], + + // Invalid tag + ["no-tag::", { err: true }], + + // latest tag + ["name:latest", { err: true }], + + // Too many colons + ["registry.com:1234/foobar:4444/image:sometag", { err: true }], + ]; + + for (const c of cases) { + const [input, expected] = c; + const result = parseImageName(input); + expect(result.name).toEqual(expected.name); + expect(result.tag).toEqual(expected.tag); + expect(result.err !== undefined).toEqual(expected.err === true); + } + }); +}); diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index 9d8018f9106e..eb4077a28d5c 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -116,6 +116,22 @@ describe("cloudchamber create", () => { ); }); + it("should fail with a nice message when image is invalid", async () => { + setIsTTY(false); + setWranglerConfig({}); + await expect( + runWrangler("cloudchamber create --image hello:latest --location sfo06") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: "latest" tag is not allowed]` + ); + + await expect( + runWrangler("cloudchamber create --image hello --location sfo06") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Invalid image format: expected NAME:TAG[@DIGEST]]` + ); + }); + it("should fail with a nice message when parameters are mistyped", async () => { setIsTTY(false); fs.writeFileSync( diff --git a/packages/wrangler/src/cloudchamber/common.ts b/packages/wrangler/src/cloudchamber/common.ts index 576e83fe7f6f..5a9a455c280d 100644 --- a/packages/wrangler/src/cloudchamber/common.ts +++ b/packages/wrangler/src/cloudchamber/common.ts @@ -40,6 +40,44 @@ import type { Arg } from "@cloudflare/cli/interactive"; export type CommonCloudchamberConfiguration = { json: boolean }; +/** + * Regular expression for matching an image name. + * + * See: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pulling-manifests + */ +const imageRe = (() => { + const alphaNumeric = "[a-z0-9]+"; + const separator = "(?:\\.|_|__|-+)"; + const port = ":[0-9]+"; + const domain = `${alphaNumeric}(?:${separator}${alphaNumeric})*`; + const tag = "[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}"; + const digest = "@sha256:[A-Fa-f0-9]+"; + return new RegExp( + `^((?:${domain}(?:${port})?/)?(?:${domain}/)*(?:${domain})):(${tag})(?:${digest})?$` + ); +})(); + +/** + * Parse a container image name. + */ +export function parseImageName(value: string): { + name?: string; + tag?: string; + err?: string; +} { + const matches = value.match(imageRe); + if (matches === null || matches.length != 3) { + return { err: "Invalid image format: expected NAME:TAG[@DIGEST]" }; + } + + const [_match, name, tag] = matches; + if (tag == "latest") { + return { err: '"latest" tag is not allowed' }; + } + + return { name, tag }; +} + /** * Wrapper that parses wrangler configuration and authentication. * It also wraps exceptions and checks if they are from the RestAPI. diff --git a/packages/wrangler/src/cloudchamber/create.ts b/packages/wrangler/src/cloudchamber/create.ts index 97c8b29bb8ca..dc74056b43c7 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -17,6 +17,7 @@ import { collectLabels, interactWithUser, loadAccountSpinner, + parseImageName, promptForEnvironmentVariables, promptForLabels, renderDeploymentConfiguration, @@ -34,6 +35,8 @@ import type { import type { EnvironmentVariable, Label, SSHPublicKeyID } from "./client"; import type { Arg } from "@cloudflare/cli/interactive"; +const defaultContainerImage = "docker.io/cloudflare/hello-world:1.0"; + export function createCommandOptionalYargs(yargs: CommonYargsArgvJSON) { return yargs .option("image", { @@ -123,6 +126,12 @@ export async function createCommand( } const body = checkEverythingIsSet(args, ["image", "location"]); + + const { err } = parseImageName(body.image); + if (err !== undefined) { + throw new Error(err); + } + const keysToAdd = args.allSshKeys ? (await pollSSHKeysUntilCondition(() => true)).map((key) => key.id) : []; @@ -239,18 +248,19 @@ async function handleCreateCommand( label: "image", validate: (value) => { if (typeof value !== "string") { - return "unknown error"; + return "Unknown error"; } if (value.length === 0) { - return "you should fill this input"; - } - if (value.endsWith(":latest")) { - return "we don't allow :latest tags"; + // validate is called before defaultValue is + // applied, so we must set it ourselves + value = defaultContainerImage; } + + const { err } = parseImageName(value); + return err; }, - defaultValue: givenImage ?? "", - initialValue: givenImage ?? "", - helpText: 'i.e. "docker.io/org/app:1.2", :latest tags are not allowed!', + defaultValue: givenImage ?? defaultContainerImage, + helpText: 'NAME:TAG ("latest" tag is not allowed)', type: "text", }); @@ -323,4 +333,4 @@ async function handleCreateCommand( await waitForPlacement(deployment); } -const whichImageQuestion = "Which image url should we use for your container?"; +const whichImageQuestion = "Which image should we use for your container?"; diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index 4c02b1579a52..996c6a702ff4 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -10,6 +10,7 @@ import { collectLabels, interactWithUser, loadAccountSpinner, + parseImageName, promptForEnvironmentVariables, promptForLabels, renderDeploymentConfiguration, @@ -192,15 +193,14 @@ async function handleModifyCommand( label: "", validate: (value) => { if (typeof value !== "string") { - return "unknown error"; - } - if (value.endsWith(":latest")) { - return "we don't allow :latest tags"; + return "Unknown error"; } + const { err } = parseImageName(value); + return err; }, defaultValue: givenImage ?? deployment.image, initialValue: givenImage ?? deployment.image, - helpText: "Press Return to leave unchanged", + helpText: "press Return to leave unchanged", type: "text", });