Skip to content

Commit 150ef7b

Browse files
martinezjandrewamartinez
andauthored
CC-7056: Add credentials subcommand to containers registries (#12693)
Co-authored-by: amartinez <amartinez@cloudflare.com>
1 parent 8d0c835 commit 150ef7b

File tree

5 files changed

+256
-1
lines changed

5 files changed

+256
-1
lines changed

‎.changeset/tricky-frogs-fry.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add `wrangler containers registries credentials` command for generating temporary push/pull credentials
6+
7+
This command generates short-lived credentials for authenticating with the Cloudflare managed registry (`registry.cloudflare.com`). Useful for CI/CD pipelines or local Docker authentication.
8+
9+
```bash
10+
# Generate push credentials (for uploading images)
11+
wrangler containers registries credentials registry.cloudflare.com --push
12+
13+
# Generate pull credentials (for downloading images)
14+
wrangler containers registries credentials registry.cloudflare.com --pull
15+
16+
# Generate credentials with both permissions
17+
wrangler containers registries credentials registry.cloudflare.com --push --pull
18+
19+
# Custom expiration (default 15)
20+
wrangler containers registries credentials registry.cloudflare.com --push --expiration-minutes=30
21+
```

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,125 @@ describe("containers registries delete", () => {
660660
});
661661
});
662662

663+
describe("containers registries credentials", () => {
664+
const { setIsTTY } = useMockIsTTY();
665+
const std = mockConsoleMethods();
666+
mockAccountId();
667+
mockApiToken();
668+
beforeEach(() => {
669+
mockAccount();
670+
});
671+
672+
afterEach(() => {
673+
msw.resetHandlers();
674+
});
675+
676+
it("should reject non-Cloudflare registry domains", async () => {
677+
setIsTTY(false);
678+
await expect(
679+
runWrangler("containers registries credentials example.com --push")
680+
).rejects.toThrowErrorMatchingInlineSnapshot(
681+
`[Error: The credentials command only accepts the Cloudflare managed registry (registry.cloudflare.com).]`
682+
);
683+
});
684+
685+
it("should default to Cloudflare registry when DOMAIN is omitted", async () => {
686+
setIsTTY(false);
687+
mockGenerateCredentials("registry.cloudflare.com", "test-password", 15, [
688+
"push",
689+
]);
690+
691+
await runWrangler("containers registries credentials --push");
692+
693+
expect(std.out).toMatchInlineSnapshot(`"test-password"`);
694+
});
695+
696+
it("should require --push or --pull", async () => {
697+
setIsTTY(false);
698+
await expect(
699+
runWrangler("containers registries credentials registry.cloudflare.com")
700+
).rejects.toThrowErrorMatchingInlineSnapshot(
701+
`[Error: You have to specify either --push or --pull in the command.]`
702+
);
703+
});
704+
705+
it("should generate credentials with --push", async () => {
706+
setIsTTY(false);
707+
mockGenerateCredentials("registry.cloudflare.com", "test-password", 15, [
708+
"push",
709+
]);
710+
711+
await runWrangler(
712+
"containers registries credentials registry.cloudflare.com --push"
713+
);
714+
715+
expect(std.out).toMatchInlineSnapshot(`"test-password"`);
716+
});
717+
718+
it("should generate credentials with --pull", async () => {
719+
setIsTTY(false);
720+
mockGenerateCredentials("registry.cloudflare.com", "test-password", 15, [
721+
"pull",
722+
]);
723+
724+
await runWrangler(
725+
"containers registries credentials registry.cloudflare.com --pull"
726+
);
727+
728+
expect(std.out).toMatchInlineSnapshot(`"test-password"`);
729+
});
730+
731+
it("should generate credentials with both --push and --pull", async () => {
732+
setIsTTY(false);
733+
mockGenerateCredentials("registry.cloudflare.com", "jwt-token", 15, [
734+
"push",
735+
"pull",
736+
]);
737+
738+
await runWrangler(
739+
"containers registries credentials registry.cloudflare.com --push --pull"
740+
);
741+
742+
expect(std.out).toMatchInlineSnapshot(`"jwt-token"`);
743+
});
744+
745+
it("should support custom expiration-minutes", async () => {
746+
setIsTTY(false);
747+
mockGenerateCredentials(
748+
"registry.cloudflare.com",
749+
"custom-expiry-token",
750+
30,
751+
["push"]
752+
);
753+
754+
await runWrangler(
755+
"containers registries credentials registry.cloudflare.com --push --expiration-minutes=30"
756+
);
757+
758+
expect(std.out).toMatchInlineSnapshot(`"custom-expiry-token"`);
759+
});
760+
761+
it("should output valid JSON when --json flag is used", async () => {
762+
setIsTTY(false);
763+
mockGenerateCredentials("registry.cloudflare.com", "test-password", 15, [
764+
"push",
765+
]);
766+
767+
await runWrangler(
768+
"containers registries credentials registry.cloudflare.com --push --json"
769+
);
770+
771+
expect(JSON.parse(std.out)).toMatchInlineSnapshot(`
772+
{
773+
"account_id": "some-account-id",
774+
"password": "test-password",
775+
"registry_host": "registry.cloudflare.com",
776+
"username": "test-username",
777+
}
778+
`);
779+
});
780+
});
781+
663782
const mockPutRegistry = (expected?: object) => {
664783
msw.use(
665784
http.post(
@@ -704,3 +823,32 @@ const mockDeleteRegistry = (domain: string, secretsStoreRef?: string) => {
704823
)
705824
);
706825
};
826+
827+
const mockGenerateCredentials = (
828+
domain: string,
829+
password: string,
830+
expectedExpirationMinutes: number,
831+
expectedPermissions: string[]
832+
) => {
833+
msw.use(
834+
http.post(
835+
`*/accounts/:accountId/containers/registries/${domain}/credentials`,
836+
async ({ request, params }) => {
837+
const body = (await request.json()) as {
838+
expiration_minutes: number;
839+
permissions: string[];
840+
};
841+
expect(body.expiration_minutes).toBe(expectedExpirationMinutes);
842+
expect(body.permissions).toEqual(expectedPermissions);
843+
return HttpResponse.json(
844+
createFetchResult({
845+
account_id: params.accountId,
846+
registry_host: domain,
847+
username: "test-username",
848+
password: password,
849+
})
850+
);
851+
}
852+
)
853+
);
854+
};

‎packages/wrangler/src/containers/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
containersRegistriesConfigureCommand,
2626
containersRegistriesListCommand,
2727
containersRegistriesDeleteCommand,
28+
containersRegistriesCredentialsCommand,
2829
} from "./registries";
2930

3031
// Build and push commands

‎packages/wrangler/src/containers/registries.ts‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {
99
ApiError,
1010
getAndValidateRegistryType,
11+
getCloudflareContainerRegistry,
1112
ImageRegistriesService,
1213
} from "@cloudflare/containers-shared";
1314
import {
@@ -39,7 +40,10 @@ import type {
3940
CommonYargsArgv,
4041
StrictYargsOptionsToInterface,
4142
} from "../yargs-types";
42-
import type { DeleteImageRegistryResponse } from "@cloudflare/containers-shared";
43+
import type {
44+
DeleteImageRegistryResponse,
45+
ImageRegistryPermissions,
46+
} from "@cloudflare/containers-shared";
4347
import type { ImageRegistryAuth } from "@cloudflare/containers-shared/src/client/models/ImageRegistryAuth";
4448
import type { Config } from "@cloudflare/workers-utils";
4549

@@ -482,6 +486,42 @@ async function registryDeleteCommand(
482486
}
483487
}
484488

489+
async function registryCredentialsCommand(credentialsArgs: {
490+
DOMAIN?: string;
491+
expirationMinutes: number;
492+
push?: boolean;
493+
pull?: boolean;
494+
json?: boolean;
495+
}) {
496+
const cloudflareRegistry = getCloudflareContainerRegistry();
497+
const domain = credentialsArgs.DOMAIN || cloudflareRegistry;
498+
if (domain !== cloudflareRegistry) {
499+
throw new UserError(
500+
`The credentials command only accepts the Cloudflare managed registry (${cloudflareRegistry}).`
501+
);
502+
}
503+
504+
if (!credentialsArgs.pull && !credentialsArgs.push) {
505+
throw new UserError(
506+
"You have to specify either --push or --pull in the command."
507+
);
508+
}
509+
510+
const credentials =
511+
await ImageRegistriesService.generateImageRegistryCredentials(domain, {
512+
expiration_minutes: credentialsArgs.expirationMinutes,
513+
permissions: [
514+
...(credentialsArgs.push ? ["push"] : []),
515+
...(credentialsArgs.pull ? ["pull"] : []),
516+
] as ImageRegistryPermissions[],
517+
});
518+
if (credentialsArgs.json) {
519+
logger.json(credentials);
520+
} else {
521+
logger.log(credentials.password);
522+
}
523+
}
524+
485525
export const containersRegistriesNamespace = createNamespace({
486526
metadata: {
487527
description: "Configure and manage non-Cloudflare registries",
@@ -605,3 +645,43 @@ export const containersRegistriesDeleteCommand = createCommand({
605645
await registryDeleteCommand(args, config);
606646
},
607647
});
648+
649+
export const containersRegistriesCredentialsCommand = createCommand({
650+
metadata: {
651+
description: "Get a temporary password for a specific domain",
652+
status: "open beta",
653+
owner: "Product: Cloudchamber",
654+
},
655+
behaviour: {
656+
printBanner: (args) => !args.json && !isNonInteractiveOrCI(),
657+
},
658+
args: {
659+
DOMAIN: {
660+
type: "string",
661+
describe: "Domain to get credentials for",
662+
},
663+
"expiration-minutes": {
664+
type: "number",
665+
default: 15,
666+
description: "How long the credentials should be valid for (in minutes)",
667+
},
668+
push: {
669+
type: "boolean",
670+
description: "If you want these credentials to be able to push",
671+
},
672+
pull: {
673+
type: "boolean",
674+
description: "If you want these credentials to be able to pull",
675+
},
676+
json: {
677+
type: "boolean",
678+
description: "Format output as JSON",
679+
default: false,
680+
},
681+
},
682+
positionalArgs: ["DOMAIN"],
683+
async handler(args, { config }) {
684+
await fillOpenAPIConfiguration(config, containersScope);
685+
await registryCredentialsCommand(args);
686+
},
687+
});

‎packages/wrangler/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
containersNamespace,
5959
containersPushCommand,
6060
containersRegistriesConfigureCommand,
61+
containersRegistriesCredentialsCommand,
6162
containersRegistriesDeleteCommand,
6263
containersRegistriesListCommand,
6364
containersRegistriesNamespace,
@@ -1534,6 +1535,10 @@ export function createCLIParser(argv: string[]) {
15341535
command: "wrangler containers registries delete",
15351536
definition: containersRegistriesDeleteCommand,
15361537
},
1538+
{
1539+
command: "wrangler containers registries credentials",
1540+
definition: containersRegistriesCredentialsCommand,
1541+
},
15371542
{
15381543
command: "wrangler containers images",
15391544
definition: containersImagesNamespace,

0 commit comments

Comments
 (0)