Skip to content

Commit 6a8aa5f

Browse files
Allow users to configure DockerHub for use with containers (#11332)
1 parent d672e2e commit 6a8aa5f

File tree

10 files changed

+293
-160
lines changed

10 files changed

+293
-160
lines changed

.changeset/breezy-groups-warn.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@cloudflare/containers-shared": patch
3+
"wrangler": minor
4+
---
5+
6+
Users are now able to configure DockerHub credentials and have containers reference images stored there.
7+
8+
DockerHub can be configured as follows:
9+
10+
```sh
11+
echo $PAT_TOKEN | npx wrangler@latest containers registries configure docker.io --dockerhub-username=user --secret-name=DockerHub_PAT_Token
12+
```
13+
14+
Containers can then specify an image from DockerHub in their `wrangler.jsonc` as follows:
15+
16+
```jsonc
17+
"containers": {
18+
"image": "docker.io/namespace/image:tag",
19+
...
20+
}
21+
```

packages/containers-shared/src/client/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export type { EnvironmentVariableValue } from "./models/EnvironmentVariableValue
9797
export { EventName } from "./models/EventName";
9898
export { EventType } from "./models/EventType";
9999
export type { ExecFormParam } from "./models/ExecFormParam";
100+
export { ExternalRegistryKind } from "./models/ExternalRegistryKind";
100101
export type { GenericErrorDetails } from "./models/GenericErrorDetails";
101102
export type { GenericErrorResponseWithRequestID } from "./models/GenericErrorResponseWithRequestID";
102103
export type { GenericMessageResponse } from "./models/GenericMessageResponse";
@@ -105,6 +106,7 @@ export type { GetPlacementError } from "./models/GetPlacementError";
105106
export { HTTPMethod } from "./models/HTTPMethod";
106107
export type { Identity } from "./models/Identity";
107108
export type { Image } from "./models/Image";
109+
export type { ImageRegistryAuth } from "./models/ImageRegistryAuth";
108110
export { ImageRegistryAlreadyExistsError } from "./models/ImageRegistryAlreadyExistsError";
109111
export type { ImageRegistryCredentialsConfiguration } from "./models/ImageRegistryCredentialsConfiguration";
110112
export { ImageRegistryIsPublic } from "./models/ImageRegistryIsPublic";
@@ -190,6 +192,7 @@ export type { SecretMetadata } from "./models/SecretMetadata";
190192
export type { SecretName } from "./models/SecretName";
191193
export { SecretNameAlreadyExists } from "./models/SecretNameAlreadyExists";
192194
export { SecretNotFound } from "./models/SecretNotFound";
195+
export type { SecretsStoreRef } from "./models/SecretsStoreRef";
193196
export type { SSHPublicKey } from "./models/SSHPublicKey";
194197
export type { SSHPublicKeyID } from "./models/SSHPublicKeyID";
195198
export type { SSHPublicKeyItem } from "./models/SSHPublicKeyItem";

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
/* tslint:disable */
33
/* eslint-disable */
44

5+
import type { DefaultImageRegistryKind } from "./DefaultImageRegistryKind";
56
import type { Domain } from "./Domain";
7+
import type { ExternalRegistryKind } from "./ExternalRegistryKind";
68
import type { ISO8601Timestamp } from "./ISO8601Timestamp";
9+
import type { SecretsStoreRef } from "./SecretsStoreRef";
710

811
/**
912
* An image registry added in a customer account
@@ -13,6 +16,11 @@ export type CustomerImageRegistry = {
1316
* A base64 representation of the public key that you can set to configure the registry. If null, the registry is public and doesn't have authentication setup with Cloudchamber
1417
*/
1518
public_key?: string;
19+
private_credential?: SecretsStoreRef;
1620
domain: Domain;
21+
/**
22+
* The type of registry that is being configured.
23+
*/
24+
kind?: ExternalRegistryKind | DefaultImageRegistryKind;
1725
created_at: ISO8601Timestamp;
1826
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/* istanbul ignore file */
2+
/* tslint:disable */
3+
/* eslint-disable */
4+
5+
export enum DefaultImageRegistryKind {
6+
DEFAULT = "default",
7+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
*/
88
export enum ExternalRegistryKind {
99
ECR = "ECR",
10+
DOCKER_HUB = "DockerHub",
1011
}

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

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

55
/**
6-
* The registry is not allowed to be added
6+
* The registry is not allowed to be modified
77
*/
88
export type ImageRegistryNotAllowedError = {
99
/**
10-
* The domain of the registry is not allowed to be added
10+
* The domain of the registry is not allowed to be modified
1111
*/
1212
error: ImageRegistryNotAllowedError.error;
1313
/**
@@ -18,7 +18,7 @@ export type ImageRegistryNotAllowedError = {
1818

1919
export namespace ImageRegistryNotAllowedError {
2020
/**
21-
* The domain of the registry is not allowed to be added
21+
* The domain of the registry is not allowed to be modified
2222
*/
2323
export enum error {
2424
IMAGE_REGISTRY_NOT_ALLOWED = "IMAGE_REGISTRY_NOT_ALLOWED",

packages/containers-shared/src/images.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@ export const getAndValidateRegistryType = (domain: string): RegistryPattern => {
259259
name: "AWS ECR",
260260
secretType: "AWS Secret Access Key",
261261
},
262+
{
263+
type: ExternalRegistryKind.DOCKER_HUB,
264+
pattern: /^docker\.io$/,
265+
name: "DockerHub",
266+
secretType: "DockerHub PAT Token",
267+
},
262268
{
263269
type: "cloudflare",
264270
// Make a regex based on the env var CLOUDFLARE_CONTAINER_REGISTRY

packages/wrangler/src/__tests__/containers/config.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ describe("getNormalizedContainerOptions", () => {
708708
containers: [
709709
{
710710
class_name: "TestContainer",
711-
image: "docker.io/test:latest",
711+
image: "unsupported.domain/test:latest",
712712
instance_type: "standard",
713713
name: "test-container",
714714
max_instances: 3,
@@ -727,7 +727,7 @@ describe("getNormalizedContainerOptions", () => {
727727
const result = await getNormalizedContainerOptions(config, {});
728728
expect(result).toHaveLength(1);
729729
expect(result[0]).toMatchObject({
730-
image_uri: "docker.io/test:latest",
730+
image_uri: "unsupported.domain/test:latest",
731731
});
732732
});
733733
it("should not try and add an account id to non containers registry uris", async () => {

packages/wrangler/src/__tests__/containers/registries.test.ts

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,33 @@ import { useMockStdin } from "../helpers/mock-stdin";
1919
import { createFetchResult, msw } from "../helpers/msw";
2020
import { runWrangler } from "../helpers/run-wrangler";
2121

22+
describe("containers registries --help", () => {
23+
const std = mockConsoleMethods();
24+
25+
it("should help", async () => {
26+
await runWrangler("containers registries --help");
27+
expect(std.out).toMatchInlineSnapshot(`
28+
"wrangler containers registries
29+
30+
Configure and manage non-Cloudflare registries [open beta]
31+
32+
COMMANDS
33+
wrangler containers registries configure <DOMAIN> Configure credentials for a non-Cloudflare container registry [open beta]
34+
wrangler containers registries list List all configured container registries [open beta]
35+
wrangler containers registries delete <DOMAIN> Delete a configured container registry [open beta]
36+
wrangler containers registries credentials [DOMAIN] Get a temporary password for a specific domain [open beta]
37+
38+
GLOBAL FLAGS
39+
-c, --config Path to Wrangler configuration file [string]
40+
--cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string]
41+
-e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string]
42+
--env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array]
43+
-h, --help Show help [boolean]
44+
-v, --version Show version number [boolean]"
45+
`);
46+
});
47+
});
48+
2249
describe("containers registries configure", () => {
2350
const { setIsTTY } = useMockIsTTY();
2451
const cliStd = mockCLIOutput();
@@ -31,15 +58,14 @@ describe("containers registries configure", () => {
3158
afterEach(() => {
3259
clearDialogs();
3360
});
34-
3561
it("should reject unsupported registry domains", async () => {
3662
await expect(
3763
runWrangler(
38-
`containers registries configure docker.io --public-credential=test-id`
64+
`containers registries configure unsupported.domain --public-credential=test-id`
3965
)
4066
).rejects.toThrowErrorMatchingInlineSnapshot(`
41-
[Error: docker.io is not a supported image registry.
42-
Currently we support the following non-Cloudflare registries: AWS ECR.
67+
[Error: unsupported.domain is not a supported image registry.
68+
Currently we support the following non-Cloudflare registries: AWS ECR, DockerHub.
4369
To use an existing image from another repository, see https://developers.cloudflare.com/containers/platform-details/image-management/#using-pre-built-container-images]
4470
`);
4571
});
@@ -81,15 +107,37 @@ describe("containers registries configure", () => {
81107
);
82108
});
83109

110+
it("should enforce mutual exclusivity for public credential arguments", async () => {
111+
await expect(
112+
runWrangler(`containers registries configure docker.io`)
113+
).rejects.toThrowErrorMatchingInlineSnapshot(
114+
`[Error: Missing required argument: dockerhub-username]`
115+
);
116+
117+
await expect(
118+
runWrangler(
119+
`containers registries configure 123456789012.dkr.ecr.region.amazonaws.com`
120+
)
121+
).rejects.toThrowErrorMatchingInlineSnapshot(
122+
`[Error: Missing required argument: aws-access-key-id]`
123+
);
124+
125+
await expect(
126+
runWrangler(
127+
`containers registries configure docker.io --public-credential=test-id --dockerhub-username=another-test-id`
128+
)
129+
).rejects.toThrowErrorMatchingInlineSnapshot(
130+
`[Error: Arguments public-credential and dockerhub-username are mutually exclusive]`
131+
);
132+
});
133+
84134
it("should no-op on cloudflare registry (default)", async () => {
85135
await runWrangler(
86-
`containers registries configure registry.cloudflare.com --public-credential=test-id`
136+
`containers registries configure registry.cloudflare.com`
87137
);
88138
expect(cliStd.stdout).toMatchInlineSnapshot(`
89139
"╭ Configure a container registry
90140
91-
│ Configuring Cloudflare Containers Managed Registry registry: registry.cloudflare.com
92-
93141
│ You do not need to configure credentials for Cloudflare managed registries.
94142
95143
╰ No configuration required
@@ -406,6 +454,95 @@ describe("containers registries configure", () => {
406454
});
407455
});
408456
});
457+
458+
describe("DockerHub registry configuration", () => {
459+
it("should configure DockerHub registry with interactive prompts", async () => {
460+
setIsTTY(true);
461+
const dockerHubDomain = "docker.io";
462+
const storeId = "test-store-id-123";
463+
mockPrompt({
464+
text: "Enter DockerHub PAT Token:",
465+
options: { isSecret: true },
466+
result: "test-pat-token",
467+
});
468+
mockPrompt({
469+
text: "Secret name:",
470+
options: { isSecret: false, defaultValue: "DockerHub_PAT_Token" },
471+
result: "DockerHub_PAT_Token",
472+
});
473+
474+
mockListSecretStores([
475+
{
476+
id: storeId,
477+
account_id: "some-account-id",
478+
name: "Default",
479+
created: "2024-01-01T00:00:00Z",
480+
modified: "2024-01-01T00:00:00Z",
481+
},
482+
]);
483+
mockListSecrets(storeId, []);
484+
mockCreateSecret(storeId);
485+
mockPutRegistry({
486+
domain: "docker.io",
487+
is_public: false,
488+
auth: {
489+
public_credential: "cloudchambertest",
490+
private_credential: {
491+
store_id: storeId,
492+
secret_name: "DockerHub_PAT_Token",
493+
},
494+
},
495+
kind: "DockerHub",
496+
});
497+
498+
await runWrangler(
499+
`containers registries configure ${dockerHubDomain} --dockerhub-username=cloudchambertest`
500+
);
501+
502+
expect(cliStd.stdout).toContain("Using existing Secret Store Default");
503+
});
504+
505+
describe("non-interactive", () => {
506+
beforeEach(() => {
507+
setIsTTY(false);
508+
});
509+
const dockerHubDomain = "docker.io";
510+
const mockStdIn = useMockStdin({ isTTY: false });
511+
512+
it("should accept the secret from piped input", async () => {
513+
const secret = "example-pat-token";
514+
const storeId = "test-store-id-999";
515+
516+
mockStdIn.send(secret);
517+
mockListSecretStores([
518+
{
519+
id: storeId,
520+
account_id: "some-account-id",
521+
name: "Default",
522+
created: "2024-01-01T00:00:00Z",
523+
modified: "2024-01-01T00:00:00Z",
524+
},
525+
]);
526+
mockListSecrets(storeId, []);
527+
mockCreateSecret(storeId);
528+
mockPutRegistry({
529+
domain: dockerHubDomain,
530+
is_public: false,
531+
auth: {
532+
public_credential: "cloudchambertest",
533+
private_credential: {
534+
store_id: storeId,
535+
secret_name: "DockerHub_PAT_Token",
536+
},
537+
},
538+
kind: "DockerHub",
539+
});
540+
await runWrangler(
541+
`containers registries configure ${dockerHubDomain} --public-credential=cloudchambertest --secret-name=DockerHub_PAT_Token`
542+
);
543+
});
544+
});
545+
});
409546
});
410547

411548
describe("containers registries list", () => {

0 commit comments

Comments
 (0)