Skip to content

Commit faed993

Browse files
committed
Parse hostname from image name
Move `parseImageName` out of `wrangler` and into `containers-shared`, and parse the hostname separately from the name of the image. See the new test cases for examples.
1 parent cf16deb commit faed993

File tree

7 files changed

+188
-118
lines changed

7 files changed

+188
-118
lines changed

.changeset/common-ends-stand.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudflare/containers-shared": minor
3+
"wrangler": minor
4+
---
5+
6+
Move parseImageName function from wrangler into containers-shared and parse the hostname from the image name

packages/containers-shared/src/utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,48 @@ export async function cleanupDuplicateImageTags(
361361
}
362362
} catch {}
363363
}
364+
365+
/**
366+
* Regular expression for matching an image name.
367+
*
368+
* See: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pulling-manifests
369+
*/
370+
const imageRe = (() => {
371+
const alphaNumeric = "[a-z0-9]+";
372+
const separator = "(?:\\.|_|__|-+)";
373+
const port = ":[0-9]+";
374+
const host = `${alphaNumeric}(?:(?:${separator}${alphaNumeric})+|(?:${separator}${alphaNumeric})*${port})`
375+
const name = `(?:${alphaNumeric}(?:${separator}${alphaNumeric})*/)*${alphaNumeric}(?:${separator}${alphaNumeric})*`;
376+
const tag = ":([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})";
377+
const digest = "@(sha256:[A-Fa-f0-9]+)";
378+
const reference = `(?:${tag}(?:${digest})?|${digest})`;
379+
return new RegExp(`^(${host}/)?(${name})${reference}$`);
380+
})();
381+
382+
/**
383+
* Parse a container image name.
384+
*/
385+
export function parseImageName(value: string): {
386+
host?: string;
387+
name?: string;
388+
tag?: string;
389+
digest?: string;
390+
} {
391+
const matches = value.match(imageRe);
392+
if (matches === null) {
393+
throw new Error("Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST");
394+
}
395+
396+
// Trim trailing slash
397+
const host = matches[1]?.slice(0, -1);
398+
const name = matches[2];
399+
const tag = matches[3];
400+
const digest = matches[4] ?? matches[5];
401+
402+
if (tag === "latest") {
403+
throw new Error('"latest" tag is not allowed');
404+
}
405+
406+
return { host, name, tag, digest };
407+
}
408+

packages/containers-shared/tests/utils.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { mkdirSync, writeFileSync } from "fs";
2-
import { checkExposedPorts, isDockerfile } from "./../src/utils";
2+
import {
3+
checkExposedPorts,
4+
isDockerfile,
5+
parseImageName,
6+
} from "./../src/utils";
37
import { runInTempDir } from "./helpers/run-in-tmp-dir";
48
import type { ContainerDevOptions } from "../src/types";
59

@@ -98,3 +102,122 @@ describe("checkExposedPorts", () => {
98102
`);
99103
});
100104
});
105+
106+
describe("parseImageName", () => {
107+
test.concurrent.for<
108+
[
109+
string,
110+
{
111+
host?: string;
112+
name?: string;
113+
tag?: string;
114+
digest?: string;
115+
err?: string;
116+
},
117+
]
118+
>([
119+
// With hostname and namespace
120+
[
121+
"docker.io/cloudflare/hello-world:1.0",
122+
{ host: "docker.io", name: "cloudflare/hello-world", tag: "1.0" },
123+
],
124+
125+
// With hostname and no namespace
126+
[
127+
"docker.io/hello-world:1.0",
128+
{ host: "docker.io", name: "hello-world", tag: "1.0" },
129+
],
130+
131+
// Hostname with port
132+
[
133+
"localhost:7777/web:local",
134+
{ host: "localhost:7777", name: "web", tag: "local" },
135+
],
136+
[
137+
"registry.com:1234/foo/bar:local",
138+
{ host: "registry.com:1234", name: "foo/bar", tag: "local" },
139+
],
140+
141+
// No hostname
142+
["hello-world:1.0", { name: "hello-world", tag: "1.0" }],
143+
144+
// No hostname with namespace
145+
[
146+
"cloudflare/hello-world:1.0",
147+
{ name: "cloudflare/hello-world", tag: "1.0" },
148+
],
149+
150+
// Hostname with sha256 digest
151+
[
152+
"registry.cloudflare.com/hello/world:1.0@sha256:abcdef0123456789",
153+
{ host: "registry.cloudflare.com", name: "hello/world", tag: "1.0", digest: "sha256:abcdef0123456789" },
154+
],
155+
156+
// With sha256 digest
157+
[
158+
"hello/world:1.0@sha256:abcdef0123456789",
159+
{ name: "hello/world", tag: "1.0", digest: "sha256:abcdef0123456789" },
160+
],
161+
162+
// sha256 digest but no tag
163+
[
164+
"hello/world@sha256:abcdef0123456789",
165+
{ name: "hello/world", digest: "sha256:abcdef0123456789" },
166+
],
167+
168+
// Invalid name
169+
[
170+
"bad image name:1",
171+
{
172+
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
173+
},
174+
],
175+
176+
// Missing tag
177+
[
178+
"no-tag",
179+
{
180+
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
181+
},
182+
],
183+
[
184+
"no-tag:",
185+
{
186+
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
187+
},
188+
],
189+
190+
// Invalid tag
191+
[
192+
"no-tag::",
193+
{
194+
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
195+
},
196+
],
197+
198+
// latest tag
199+
["name:latest", { err: '"latest" tag is not allowed' }],
200+
201+
// Too many colons
202+
[
203+
"registry.com:1234/foobar:4444/image:sometag",
204+
{
205+
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
206+
},
207+
],
208+
])("%s", ([input, expected], { expect }) => {
209+
let result;
210+
try {
211+
result = parseImageName(input);
212+
} catch (err) {
213+
assert.instanceOf(err, Error);
214+
expect(err.message).toEqual(expected.err);
215+
return;
216+
}
217+
218+
expect(result.host).toEqual(expected.host);
219+
expect(result.name).toEqual(expected.name);
220+
expect(result.tag).toEqual(expected.tag);
221+
expect(result.digest).toEqual(expected.digest);
222+
});
223+
});

packages/wrangler/src/__tests__/cloudchamber/common.test.ts

Lines changed: 0 additions & 62 deletions
This file was deleted.

packages/wrangler/src/cloudchamber/common.ts

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -43,50 +43,6 @@ export function isValidContainerID(value: string): boolean {
4343
return matches !== null;
4444
}
4545

46-
/**
47-
* Regular expression for matching an image name.
48-
*
49-
* See: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pulling-manifests
50-
*/
51-
const imageRe = (() => {
52-
const alphaNumeric = "[a-z0-9]+";
53-
const separator = "(?:\\.|_|__|-+)";
54-
const port = ":[0-9]+";
55-
const domain = `${alphaNumeric}(?:${separator}${alphaNumeric})*`;
56-
const name = `(?:${domain}(?:${port})?/)?(?:${domain}/)*(?:${domain})`;
57-
const tag = ":([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})";
58-
const digest = "@(sha256:[A-Fa-f0-9]+)";
59-
const reference = `(?:${tag}(?:${digest})?|${digest})`;
60-
return new RegExp(`^(${name})${reference}$`);
61-
})();
62-
63-
/**
64-
* Parse a container image name.
65-
*/
66-
export function parseImageName(value: string): {
67-
name?: string;
68-
tag?: string;
69-
digest?: string;
70-
err?: string;
71-
} {
72-
const matches = value.match(imageRe);
73-
if (matches === null) {
74-
return {
75-
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
76-
};
77-
}
78-
79-
const name = matches[1];
80-
const tag = matches[2];
81-
const digest = matches[3] ?? matches[4];
82-
83-
if (tag === "latest") {
84-
return { err: '"latest" tag is not allowed' };
85-
}
86-
87-
return { name, tag, digest };
88-
}
89-
9046
/**
9147
* Wrapper that parses wrangler configuration and authentication.
9248
* It also wraps exceptions and checks if they are from the RestAPI.

packages/wrangler/src/cloudchamber/create.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
AssignIPv4,
1313
AssignIPv6,
1414
DeploymentsService,
15+
parseImageName,
1516
} from "@cloudflare/containers-shared";
1617
import { isNonInteractiveOrCI } from "../is-interactive";
1718
import { logger } from "../logger";
@@ -22,7 +23,6 @@ import {
2223
checkEverythingIsSet,
2324
collectEnvironmentVariables,
2425
collectLabels,
25-
parseImageName,
2626
promptForEnvironmentVariables,
2727
promptForLabels,
2828
renderDeploymentConfiguration,
@@ -153,10 +153,7 @@ export async function createCommand(
153153

154154
const body = checkEverythingIsSet(args, ["image", "location"]);
155155

156-
const { err } = parseImageName(body.image);
157-
if (err !== undefined) {
158-
throw new Error(err);
159-
}
156+
parseImageName(body.image);
160157

161158
const keysToAdd = args.allSshKeys
162159
? (await pollSSHKeysUntilCondition(() => true)).map((key) => key.id)
@@ -294,8 +291,11 @@ async function handleCreateCommand(
294291
value = defaultContainerImage;
295292
}
296293

297-
const { err } = parseImageName(value);
298-
return err;
294+
try {
295+
parseImageName(value);
296+
} catch (err) {
297+
return (err as Error).message;
298+
}
299299
},
300300
defaultValue: givenImage ?? defaultContainerImage,
301301
helpText: 'NAME:TAG ("latest" tag is not allowed)',

packages/wrangler/src/cloudchamber/modify.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cancel, startSection } from "@cloudflare/cli";
22
import { processArgument } from "@cloudflare/cli/args";
33
import { inputPrompt, spinner } from "@cloudflare/cli/interactive";
4-
import { DeploymentsService } from "@cloudflare/containers-shared";
4+
import { DeploymentsService, parseImageName } from "@cloudflare/containers-shared";
55
import { isNonInteractiveOrCI } from "../is-interactive";
66
import { logger } from "../logger";
77
import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli";
@@ -10,7 +10,6 @@ import { getLocation } from "./cli/locations";
1010
import {
1111
collectEnvironmentVariables,
1212
collectLabels,
13-
parseImageName,
1413
promptForEnvironmentVariables,
1514
promptForLabels,
1615
renderDeploymentConfiguration,
@@ -218,8 +217,11 @@ async function handleModifyCommand(
218217
return "Unknown error";
219218
}
220219

221-
const { err } = parseImageName(value);
222-
return err;
220+
try {
221+
parseImageName(value);
222+
} catch (err) {
223+
return (err as Error).message;
224+
}
223225
},
224226
defaultValue: givenImage ?? deployment.image,
225227
initialValue: givenImage ?? deployment.image,

0 commit comments

Comments
 (0)