Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/common-ends-stand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudflare/containers-shared": minor
"wrangler": minor
Comment on lines +2 to +3
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"@cloudflare/containers-shared": minor
"wrangler": minor
"@cloudflare/containers-shared": patch
"wrangler": patch

Copy link
Member Author

@gpanders gpanders Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this deletes an exported function from wrangler shouldn't it be a new minor version?

Or perhaps we should keep a stub for parseImageName in Wrangler that just calls the one in containers-shared to avoid any breaking changes.

Copy link
Member

@nikitassharma nikitassharma Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we were keeping containers changes as patches since it's still beta, but I'm not certain. @emily-shen might know?

I don't see any reason to keep the stub, I like the way it's structured currently, it feels like this belongs in containers-shared

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its only importable from within wrangler, it was never exposed as part of wranglers public api. so no breaking changes :)

---

Move parseImageName function from wrangler into containers-shared and parse the hostname from the image name
46 changes: 46 additions & 0 deletions packages/containers-shared/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we added a UserError to containers shared (prevents the error showing up in wrangler's sentry)

"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 };
}
130 changes: 129 additions & 1 deletion packages/containers-shared/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
62 changes: 0 additions & 62 deletions packages/wrangler/src/__tests__/cloudchamber/common.test.ts

This file was deleted.

44 changes: 0 additions & 44 deletions packages/wrangler/src/cloudchamber/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,50 +43,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.
Expand Down
14 changes: 7 additions & 7 deletions packages/wrangler/src/cloudchamber/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AssignIPv4,
AssignIPv6,
DeploymentsService,
parseImageName,
} from "@cloudflare/containers-shared";
import { isNonInteractiveOrCI } from "../is-interactive";
import { logger } from "../logger";
Expand All @@ -22,7 +23,6 @@ import {
checkEverythingIsSet,
collectEnvironmentVariables,
collectLabels,
parseImageName,
promptForEnvironmentVariables,
promptForLabels,
renderDeploymentConfiguration,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)',
Expand Down
13 changes: 9 additions & 4 deletions packages/wrangler/src/cloudchamber/modify.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,7 +13,6 @@ import { getLocation } from "./cli/locations";
import {
collectEnvironmentVariables,
collectLabels,
parseImageName,
promptForEnvironmentVariables,
promptForLabels,
renderDeploymentConfiguration,
Expand Down Expand Up @@ -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,
Expand Down
Loading