diff --git a/.changeset/common-ends-stand.md b/.changeset/common-ends-stand.md new file mode 100644 index 000000000000..39368d443863 --- /dev/null +++ b/.changeset/common-ends-stand.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/containers-shared": minor +"wrangler": minor +--- + +Move parseImageName function from wrangler into containers-shared and parse the hostname from the image name diff --git a/packages/containers-shared/src/utils.ts b/packages/containers-shared/src/utils.ts index f0379c5b43c0..ce60a882ecbb 100644 --- a/packages/containers-shared/src/utils.ts +++ b/packages/containers-shared/src/utils.ts @@ -361,3 +361,49 @@ export async function cleanupDuplicateImageTags( } } catch {} } + +/** + * 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 host = `${alphaNumeric}(?:(?:${separator}${alphaNumeric})+|(?:${separator}${alphaNumeric})*${port})`; + const name = `(?:${alphaNumeric}(?:${separator}${alphaNumeric})*/)*${alphaNumeric}(?:${separator}${alphaNumeric})*`; + 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(`^(${host}/)?(${name})${reference}$`); +})(); + +/** + * Parse a container image name. + */ +export function parseImageName(value: string): { + host?: string; + name?: string; + tag?: string; + digest?: string; +} { + const matches = value.match(imageRe); + if (matches === null) { + throw new Error( + "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST" + ); + } + + // Trim trailing slash + const host = matches[1]?.slice(0, -1); + const name = matches[2]; + const tag = matches[3]; + const digest = matches[4] ?? matches[5]; + + if (tag === "latest") { + throw new Error('"latest" tag is not allowed'); + } + + return { host, name, tag, digest }; +} diff --git a/packages/containers-shared/tests/utils.test.ts b/packages/containers-shared/tests/utils.test.ts index b41fe03a861d..27357a67a94d 100644 --- a/packages/containers-shared/tests/utils.test.ts +++ b/packages/containers-shared/tests/utils.test.ts @@ -1,5 +1,9 @@ import { mkdirSync, writeFileSync } from "fs"; -import { checkExposedPorts, isDockerfile } from "./../src/utils"; +import { + checkExposedPorts, + isDockerfile, + parseImageName, +} from "./../src/utils"; import { runInTempDir } from "./helpers/run-in-tmp-dir"; import type { ContainerDevOptions } from "../src/types"; @@ -98,3 +102,127 @@ describe("checkExposedPorts", () => { `); }); }); + +describe("parseImageName", () => { + test.concurrent.for< + [ + string, + { + host?: string; + name?: string; + tag?: string; + digest?: string; + err?: string; + }, + ] + >([ + // With hostname and namespace + [ + "docker.io/cloudflare/hello-world:1.0", + { host: "docker.io", name: "cloudflare/hello-world", tag: "1.0" }, + ], + + // With hostname and no namespace + [ + "docker.io/hello-world:1.0", + { host: "docker.io", name: "hello-world", tag: "1.0" }, + ], + + // Hostname with port + [ + "localhost:7777/web:local", + { host: "localhost:7777", name: "web", tag: "local" }, + ], + [ + "registry.com:1234/foo/bar:local", + { host: "registry.com:1234", name: "foo/bar", tag: "local" }, + ], + + // No hostname + ["hello-world:1.0", { name: "hello-world", tag: "1.0" }], + + // No hostname with namespace + [ + "cloudflare/hello-world:1.0", + { name: "cloudflare/hello-world", tag: "1.0" }, + ], + + // Hostname with sha256 digest + [ + "registry.cloudflare.com/hello/world:1.0@sha256:abcdef0123456789", + { + host: "registry.cloudflare.com", + name: "hello/world", + tag: "1.0", + digest: "sha256:abcdef0123456789", + }, + ], + + // With sha256 digest + [ + "hello/world:1.0@sha256:abcdef0123456789", + { name: "hello/world", tag: "1.0", digest: "sha256:abcdef0123456789" }, + ], + + // sha256 digest but no tag + [ + "hello/world@sha256:abcdef0123456789", + { name: "hello/world", digest: "sha256:abcdef0123456789" }, + ], + + // Invalid name + [ + "bad image name:1", + { + err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST", + }, + ], + + // Missing tag + [ + "no-tag", + { + err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST", + }, + ], + [ + "no-tag:", + { + err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST", + }, + ], + + // Invalid tag + [ + "no-tag::", + { + err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST", + }, + ], + + // latest tag + ["name:latest", { err: '"latest" tag is not allowed' }], + + // Too many colons + [ + "registry.com:1234/foobar:4444/image:sometag", + { + err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST", + }, + ], + ])("%s", ([input, expected], { expect }) => { + let result; + try { + result = parseImageName(input); + } catch (err) { + assert.instanceOf(err, Error); + expect(err.message).toEqual(expected.err); + return; + } + + expect(result.host).toEqual(expected.host); + expect(result.name).toEqual(expected.name); + expect(result.tag).toEqual(expected.tag); + expect(result.digest).toEqual(expected.digest); + }); +}); diff --git a/packages/wrangler/src/__tests__/cloudchamber/common.test.ts b/packages/wrangler/src/__tests__/cloudchamber/common.test.ts deleted file mode 100644 index 91b2a1475eed..000000000000 --- a/packages/wrangler/src/__tests__/cloudchamber/common.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -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/cloudchamber/common.ts b/packages/wrangler/src/cloudchamber/common.ts index b67b0ba012ef..419b2e54fc17 100644 --- a/packages/wrangler/src/cloudchamber/common.ts +++ b/packages/wrangler/src/cloudchamber/common.ts @@ -41,50 +41,6 @@ export function isValidContainerID(value: string): boolean { return matches !== null; } -/** - * 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 0b14cfaf323c..aefcddcde012 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -12,6 +12,7 @@ import { AssignIPv4, AssignIPv6, DeploymentsService, + parseImageName, } from "@cloudflare/containers-shared"; import { parseByteSize } from "@cloudflare/workers-utils"; import { isNonInteractiveOrCI } from "../is-interactive"; @@ -22,7 +23,6 @@ import { checkEverythingIsSet, collectEnvironmentVariables, collectLabels, - parseImageName, promptForEnvironmentVariables, promptForLabels, renderDeploymentConfiguration, @@ -153,10 +153,7 @@ export async function createCommand( const body = checkEverythingIsSet(args, ["image", "location"]); - const { err } = parseImageName(body.image); - if (err !== undefined) { - throw new Error(err); - } + parseImageName(body.image); const keysToAdd = args.allSshKeys ? (await pollSSHKeysUntilCondition(() => true)).map((key) => key.id) @@ -294,8 +291,11 @@ async function handleCreateCommand( value = defaultContainerImage; } - const { err } = parseImageName(value); - return err; + try { + parseImageName(value); + } catch (err) { + return (err as Error).message; + } }, defaultValue: givenImage ?? defaultContainerImage, helpText: 'NAME:TAG ("latest" tag is not allowed)', diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index 34843d9f4210..74369c80198f 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -1,7 +1,10 @@ import { cancel, startSection } from "@cloudflare/cli"; import { processArgument } from "@cloudflare/cli/args"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; -import { DeploymentsService } from "@cloudflare/containers-shared"; +import { + DeploymentsService, + parseImageName, +} from "@cloudflare/containers-shared"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; @@ -10,7 +13,6 @@ import { getLocation } from "./cli/locations"; import { collectEnvironmentVariables, collectLabels, - parseImageName, promptForEnvironmentVariables, promptForLabels, renderDeploymentConfiguration, @@ -218,8 +220,11 @@ async function handleModifyCommand( return "Unknown error"; } - const { err } = parseImageName(value); - return err; + try { + parseImageName(value); + } catch (err) { + return (err as Error).message; + } }, defaultValue: givenImage ?? deployment.image, initialValue: givenImage ?? deployment.image,