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..0606ab32c844 --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/common.test.ts @@ -0,0 +1,41 @@ +import { parseImageName } from "../../cloudchamber/common"; + +describe("parseImageName", () => { + it("works", () => { + type TestCase = [input: string, name: string | undefined, tag: string | undefined, err: boolean]; + const cases: TestCase[] = [ + // Multiple domains + ["docker.io/cloudflare/hello-world:1.0", "docker.io/cloudflare/hello-world", "1.0", false], + + // Domain with port + ["localhost:7777/web:local", "localhost:7777/web", "local", false], + + // No domain + ["hello-world:1.0", "hello-world", "1.0", false], + + // Invalid name + ["bad image name:1", undefined, undefined, true], + + // Missing tag + ["no-tag", undefined, undefined, true], + ["no-tag:", undefined, undefined, true], + + // Invalid tag + ["no-tag::", undefined, undefined, true], + + // latest tag + ["name:latest", undefined, undefined, true], + + // Too many colons + ["registry.com:1234/foobar:4444/image:sometag", undefined, undefined, true], + ]; + + for (const c of cases) { + let [input, name, tag, isErr] = c; + let result = parseImageName(input); + expect(result.name).toEqual(name); + expect(result.tag).toEqual(tag); + expect(result.err !== undefined).toEqual(isErr); + } + }); +}); diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index 9d8018f9106e..7ff2e11a7cd1 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]` + ); + }); + 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..e545b95715ca 100644 --- a/packages/wrangler/src/cloudchamber/common.ts +++ b/packages/wrangler/src/cloudchamber/common.ts @@ -40,6 +40,35 @@ import type { Arg } from "@cloudflare/cli/interactive"; export type CommonCloudchamberConfiguration = { json: boolean }; +/** + * These patterns are defined by the OCI specification. + * + * See: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pulling-manifests + */ +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 imageRe = new RegExp(`^((?:${domain}(?:${port})?/)?(?:${domain}/)*(?:${domain})):(${tag})$`); + +/** + * 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"}; + } + + 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..6eadd76307df 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -21,6 +21,7 @@ import { promptForLabels, renderDeploymentConfiguration, renderDeploymentMutationError, + parseImageName, } from "./common"; import { wrap } from "./helpers/wrap"; import { loadAccount } from "./locations"; @@ -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..7e9c1d3af6a9 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -14,6 +14,7 @@ import { promptForLabels, renderDeploymentConfiguration, renderDeploymentMutationError, + parseImageName, } from "./common"; import { wrap } from "./helpers/wrap"; import { loadAccount } from "./locations"; @@ -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", });