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). diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 24ab7652983c..88bcffeff66a 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -660,6 +660,86 @@ describe("containers registries delete", () => { }); }); +describe("containers registries credentials", () => { + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + mockAccountId(); + mockApiToken(); + beforeEach(() => { + mockAccount(); + }); + + afterEach(() => { + 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.cloudflare.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.cloudflare.com", "test-password"); + + await runWrangler( + "containers registries credentials registry.cloudflare.com --push" + ); + + expect(std.out).toMatchInlineSnapshot(`"test-password"`); + }); + + it("should generate credentials with --pull", async () => { + setIsTTY(false); + mockGenerateCredentials("registry.cloudflare.com", "test-password"); + + await runWrangler( + "containers registries credentials registry.cloudflare.com --pull" + ); + + expect(std.out).toMatchInlineSnapshot(`"test-password"`); + }); + + it("should generate credentials with both --push and --pull", async () => { + setIsTTY(false); + mockGenerateCredentials("registry.cloudflare.com", "jwt-token"); + + await runWrangler( + "containers registries credentials registry.cloudflare.com --push --pull" + ); + + expect(std.out).toMatchInlineSnapshot(`"jwt-token"`); + }); + + it("should support custom expiration-minutes", async () => { + setIsTTY(false); + mockGenerateCredentials( + "registry.cloudflare.com", + "custom-expiry-token", + 30 + ); + + await runWrangler( + "containers registries credentials registry.cloudflare.com --push --expiration-minutes=30" + ); + + expect(std.out).toMatchInlineSnapshot(`"custom-expiry-token"`); + }); +}); + const mockPutRegistry = (expected?: object) => { msw.use( http.post( @@ -704,3 +784,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, + }) + ); + } + ) + ); +}; 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..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 { @@ -39,7 +40,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 +486,39 @@ async function registryDeleteCommand( } } +async function registryCredentialsCommand(credentialsArgs: { + DOMAIN: string; + expirationMinutes: number; + 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." + ); + } + + 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 +642,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,