Skip to content

Commit b55a3c7

Browse files
add npx wrangler containers registry commands (#10605)
* add containers registry put command * changeset * call api * add list and delete commands * PR feedback and fixups * more review fixups * pr feedback * unhide containers command * pr feedback * Update packages/wrangler/src/__tests__/containers/registries.test.ts Co-authored-by: Pete Bacon Darwin <[email protected]> * wip: secret store rework * pr feedback and tests * update api shape * pr feedback * mark containers commands as in open beta --------- Co-authored-by: Pete Bacon Darwin <[email protected]>
1 parent 6d3b49f commit b55a3c7

File tree

28 files changed

+1244
-178
lines changed

28 files changed

+1244
-178
lines changed

.changeset/rare-bushes-prove.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@cloudflare/containers-shared": patch
3+
"wrangler": patch
4+
---
5+
6+
Add command to configure credentials for non-Cloudflare container registries
7+
8+
Note this is a closed/experimental command that will not work without the appropriate account-level capabilities.

packages/containers-shared/src/client/models/CreateImageRegistryRequestBody.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
33
/* eslint-disable */
44

55
import type { Domain } from "./Domain";
6+
import type { ExternalRegistryKind } from "./ExternalRegistryKind";
7+
import type { ImageRegistryAuth } from "./ImageRegistryAuth";
68

79
/**
810
* Request body for creating a new image registry configuration
911
*/
1012
export type CreateImageRegistryRequestBody = {
11-
/**
12-
* The domain of the registry. It shouldn't contain the proto part of the domain, for example 'domain.com' is allowed, 'https://domain.com' is not
13-
*/
1413
domain: Domain;
1514
/**
1615
* If you own the registry and is private, this should be false or not defined. If it's a public registry like docker.io, you should set this to true
1716
*/
1817
is_public?: boolean;
18+
auth?: ImageRegistryAuth;
19+
kind?: ExternalRegistryKind;
1920
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* istanbul ignore file */
2+
/* tslint:disable */
3+
/* eslint-disable */
4+
5+
/**
6+
* The type of external registry that is being configured.
7+
*/
8+
export enum ExternalRegistryKind {
9+
ECR = "ECR",
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* istanbul ignore file */
2+
/* tslint:disable */
3+
/* eslint-disable */
4+
5+
/**
6+
* A JSON string that encodes the auth required to authenticate with an external image registry. The format of the JSON object is determined by the registry being configured.
7+
*/
8+
export type ImageRegistryAuth = {
9+
public_credential: string;
10+
private_credential: {
11+
store_id: string;
12+
secret_name: string;
13+
};
14+
};

packages/containers-shared/src/images.ts

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { buildImage } from "./build";
2+
import { ExternalRegistryKind } from "./client/models/ExternalRegistryKind";
23
import { UserError } from "./error";
3-
import {
4-
getCloudflareContainerRegistry,
5-
isCloudflareRegistryLink,
6-
} from "./knobs";
7-
import { dockerLoginManagedRegistry } from "./login";
4+
import { getCloudflareContainerRegistry } from "./knobs";
5+
import { dockerLoginImageRegistry } from "./login";
86
import { getCloudflareRegistryWithAccountNamespace } from "./registry";
97
import {
108
checkExposedPorts,
@@ -18,7 +16,8 @@ export async function pullImage(
1816
dockerPath: string,
1917
options: Exclude<ContainerDevOptions, DockerfileConfig>
2018
): Promise<{ abort: () => void; ready: Promise<void> }> {
21-
await dockerLoginManagedRegistry(dockerPath);
19+
const domain = new URL(`http://${options.image_uri}`).hostname;
20+
await dockerLoginImageRegistry(dockerPath, domain);
2221
const pull = runDockerCmd(dockerPath, [
2322
"pull",
2423
options.image_uri,
@@ -95,12 +94,6 @@ export async function prepareContainerImagesForDev(args: {
9594
containerOptions: options,
9695
});
9796
} else {
98-
if (!isCloudflareRegistryLink(options.image_uri)) {
99-
throw new UserError(
100-
`Image "${options.image_uri}" is a registry link but does not point to the Cloudflare container registry.\n` +
101-
`To use an existing image from another repository, see https://developers.cloudflare.com/containers/platform-details/image-management/#using-pre-built-container-images`
102-
);
103-
}
10497
const pull = await pullImage(dockerPath, options);
10598
onContainerImagePreparationStart({
10699
containerOptions: options,
@@ -173,3 +166,66 @@ export function resolveImageName(accountId: string, image: string): string {
173166
// is managed registry and doesn't have the account id,add it to the path
174167
return `${url.hostname}/${accountId}${url.pathname}`;
175168
}
169+
170+
/**
171+
* get type of container registry, and validate
172+
* currently we support cloudflare managed registries and AWS ECR
173+
* when using cloudflare mananged registries we expect CLOUDFLARE_CONTAINER_REGISTRY to be set
174+
*/
175+
export const getAndValidateRegistryType = (domain: string): RegistryPattern => {
176+
// TODO: use parseImageName when that gets moved to this package
177+
if (domain.includes("://")) {
178+
throw new Error(
179+
`${domain} is invalid:\nImage reference should not include the protocol part (e.g: registry.cloudflare.com rather than https://registry.cloudflare.com)`
180+
);
181+
}
182+
let url: URL;
183+
try {
184+
url = new URL(`http://${domain}`);
185+
} catch (e) {
186+
if (e instanceof Error) {
187+
throw new Error(`${domain} is invalid:\n${e.message}`);
188+
}
189+
throw e;
190+
}
191+
192+
const acceptedRegistries: RegistryPattern[] = [
193+
{
194+
type: ExternalRegistryKind.ECR,
195+
pattern: /^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$/,
196+
name: "AWS ECR",
197+
secretType: "AWS Secret Access Key",
198+
},
199+
{
200+
type: "cloudflare",
201+
// Make a regex based on the env var CLOUDFLARE_CONTAINER_REGISTRY
202+
pattern: new RegExp(
203+
`^${getCloudflareContainerRegistry().replace(/[\\.]/g, "\\$&")}$`
204+
),
205+
name: "Cloudflare Containers Managed Registry",
206+
},
207+
];
208+
209+
const match = acceptedRegistries.find((registry) =>
210+
registry.pattern.test(url.hostname)
211+
);
212+
213+
if (!match) {
214+
const supportedRegistries = acceptedRegistries
215+
.filter((r) => r.type !== "cloudflare")
216+
.map((r) => r.name)
217+
.join(", ");
218+
throw new UserError(
219+
`${url.hostname} is not a supported image registry.\nCurrently we support the following non-Cloudflare registries: ${supportedRegistries}.\nTo use an existing image from another repository, see https://developers.cloudflare.com/containers/platform-details/image-management/#using-pre-built-container-images`
220+
);
221+
}
222+
223+
return match;
224+
};
225+
226+
interface RegistryPattern {
227+
type: ExternalRegistryKind | "cloudflare";
228+
secretType?: string;
229+
pattern: RegExp;
230+
name: string;
231+
}

packages/containers-shared/src/knobs.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,6 @@ export const getCloudflareContainerRegistry = () => {
1313
);
1414
};
1515

16-
/**
17-
* Given a container image that is a registry link, this function
18-
* returns true if the link points the Cloudflare container registry
19-
* (defined as per `getCloudflareContainerRegistry` above)
20-
*/
21-
export function isCloudflareRegistryLink(image: string) {
22-
const cfRegistry = getCloudflareContainerRegistry();
23-
return image.includes(cfRegistry);
24-
}
25-
2616
/** Prefixes with the cloudflare-dev namespace. The name should be the container's DO classname, and the tag a build uuid. */
2717
export const getDevContainerImageName = (name: string, tag: string) => {
2818
return `${MF_DEV_CONTAINER_PREFIX}/${name.toLowerCase()}:${tag}`;

packages/containers-shared/src/login.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,31 @@
11
import { spawn } from "node:child_process";
22
import { ImageRegistriesService, ImageRegistryPermissions } from "./client";
33
import { UserError } from "./error";
4-
import { getCloudflareContainerRegistry } from "./knobs";
54

65
/**
7-
* Gets push and pull credentials for Cloudflare's managed image registry
6+
* Gets push and pull credentials for a configured image registry
87
* and runs `docker login`, so subsequent image pushes or pulls are
98
* authenticated
109
*/
11-
export async function dockerLoginManagedRegistry(pathToDocker: string) {
10+
export async function dockerLoginImageRegistry(
11+
pathToDocker: string,
12+
domain: string
13+
) {
1214
// how long the credentials should be valid for
1315
const expirationMinutes = 15;
1416

1517
const credentials =
16-
await ImageRegistriesService.generateImageRegistryCredentials(
17-
getCloudflareContainerRegistry(),
18-
{
19-
expiration_minutes: expirationMinutes,
20-
permissions: [
21-
ImageRegistryPermissions.PUSH,
22-
ImageRegistryPermissions.PULL,
23-
],
24-
}
25-
);
18+
await ImageRegistriesService.generateImageRegistryCredentials(domain, {
19+
expiration_minutes: expirationMinutes,
20+
permissions: [
21+
ImageRegistryPermissions.PUSH,
22+
ImageRegistryPermissions.PULL,
23+
],
24+
});
2625

2726
const child = spawn(
2827
pathToDocker,
29-
[
30-
"login",
31-
"--password-stdin",
32-
"--username",
33-
"v1",
34-
getCloudflareContainerRegistry(),
35-
],
28+
["login", "--password-stdin", "--username", credentials.username, domain],
3629
{ stdio: ["pipe", "inherit", "inherit"] }
3730
).on("error", (err) => {
3831
throw err;

packages/containers-shared/src/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ export const isDockerfile = (
138138
`If this is an image registry path, it needs to include at least a tag ':' (e.g: docker.io/httpd:1)`
139139
);
140140
}
141-
142141
// validate URL
143142
if (image.includes("://")) {
144143
throw new UserError(

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ describe("isDockerfile", () => {
4646
});
4747

4848
it("should error if image registry reference contains the protocol part", async () => {
49-
expect(() => isDockerfile("http://example.com/image:tag", undefined))
50-
.toThrowErrorMatchingInlineSnapshot(`
51-
[Error: The image "http://example.com/image:tag" does not appear to be a valid path to a Dockerfile, or a valid image registry path:
52-
Image reference should not include the protocol part (e.g: docker.io/httpd:1, not https://docker.io/httpd:1)]
53-
`);
49+
expect(() =>
50+
isDockerfile("http://registry.cloudflare.com/image:tag", undefined)
51+
).toThrowErrorMatchingInlineSnapshot(`
52+
[Error: The image "http://registry.cloudflare.com/image:tag" does not appear to be a valid path to a Dockerfile, or a valid image registry path:
53+
Image reference should not include the protocol part (e.g: docker.io/httpd:1, not https://docker.io/httpd:1)]
54+
`);
5455
});
5556

5657
it("should error if image registry reference does not contain a tag", async () => {

0 commit comments

Comments
 (0)