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..91b2a1475eed --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/common.test.ts @@ -0,0 +1,62 @@ +import { parseImageName } from "../../cloudchamber/common"; + +describe("parseImageName", () => { + it("works", () => { + type TestCase = [ + input: string, + expected: { name?: string; tag?: string; digest?: 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", digest: "abcdef0123456789" }, + ], + + // sha256 digest but no tag + [ + "hello/world@sha256:abcdef0123456789", + { name: "hello/world", digest: "sha256:abcdef0123456789" }, + ], + + // Invalid name + ["bad image name:1", { err: true }], + + // Missing tag + ["no-tag", { err: true }], + ["no-tag:", { 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..0e9814b59e28 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] or NAME@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..39d6bdfa46c8 100644 --- a/packages/wrangler/src/cloudchamber/common.ts +++ b/packages/wrangler/src/cloudchamber/common.ts @@ -40,6 +40,50 @@ 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 name = `(?:${domain}(?:${port})?/)?(?:${domain}/)*(?:${domain})`; + const tag = ":([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})"; + const digest = "@(sha256:[A-Fa-f0-9]+)"; + const reference = `(?:${tag}(?:${digest})?|${digest})`; + return new RegExp(`^(${name})${reference}$`); +})(); + +/** + * Parse a container image name. + */ +export function parseImageName(value: string): { + name?: string; + tag?: string; + digest?: string; + err?: string; +} { + const matches = value.match(imageRe); + if (matches === null) { + return { + err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST", + }; + } + + const name = matches[1]; + const tag = matches[2]; + const digest = matches[3] ?? matches[4]; + + if (tag === "latest") { + return { err: '"latest" tag is not allowed' }; + } + + return { name, tag, digest }; +} + /** * 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", });