From 9bed79e31bbf00e4e3d35439552aa5ffea022e3f Mon Sep 17 00:00:00 2001 From: amartinez Date: Thu, 26 Feb 2026 15:37:40 -0600 Subject: [PATCH 1/5] CC-7056: Add credentials subcommand to containers registries --- packages/wrangler/src/containers/index.ts | 1 + .../wrangler/src/containers/registries.ts | 68 ++++++++++++++++++- packages/wrangler/src/index.ts | 5 ++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/wrangler/src/containers/index.ts b/packages/wrangler/src/containers/index.ts index ba0e61975e5b..22ed9430c06f 100644 --- a/packages/wrangler/src/containers/index.ts +++ b/packages/wrangler/src/containers/index.ts @@ -25,6 +25,7 @@ export { containersRegistriesConfigureCommand, containersRegistriesListCommand, containersRegistriesDeleteCommand, + containersRegistriesCredentialsCommand, } from "./registries"; // Build and push commands diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 49be21d42500..8e4689d712a4 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -39,7 +39,10 @@ import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "../yargs-types"; -import type { DeleteImageRegistryResponse } from "@cloudflare/containers-shared"; +import type { + DeleteImageRegistryResponse, + ImageRegistryPermissions, +} from "@cloudflare/containers-shared"; import type { ImageRegistryAuth } from "@cloudflare/containers-shared/src/client/models/ImageRegistryAuth"; import type { Config } from "@cloudflare/workers-utils"; @@ -482,6 +485,33 @@ async function registryDeleteCommand( } } +async function registryCredentialsCommand(credentialsArgs: { + DOMAIN: string; + expirationMinutes: number; + push?: boolean; + pull?: boolean; +}) { + if (!credentialsArgs.pull && !credentialsArgs.push) { + throw new UserError( + "You have to specify either --push or --pull in the command." + ); + } + + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + credentialsArgs.DOMAIN, + { + expiration_minutes: credentialsArgs.expirationMinutes, + permissions: [ + ...(credentialsArgs.push ? ["push"] : []), + ...(credentialsArgs.pull ? ["pull"] : []), + ] as ImageRegistryPermissions[], + } + ); + logger.log(credentials.password); +} + + export const containersRegistriesNamespace = createNamespace({ metadata: { description: "Configure and manage non-Cloudflare registries", @@ -605,3 +635,39 @@ export const containersRegistriesDeleteCommand = createCommand({ await registryDeleteCommand(args, config); }, }); + +export const containersRegistriesCredentialsCommand = createCommand({ + metadata: { + description: "Get a temporary password for a specific domain", + status: "open beta", + owner: "Product: Cloudchamber", + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + DOMAIN: { + type: "string", + demandOption: true, + describe: "Domain to get credentials for", + }, + "expiration-minutes": { + type: "number", + default: 15, + description: "How long the credentials should be valid for (in minutes)", + }, + push: { + type: "boolean", + description: "If you want these credentials to be able to push", + }, + pull: { + type: "boolean", + description: "If you want these credentials to be able to pull", + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryCredentialsCommand(args); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 006faf77aa23..cd41b3465476 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -58,6 +58,7 @@ import { containersNamespace, containersPushCommand, containersRegistriesConfigureCommand, + containersRegistriesCredentialsCommand, containersRegistriesDeleteCommand, containersRegistriesListCommand, containersRegistriesNamespace, @@ -1534,6 +1535,10 @@ export function createCLIParser(argv: string[]) { command: "wrangler containers registries delete", definition: containersRegistriesDeleteCommand, }, + { + command: "wrangler containers registries credentials", + definition: containersRegistriesCredentialsCommand, + }, { command: "wrangler containers images", definition: containersImagesNamespace, From 102442a447625a730ffbba3afcdd3c22541e7f4c Mon Sep 17 00:00:00 2001 From: amartinez Date: Thu, 26 Feb 2026 15:40:19 -0600 Subject: [PATCH 2/5] CC-7056: Add tests for credentials subcommand --- .../__tests__/containers/registries.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 24ab7652983c..7d940176e7fa 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -660,6 +660,74 @@ describe("containers registries delete", () => { }); }); +describe("containers registries credentials", () => { + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + mockAccountId(); + mockApiToken(); + beforeEach(() => { + mockAccount(); + }); + + afterEach(() => { + msw.resetHandlers(); + }); + + it("should require --push or --pull", async () => { + setIsTTY(false); + await expect( + runWrangler("containers registries credentials registry.example.com") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You have to specify either --push or --pull in the command.]` + ); + }); + + it("should generate credentials with --push", async () => { + setIsTTY(false); + mockGenerateCredentials("registry.example.com", "test-password"); + + await runWrangler( + "containers registries credentials registry.example.com --push" + ); + + expect(std.out).toMatchInlineSnapshot(`"test-password"`); + }); + + it("should generate credentials with --pull", async () => { + setIsTTY(false); + mockGenerateCredentials("registry.example.com", "test-password"); + + await runWrangler( + "containers registries credentials registry.example.com --pull" + ); + + expect(std.out).toMatchInlineSnapshot(`"test-password"`); + }); + + it("should generate credentials with both --push and --pull", async () => { + setIsTTY(false); + mockGenerateCredentials("registry.example.com", "jwt-token"); + + await runWrangler( + "containers registries credentials registry.example.com --push --pull" + ); + + expect(std.out).toMatchInlineSnapshot(`"jwt-token"`); + }); + + it("should support custom expiration-minutes", async () => { + setIsTTY(false); + mockGenerateCredentials("registry.example.com", "custom-expiry-token", 30); + + await runWrangler( + "containers registries credentials registry.example.com --push --expiration-minutes=30" + ); + + expect(std.out).toMatchInlineSnapshot(`"custom-expiry-token"`); + }); +}); + + const mockPutRegistry = (expected?: object) => { msw.use( http.post( @@ -704,3 +772,28 @@ const mockDeleteRegistry = (domain: string, secretsStoreRef?: string) => { ) ); }; + +const mockGenerateCredentials = ( + domain: string, + password: string, + expectedExpirationMinutes?: number +) => { + msw.use( + http.post( + `*/accounts/:accountId/containers/registries/${domain}/credentials`, + async ({ request }) => { + if (expectedExpirationMinutes !== undefined) { + const body = (await request.json()) as { + expiration_minutes: number; + }; + expect(body.expiration_minutes).toBe(expectedExpirationMinutes); + } + return HttpResponse.json( + createFetchResult({ + password: password, + }) + ); + } + ) + ); +}; From a3d751342bf9a627e535db5650de6c000e4d3550 Mon Sep 17 00:00:00 2001 From: amartinez Date: Thu, 26 Feb 2026 15:40:30 -0600 Subject: [PATCH 3/5] Run prettify --- packages/wrangler/src/__tests__/containers/registries.test.ts | 1 - packages/wrangler/src/containers/registries.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 7d940176e7fa..e4a6ed4ba3a5 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -727,7 +727,6 @@ describe("containers registries credentials", () => { }); }); - const mockPutRegistry = (expected?: object) => { msw.use( http.post( diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 8e4689d712a4..a6e46533db55 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -511,7 +511,6 @@ async function registryCredentialsCommand(credentialsArgs: { logger.log(credentials.password); } - export const containersRegistriesNamespace = createNamespace({ metadata: { description: "Configure and manage non-Cloudflare registries", From 8a1a0589f721cf7728a3cd91d1e93210461fa35d Mon Sep 17 00:00:00 2001 From: amartinez Date: Thu, 26 Feb 2026 15:55:10 -0600 Subject: [PATCH 4/5] CC-7056: restrict credentials command to only the managed registry --- .../__tests__/containers/registries.test.ts | 31 +++++++++++++------ .../wrangler/src/containers/registries.ts | 8 +++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index e4a6ed4ba3a5..88bcffeff66a 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -673,10 +673,19 @@ describe("containers registries credentials", () => { msw.resetHandlers(); }); + it("should reject non-Cloudflare registry domains", async () => { + setIsTTY(false); + await expect( + runWrangler("containers registries credentials example.com --push") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The credentials command only works with the Cloudflare managed registry (registry.cloudflare.com).]` + ); + }); + it("should require --push or --pull", async () => { setIsTTY(false); await expect( - runWrangler("containers registries credentials registry.example.com") + runWrangler("containers registries credentials registry.cloudflare.com") ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: You have to specify either --push or --pull in the command.]` ); @@ -684,10 +693,10 @@ describe("containers registries credentials", () => { it("should generate credentials with --push", async () => { setIsTTY(false); - mockGenerateCredentials("registry.example.com", "test-password"); + mockGenerateCredentials("registry.cloudflare.com", "test-password"); await runWrangler( - "containers registries credentials registry.example.com --push" + "containers registries credentials registry.cloudflare.com --push" ); expect(std.out).toMatchInlineSnapshot(`"test-password"`); @@ -695,10 +704,10 @@ describe("containers registries credentials", () => { it("should generate credentials with --pull", async () => { setIsTTY(false); - mockGenerateCredentials("registry.example.com", "test-password"); + mockGenerateCredentials("registry.cloudflare.com", "test-password"); await runWrangler( - "containers registries credentials registry.example.com --pull" + "containers registries credentials registry.cloudflare.com --pull" ); expect(std.out).toMatchInlineSnapshot(`"test-password"`); @@ -706,10 +715,10 @@ describe("containers registries credentials", () => { it("should generate credentials with both --push and --pull", async () => { setIsTTY(false); - mockGenerateCredentials("registry.example.com", "jwt-token"); + mockGenerateCredentials("registry.cloudflare.com", "jwt-token"); await runWrangler( - "containers registries credentials registry.example.com --push --pull" + "containers registries credentials registry.cloudflare.com --push --pull" ); expect(std.out).toMatchInlineSnapshot(`"jwt-token"`); @@ -717,10 +726,14 @@ describe("containers registries credentials", () => { it("should support custom expiration-minutes", async () => { setIsTTY(false); - mockGenerateCredentials("registry.example.com", "custom-expiry-token", 30); + mockGenerateCredentials( + "registry.cloudflare.com", + "custom-expiry-token", + 30 + ); await runWrangler( - "containers registries credentials registry.example.com --push --expiration-minutes=30" + "containers registries credentials registry.cloudflare.com --push --expiration-minutes=30" ); expect(std.out).toMatchInlineSnapshot(`"custom-expiry-token"`); diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index a6e46533db55..15ae0dc2fc56 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -8,6 +8,7 @@ import { import { ApiError, getAndValidateRegistryType, + getCloudflareContainerRegistry, ImageRegistriesService, } from "@cloudflare/containers-shared"; import { @@ -491,6 +492,13 @@ async function registryCredentialsCommand(credentialsArgs: { push?: boolean; pull?: boolean; }) { + const cloudflareRegistry = getCloudflareContainerRegistry(); + if (credentialsArgs.DOMAIN !== cloudflareRegistry) { + throw new UserError( + `The credentials command only works with the Cloudflare managed registry (${cloudflareRegistry}).` + ); + } + if (!credentialsArgs.pull && !credentialsArgs.push) { throw new UserError( "You have to specify either --push or --pull in the command." From 5a350fa2e87dc3a30e2cb98326dbba280caa7257 Mon Sep 17 00:00:00 2001 From: amartinez Date: Thu, 26 Feb 2026 16:02:19 -0600 Subject: [PATCH 5/5] Add changeset --- .changeset/tricky-frogs-fry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tricky-frogs-fry.md diff --git a/.changeset/tricky-frogs-fry.md b/.changeset/tricky-frogs-fry.md new file mode 100644 index 000000000000..f91edd1ec1b1 --- /dev/null +++ b/.changeset/tricky-frogs-fry.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Add containers registries credentials command for generating temporary push/pull credentials. Only works with the Cloudflare managed registry (registry.cloudflare.com).