Skip to content

Commit

Permalink
Add default image for cloudchamber create
Browse files Browse the repository at this point in the history
Add a default image when using "wrangler cloudchamber create". This
image is docker.io/cloudflare/hello-world which is a simple HTTP server
that runs on Cloudflare's container platform.

This commit also augments validation to both the `cloudchamber create`
and `cloudchamber modify` commands to ensure the provided image contains
a tag and adheres to the format in the OCI specification.
  • Loading branch information
gpanders committed Nov 22, 2024
1 parent 3154114 commit 96392ca
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/six-apes-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wrangler": minor
---

Add a default image for cloudchamber create and modify commands
62 changes: 62 additions & 0 deletions packages/wrangler/src/__tests__/cloudchamber/common.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
16 changes: 16 additions & 0 deletions packages/wrangler/src/__tests__/cloudchamber/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions packages/wrangler/src/cloudchamber/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ":(?<tag>[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})";
const digest = "@(?<digest>sha256:[A-Fa-f0-9]+)";
const reference = `(?:${tag}|${digest}|${tag}${digest})`;
return new RegExp(`^(?<name>${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 || matches.groups === undefined) {
return {
err: "Invalid image format: expected NAME:TAG[@DIGEST] or NAME@DIGEST",
};
}

if (matches.groups.tag === "latest") {
return { err: '"latest" tag is not allowed' };
}

return {
name: matches.groups.name,
tag: matches.groups.tag,
digest: matches.groups.digest,
};
}

/**
* Wrapper that parses wrangler configuration and authentication.
* It also wraps exceptions and checks if they are from the RestAPI.
Expand Down
28 changes: 19 additions & 9 deletions packages/wrangler/src/cloudchamber/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
collectLabels,
interactWithUser,
loadAccountSpinner,
parseImageName,
promptForEnvironmentVariables,
promptForLabels,
renderDeploymentConfiguration,
Expand All @@ -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", {
Expand Down Expand Up @@ -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)
: [];
Expand Down Expand Up @@ -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",
});

Expand Down Expand Up @@ -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?";
10 changes: 5 additions & 5 deletions packages/wrangler/src/cloudchamber/modify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
collectLabels,
interactWithUser,
loadAccountSpinner,
parseImageName,
promptForEnvironmentVariables,
promptForLabels,
renderDeploymentConfiguration,
Expand Down Expand Up @@ -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",
});

Expand Down

0 comments on commit 96392ca

Please sign in to comment.