Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tricky-frogs-fry.md
Original file line number Diff line number Diff line change
@@ -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).
105 changes: 105 additions & 0 deletions packages/wrangler/src/__tests__/containers/registries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
})
);
}
)
);
};
1 change: 1 addition & 0 deletions packages/wrangler/src/containers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
containersRegistriesConfigureCommand,
containersRegistriesListCommand,
containersRegistriesDeleteCommand,
containersRegistriesCredentialsCommand,
} from "./registries";

// Build and push commands
Expand Down
75 changes: 74 additions & 1 deletion packages/wrangler/src/containers/registries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
ApiError,
getAndValidateRegistryType,
getCloudflareContainerRegistry,
ImageRegistriesService,
} from "@cloudflare/containers-shared";
import {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
},
});
5 changes: 5 additions & 0 deletions packages/wrangler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
containersNamespace,
containersPushCommand,
containersRegistriesConfigureCommand,
containersRegistriesCredentialsCommand,
containersRegistriesDeleteCommand,
containersRegistriesListCommand,
containersRegistriesNamespace,
Expand Down Expand Up @@ -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,
Expand Down
Loading