diff --git a/.changeset/common-boats-help.md b/.changeset/common-boats-help.md new file mode 100644 index 0000000000..d414f1f9eb --- /dev/null +++ b/.changeset/common-boats-help.md @@ -0,0 +1,16 @@ +--- +"@cloudflare/containers-shared": minor +"wrangler": minor +--- + +Add Google Artifact Registry support to `containers registries configure` + +`wrangler containers registries configure` now recognizes `*-docker.pkg.dev` (Google Artifact Registry) domains. + +- The Google service account email is the public credential, supplied with `--gar-email`. It must match the `client_email` in the service account key. +- The service account JSON key is the private credential. It is provided via stdin (a file path, raw JSON, or base64) or an interactive prompt (a file path or base64) — never as a CLI flag, so it does not appear in shell history. The key is validated against `--gar-email` and stored base64-encoded. +- Secret reuse inherits the existence-first flow: when the target Secrets Store secret already exists, it is reused by reference and the key is not required. In that case the email cannot be verified locally; it is validated against the key when images are pulled. + +```sh +.json | npx wrangler@latest containers registries configure -docker.pkg.dev --gar-email= --secret-name=Google_Service_Account_JSON_Key +``` diff --git a/packages/containers-shared/src/client/models/ExternalRegistryKind.ts b/packages/containers-shared/src/client/models/ExternalRegistryKind.ts index 30ae943c70..195a877bcf 100644 --- a/packages/containers-shared/src/client/models/ExternalRegistryKind.ts +++ b/packages/containers-shared/src/client/models/ExternalRegistryKind.ts @@ -8,4 +8,5 @@ export enum ExternalRegistryKind { ECR = "ECR", DOCKER_HUB = "DockerHub", + GAR = "GAR", } diff --git a/packages/containers-shared/src/images.ts b/packages/containers-shared/src/images.ts index a2ba7cccec..413a97336e 100644 --- a/packages/containers-shared/src/images.ts +++ b/packages/containers-shared/src/images.ts @@ -226,7 +226,8 @@ export function resolveImageName(accountId: string, image: string): string { /** * Get type of container registry, and validate. - * Currently we support Cloudflare managed registries and AWS ECR. + * We support Cloudflare managed registries plus the external registries listed in + * `acceptedRegistries` below (currently AWS ECR, DockerHub, and Google Artifact Registry). * When using Cloudflare managed registries we expect CLOUDFLARE_CONTAINER_REGISTRY to be set */ export const getAndValidateRegistryType = (domain: string): RegistryPattern => { @@ -259,6 +260,12 @@ export const getAndValidateRegistryType = (domain: string): RegistryPattern => { name: "DockerHub", secretType: "DockerHub PAT Token", }, + { + type: ExternalRegistryKind.GAR, + pattern: /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?-docker\.pkg\.dev$/, + name: "Google Artifact Registry", + secretType: "Google Service Account JSON Key", + }, { type: "cloudflare", // Make a regex based on the env var CLOUDFLARE_CONTAINER_REGISTRY @@ -293,3 +300,136 @@ interface RegistryPattern { pattern: RegExp; name: string; } + +type ServiceAccountKey = { + private_key: string; + client_email: string; + private_key_id?: string; +}; + +function invalidGarCredentialError(): UserError { + return new UserError( + "The Google service account key must be a JSON key file or its base64-encoded form.", + { + telemetryMessage: + "containers registries configure invalid gar credential", + } + ); +} + +function tryParseJson(value: string): unknown | undefined { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function assertJsonObject(parsed: unknown): Record { + if (typeof parsed !== "object" || parsed === null) { + throw invalidGarCredentialError(); + } + return parsed as Record; +} + +function validateServiceAccountKey( + accountKey: Record +): ServiceAccountKey { + const privateKey = accountKey.private_key; + const clientEmail = accountKey.client_email; + const rawPrivateKeyId = accountKey.private_key_id; + if (typeof privateKey !== "string" || typeof clientEmail !== "string") { + throw new UserError( + "The Google service account key is missing required fields (private_key, client_email).", + { + telemetryMessage: + "containers registries configure gar credential missing fields", + } + ); + } + if (privateKey.length === 0 || clientEmail.length === 0) { + throw new UserError( + "The Google service account key has an empty private_key or client_email.", + { + telemetryMessage: + "containers registries configure gar credential empty fields", + } + ); + } + let privateKeyId: string | undefined; + if (rawPrivateKeyId === undefined) { + privateKeyId = undefined; + } else if ( + typeof rawPrivateKeyId === "string" && + rawPrivateKeyId.length > 0 + ) { + privateKeyId = rawPrivateKeyId; + } else { + throw new UserError( + "The Google service account key has an empty or invalid private_key_id.", + { + telemetryMessage: + "containers registries configure gar credential invalid private key id", + } + ); + } + return { + private_key: privateKey, + client_email: clientEmail, + private_key_id: privateKeyId, + }; +} + +/** + * Validates a Google service account JSON key and returns it base64-encoded for + * storage as the private credential. + * + * Accepts the raw JSON key contents or its base64-encoded form. Throws a + * `UserError` if the key is malformed, or if `expectedEmail` (the + * `--gar-email` public credential) does not match the `client_email` in the key. + */ +export function validateAndEncodeGarKey( + rawKey: string, + expectedEmail: string +): string { + const trimmed = rawKey.trim(); + + let base64Key: string; + let json: Record; + const rawJson = tryParseJson(trimmed); + if (rawJson !== undefined) { + json = assertJsonObject(rawJson); + base64Key = Buffer.from(trimmed, "utf8").toString("base64"); + } else { + if (trimmed.startsWith("-----BEGIN")) { + throw new UserError( + "The provided key appears to be a PEM private key. Provide the full Google service-account JSON key file, not just the private key.", + { + telemetryMessage: + "containers registries configure gar credential pem key", + } + ); + } + base64Key = trimmed.replace(/\s+/g, ""); + const decodedJson = tryParseJson( + Buffer.from(base64Key, "base64").toString("utf8") + ); + if (decodedJson === undefined) { + throw invalidGarCredentialError(); + } + json = assertJsonObject(decodedJson); + } + + const key = validateServiceAccountKey(json); + + if (key.client_email !== expectedEmail) { + throw new UserError( + `The provided --gar-email "${expectedEmail}" does not match the service account email "${key.client_email}" in the key.`, + { + telemetryMessage: "containers registries configure gar email mismatch", + } + ); + } + + return base64Key; +} diff --git a/packages/containers-shared/tests/images.test.ts b/packages/containers-shared/tests/images.test.ts index 23de1bd328..f6c7677bb0 100644 --- a/packages/containers-shared/tests/images.test.ts +++ b/packages/containers-shared/tests/images.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, it, vi } from "vitest"; +import { ExternalRegistryKind } from "../src/client/models/ExternalRegistryKind"; import { getEgressInterceptorPlatform, pullEgressInterceptorImage, + getAndValidateRegistryType, + validateAndEncodeGarKey, } from "../src/images"; import { runDockerCmd } from "../src/utils"; @@ -66,3 +69,228 @@ describe("pullEgressInterceptorImage", () => { ]); }); }); + +const garDomain = "us-central1-docker.pkg.dev"; +const clientEmail = "wrangler-test@test-project.iam.gserviceaccount.com"; +const serviceAccountKey = JSON.stringify({ + type: "service_account", + project_id: "test-project", + private_key_id: "test-key-id", + private_key: "fake-private-key", + client_email: clientEmail, + client_id: "123456789", +}); + +describe("getAndValidateRegistryType - GAR", () => { + it("recognizes a *-docker.pkg.dev domain as GAR", ({ expect }) => { + const gar = getAndValidateRegistryType(garDomain); + expect(gar.type).toBe(ExternalRegistryKind.GAR); + expect(gar.name).toBe("Google Artifact Registry"); + expect(gar.secretType).toBe("Google Service Account JSON Key"); + }); + + const validGarDomains = [ + "us-docker.pkg.dev", + "us-central1-docker.pkg.dev", + "europe-west1-docker.pkg.dev", + "asia-docker.pkg.dev", + "northamerica-northeast1-docker.pkg.dev", + "a-docker.pkg.dev", + ]; + for (const domain of validGarDomains) { + it(`recognizes ${domain} as GAR`, ({ expect }) => { + expect(getAndValidateRegistryType(domain).type).toBe( + ExternalRegistryKind.GAR + ); + }); + } + + const invalidGarDomains = [ + "gcr.io", + "us.gcr.io", + "docker.pkg.dev", // missing the `-` prefix + "us-central1.docker.pkg.dev", // dot instead of hyphen before docker + "foo.bar-docker.pkg.dev", // dotted prefix is not a single location label + "us-central1-docker.pkg.dev.evil.com", // trailing suffix (anchored regex) + "us-central1-docker.pkg.deviant", // wrong TLD-like suffix + ]; + for (const domain of invalidGarDomains) { + it(`rejects ${domain}`, ({ expect }) => { + expect(() => getAndValidateRegistryType(domain)).toThrowError( + `${domain} is not a supported image registry` + ); + }); + } + + it("does not treat ECR or DockerHub domains as GAR", ({ expect }) => { + expect( + getAndValidateRegistryType("123456789012.dkr.ecr.us-west-2.amazonaws.com") + .type + ).toBe(ExternalRegistryKind.ECR); + expect(getAndValidateRegistryType("docker.io").type).toBe( + ExternalRegistryKind.DOCKER_HUB + ); + }); +}); + +describe("validateAndEncodeGarKey", () => { + it("base64-encodes a raw JSON key when the email matches", ({ expect }) => { + expect(validateAndEncodeGarKey(serviceAccountKey, clientEmail)).toBe( + Buffer.from(serviceAccountKey, "utf8").toString("base64") + ); + }); + + it("trims surrounding whitespace before encoding", ({ expect }) => { + expect( + validateAndEncodeGarKey(`\n ${serviceAccountKey} \n`, clientEmail) + ).toBe(Buffer.from(serviceAccountKey, "utf8").toString("base64")); + }); + + it("passes through an already base64-encoded key unchanged", ({ expect }) => { + const base64Key = Buffer.from(serviceAccountKey, "utf8").toString("base64"); + expect(validateAndEncodeGarKey(base64Key, clientEmail)).toBe(base64Key); + }); + + it("accepts a key that is missing the optional private_key_id", ({ + expect, + }) => { + const keyWithoutPrivateKeyId = JSON.stringify({ + type: "service_account", + project_id: "test-project", + private_key: "fake-private-key", + client_email: clientEmail, + client_id: "123456789", + }); + + expect(validateAndEncodeGarKey(keyWithoutPrivateKeyId, clientEmail)).toBe( + Buffer.from(keyWithoutPrivateKeyId, "utf8").toString("base64") + ); + }); + + it("accepts unused service-account key fields", ({ expect }) => { + const keyWithUnusedFields = JSON.stringify({ + private_key: "fake-private-key", + client_email: clientEmail, + private_key_id: "test-key-id", + token_uri: "https://oauth2.googleapis.com/token", + universe_domain: "googleapis.com", + unused_nested: { value: true }, + }); + + expect(validateAndEncodeGarKey(keyWithUnusedFields, clientEmail)).toBe( + Buffer.from(keyWithUnusedFields, "utf8").toString("base64") + ); + }); + + it("throws when --gar-email does not match the key's client_email", ({ + expect, + }) => { + expect(() => + validateAndEncodeGarKey(serviceAccountKey, "wrong@example.com") + ).toThrowErrorMatchingInlineSnapshot( + `[Error: The provided --gar-email "wrong@example.com" does not match the service account email "wrangler-test@test-project.iam.gserviceaccount.com" in the key.]` + ); + }); + + it("rejects input that is neither JSON nor base64-encoded JSON", ({ + expect, + }) => { + expect(() => + validateAndEncodeGarKey("this is not a valid key", clientEmail) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key must be a JSON key file or its base64-encoded form.]" + ); + }); + + it("rejects a JSON value that is not an object", ({ expect }) => { + expect(() => + validateAndEncodeGarKey("null", clientEmail) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key must be a JSON key file or its base64-encoded form.]" + ); + }); + + it("gives a targeted error when only a PEM private key is provided", ({ + expect, + }) => { + const pem = + "-----BEGIN PRIVATE KEY-----\nMIIfakekeydata\n-----END PRIVATE KEY-----\n"; + expect(() => + validateAndEncodeGarKey(pem, clientEmail) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The provided key appears to be a PEM private key. Provide the full Google service-account JSON key file, not just the private key.]" + ); + }); + + it("rejects a JSON string containing a base64-encoded key", ({ expect }) => { + const base64Key = Buffer.from(serviceAccountKey, "utf8").toString("base64"); + expect(() => + validateAndEncodeGarKey(JSON.stringify(base64Key), clientEmail) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key must be a JSON key file or its base64-encoded form.]" + ); + }); + + it("rejects a key missing private_key", ({ expect }) => { + expect(() => + validateAndEncodeGarKey( + JSON.stringify({ client_email: clientEmail }), + clientEmail + ) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key is missing required fields (private_key, client_email).]" + ); + }); + + it("rejects a key missing client_email", ({ expect }) => { + expect(() => + validateAndEncodeGarKey( + JSON.stringify({ private_key: "fake-private-key" }), + clientEmail + ) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key is missing required fields (private_key, client_email).]" + ); + }); + + it("rejects a key with an empty private_key", ({ expect }) => { + expect(() => + validateAndEncodeGarKey( + JSON.stringify({ private_key: "", client_email: clientEmail }), + clientEmail + ) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key has an empty private_key or client_email.]" + ); + }); + + it("rejects a key with an empty private_key_id", ({ expect }) => { + expect(() => + validateAndEncodeGarKey( + JSON.stringify({ + private_key: "fake-private-key", + client_email: clientEmail, + private_key_id: "", + }), + clientEmail + ) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key has an empty or invalid private_key_id.]" + ); + }); + + it("rejects a key with a non-string private_key_id", ({ expect }) => { + expect(() => + validateAndEncodeGarKey( + JSON.stringify({ + private_key: "fake-private-key", + client_email: clientEmail, + private_key_id: 42, + }), + clientEmail + ) + ).toThrowErrorMatchingInlineSnapshot( + "[Error: The Google service account key has an empty or invalid private_key_id.]" + ); + }); +}); diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 509c203ed8..57d85f826b 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -1,3 +1,5 @@ +import { writeFileSync } from "node:fs"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { mockAccount } from "../cloudchamber/utils"; @@ -73,7 +75,7 @@ describe("containers registries configure", () => { ) ).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: unsupported.domain is not a supported image registry. - Currently we support the following non-Cloudflare registries: AWS ECR, DockerHub. + Currently we support the following non-Cloudflare registries: AWS ECR, DockerHub, Google Artifact Registry. To use an existing image from another repository, see https://developers.cloudflare.com/containers/platform-details/image-management/#using-pre-built-container-images] `); }); @@ -141,6 +143,34 @@ describe("containers registries configure", () => { ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: Arguments public-credential and dockerhub-username are mutually exclusive]` ); + + await expect( + runWrangler( + `containers registries configure us-central1-docker.pkg.dev --gar-email=test@example.com --aws-access-key-id=test-id` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Arguments gar-email and aws-access-key-id are mutually exclusive]` + ); + }); + + it("should reject provider-specific credential flags used with the wrong registry", async ({ + expect, + }) => { + await expect( + runWrangler( + `containers registries configure docker.io --gar-email=test@example.com` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: --gar-email can only be used with Google Artifact Registry.]` + ); + + await expect( + runWrangler( + `containers registries configure us-central1-docker.pkg.dev --dockerhub-username=cloudchambertest` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: --dockerhub-username can only be used with DockerHub.]` + ); }); it("should no-op on cloudflare registry (default)", async ({ expect }) => { @@ -707,6 +737,386 @@ describe("containers registries configure", () => { }); }); }); + + describe("Google Artifact Registry configuration", () => { + runInTempDir(); + const garDomain = "us-central1-docker.pkg.dev"; + const garEmail = "wrangler-test@test-project.iam.gserviceaccount.com"; + const serviceAccountKey = JSON.stringify({ + type: "service_account", + project_id: "test-project", + private_key_id: "test-key-id", + private_key: "fake-private-key", + client_email: garEmail, + client_id: "123456789", + }); + const base64Key = Buffer.from(serviceAccountKey, "utf8").toString("base64"); + + describe("interactive", () => { + beforeEach(() => { + setIsTTY(true); + }); + + it("should configure GAR with a key file path", async ({ expect }) => { + const storeId = "test-store-id-gar"; + writeFileSync("gar-key.json", serviceAccountKey); + // Existence-first: the secret name is resolved before the key is read. + mockPrompt({ + text: "Secret name:", + options: { + isSecret: false, + defaultValue: "Google_Service_Account_JSON_Key", + }, + result: "Google_Service_Account_JSON_Key", + }); + mockPrompt({ + text: "Enter Google Service Account JSON Key (file path or base64):", + options: { isSecret: true }, + result: "gar-key.json", + }); + + mockListSecretStores([ + { + id: storeId, + account_id: "some-account-id", + name: "Default", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + }, + ]); + mockListSecrets(storeId, []); + mockCreateSecret(storeId); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: garEmail, + private_credential: { + store_id: storeId, + secret_name: "Google_Service_Account_JSON_Key", + }, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=${garEmail}` + ); + + expect(cliStd.stdout).toContain("Using existing Secret Store Default"); + }); + + it("should configure GAR with base64 key contents", async ({ + expect, + }) => { + const storeId = "test-store-id-gar"; + mockPrompt({ + text: "Secret name:", + options: { + isSecret: false, + defaultValue: "Google_Service_Account_JSON_Key", + }, + result: "Google_Service_Account_JSON_Key", + }); + mockPrompt({ + text: "Enter Google Service Account JSON Key (file path or base64):", + options: { isSecret: true }, + result: base64Key, + }); + + mockListSecretStores([ + { + id: storeId, + account_id: "some-account-id", + name: "Default", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + }, + ]); + mockListSecrets(storeId, []); + mockCreateSecret(storeId); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: garEmail, + private_credential: { + store_id: storeId, + secret_name: "Google_Service_Account_JSON_Key", + }, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=${garEmail}` + ); + + expect(cliStd.stdout).toContain("Using existing Secret Store Default"); + }); + + it("should reuse an existing secret without prompting for the key", async ({ + expect, + }) => { + const providedStoreId = "provided-store-id-gar-reuse"; + const secretName = "existing_gar_secret"; + + mockConfirm({ + text: "Do you want to reuse the existing secret? If not, then you'll be prompted to pick a new name.", + result: true, + }); + mockListSecrets(providedStoreId, [ + { + id: "existing-secret-id", + store_id: providedStoreId, + name: secretName, + comment: "", + scopes: ["containers"], + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + status: "active", + }, + ]); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: garEmail, + private_credential: { + store_id: providedStoreId, + secret_name: secretName, + }, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=${garEmail} --secret-store-id=${providedStoreId} --secret-name=${secretName}` + ); + + expect(cliStd.stdout).not.toContain("created in Secrets Store"); + expect(cliStd.stdout).toContain( + "Wrangler cannot verify it matches --gar-email" + ); + }); + }); + + describe("non-interactive", () => { + beforeEach(() => { + setIsTTY(false); + }); + const mockStdIn = useMockStdin({ isTTY: false }); + + it("should accept the key from piped stdin when --gar-email matches", async ({ + expect, + }) => { + const storeId = "test-store-id-gar"; + mockStdIn.send(serviceAccountKey); + mockListSecretStores([ + { + id: storeId, + account_id: "some-account-id", + name: "Default", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + }, + ]); + mockListSecrets(storeId, []); + mockCreateSecret(storeId); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: garEmail, + private_credential: { + store_id: storeId, + secret_name: "Google_Service_Account_JSON_Key", + }, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=${garEmail} --secret-name=Google_Service_Account_JSON_Key` + ); + }); + + it("should reject when --gar-email does not match the key", async ({ + expect, + }) => { + const storeId = "test-store-id-gar"; + mockStdIn.send(serviceAccountKey); + mockListSecretStores([ + { + id: storeId, + account_id: "some-account-id", + name: "Default", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + }, + ]); + mockListSecrets(storeId, []); + + await expect( + runWrangler( + `containers registries configure ${garDomain} --gar-email=wrong@example.com --secret-name=Google_Service_Account_JSON_Key` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The provided --gar-email "wrong@example.com" does not match the service account email "wrangler-test@test-project.iam.gserviceaccount.com" in the key.]` + ); + }); + + it("should reuse an existing secret without requiring the key (with warning)", async ({ + expect, + }) => { + const storeId = "test-store-id-gar"; + const secretName = "existing_gar_secret"; + // No key is piped: the existing secret is reused by reference. + mockListSecretStores([ + { + id: storeId, + account_id: "some-account-id", + name: "Default", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + }, + ]); + mockListSecrets(storeId, [ + { + id: "existing-secret-id", + store_id: storeId, + name: secretName, + comment: "", + scopes: ["containers"], + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + status: "active", + }, + ]); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: garEmail, + private_credential: { + store_id: storeId, + secret_name: secretName, + }, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=${garEmail} --secret-name=${secretName} --skip-confirmation` + ); + + expect(cliStd.stdout).not.toContain("created in Secrets Store"); + expect(cliStd.stdout).toContain( + "Wrangler cannot verify it matches --gar-email" + ); + }); + + it("should ignore a piped key when reusing an existing secret", async ({ + expect, + }) => { + const storeId = "test-store-id-gar"; + const secretName = "existing_gar_secret"; + + mockStdIn.send(serviceAccountKey); + mockListSecretStores([ + { + id: storeId, + account_id: "some-account-id", + name: "Default", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + }, + ]); + mockListSecrets(storeId, [ + { + id: "existing-secret-id", + store_id: storeId, + name: secretName, + comment: "", + scopes: ["containers"], + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + status: "active", + }, + ]); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: "different@example.com", + private_credential: { + store_id: storeId, + secret_name: secretName, + }, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=different@example.com --secret-name=${secretName} --skip-confirmation` + ); + + expect(cliStd.stdout).not.toContain("created in Secrets Store"); + expect(cliStd.stdout).toContain( + "Wrangler cannot verify it matches --gar-email" + ); + }); + + it("should require --gar-email", async ({ expect }) => { + await expect( + runWrangler(`containers registries configure ${garDomain}`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing required argument: gar-email]` + ); + }); + }); + + describe("FedRAMP compliance region", () => { + beforeEach(() => { + vi.stubEnv("CLOUDFLARE_COMPLIANCE_REGION", "fedramp_high"); + setIsTTY(false); + }); + const mockStdIn = useMockStdin({ isTTY: false }); + + it("should validate and encode the key inline when --gar-email matches", async ({ + expect, + }) => { + mockStdIn.send(serviceAccountKey); + mockPutRegistry(expect, { + domain: garDomain, + is_public: false, + auth: { + public_credential: garEmail, + private_credential: base64Key, + }, + kind: "GAR", + }); + + await runWrangler( + `containers registries configure ${garDomain} --gar-email=${garEmail} --disable-secrets-store` + ); + }); + + it("should reject inline when --gar-email does not match the key", async ({ + expect, + }) => { + mockStdIn.send(serviceAccountKey); + + await expect( + runWrangler( + `containers registries configure ${garDomain} --gar-email=wrong@example.com --disable-secrets-store` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The provided --gar-email "wrong@example.com" does not match the service account email "wrangler-test@test-project.iam.gserviceaccount.com" in the key.]` + ); + }); + }); + }); }); describe("containers registries list", () => { diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 7f24aa9b2a..40544d2f2a 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { cancel, endSection, @@ -10,11 +11,13 @@ import { ExternalRegistryKind, getAndValidateRegistryType, getCloudflareContainerRegistry, + validateAndEncodeGarKey, ImageRegistriesService, } from "@cloudflare/containers-shared"; import { APIError, getCloudflareComplianceRegion, + readFileSync, UserError, } from "@cloudflare/workers-utils"; import { @@ -45,6 +48,44 @@ import type { } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; +const providerSpecificCredentialFlags = [ + { + argName: "awsAccessKeyId", + flagName: "aws-access-key-id", + registryType: ExternalRegistryKind.ECR, + registryName: "AWS ECR", + telemetryMessage: + "containers registries configure aws access key id unsupported registry", + }, + { + argName: "dockerhubUsername", + flagName: "dockerhub-username", + registryType: ExternalRegistryKind.DOCKER_HUB, + registryName: "DockerHub", + telemetryMessage: + "containers registries configure dockerhub username unsupported registry", + }, + { + argName: "garEmail", + flagName: "gar-email", + registryType: ExternalRegistryKind.GAR, + registryName: "Google Artifact Registry", + telemetryMessage: + "containers registries configure gar email unsupported registry", + }, +] as const; + +const providerSpecificCredentialFlagNames = providerSpecificCredentialFlags.map( + (flag) => flag.flagName +); + +function providerSpecificCredentialFlagConflicts(flagName: string): string[] { + return [ + "public-credential", + ...providerSpecificCredentialFlagNames.filter((name) => name !== flagName), + ]; +} + const registryConfigureArgs = { DOMAIN: { describe: "Domain to configure for the registry", @@ -56,19 +97,27 @@ const registryConfigureArgs = { demandOption: false, hidden: true, deprecated: true, - conflicts: ["dockerhub-username", "aws-access-key-id"], + conflicts: providerSpecificCredentialFlagNames, }, "aws-access-key-id": { type: "string", description: "When configuring Amazon ECR, `AWS_ACCESS_KEY_ID`", demandOption: false, - conflicts: ["public-credential", "dockerhub-username"], + conflicts: providerSpecificCredentialFlagConflicts("aws-access-key-id"), }, "dockerhub-username": { type: "string", description: "When configuring DockerHub, the DockerHub username", demandOption: false, - conflicts: ["public-credential", "aws-access-key-id"], + conflicts: providerSpecificCredentialFlagConflicts("dockerhub-username"), + }, + "gar-email": { + type: "string", + description: + "When configuring Google Artifact Registry, the Google service account email. Must match the `client_email` in the service account key.", + demandOption: false, + requiresArg: true, + conflicts: providerSpecificCredentialFlagConflicts("gar-email"), }, "secret-store-id": { type: "string", @@ -115,9 +164,12 @@ async function registryConfigureCommand( return; } + validateProviderSpecificCredentialFlags(configureArgs, registryType.type); + const publicCredential = configureArgs.awsAccessKeyId ?? configureArgs.dockerhubUsername ?? + configureArgs.garEmail ?? configureArgs.publicCredential; if (!publicCredential) { const arg = @@ -125,7 +177,9 @@ async function registryConfigureCommand( ? "dockerhub-username" : registryType.type === ExternalRegistryKind.ECR ? "aws-access-key-id" - : "public-credential"; + : registryType.type === ExternalRegistryKind.GAR + ? "gar-email" + : "public-credential"; throw new UserError(`Missing required argument: ${arg}`, { telemetryMessage: "containers registries configure missing public credential", @@ -218,8 +272,10 @@ async function registryConfigureCommand( if (!secretExists) { // New secret: read the private credential and store it. log(`Getting ${registryType.secretType}...\n`); - const privateCredential = await promptForRegistryPrivateCredential( - registryType.secretType + const privateCredential = await resolvePrivateCredential( + registryType.type, + registryType.secretType, + publicCredential ); await promiseSpinner( createSecret(config, accountId, secretStoreId, { @@ -232,6 +288,10 @@ async function registryConfigureCommand( log( `Container-scoped secret "${secretName}" created in Secrets Store.\n` ); + } else if (registryType.type === ExternalRegistryKind.GAR) { + log( + `Reusing existing secret "${secretName}". Wrangler cannot verify it matches --gar-email "${publicCredential}"; the email is validated against the key when images are pulled.\n` + ); } private_credential = { @@ -241,8 +301,10 @@ async function registryConfigureCommand( } else { // If we are not using the secret store, we will be passing in the secret directly log(`Getting ${registryType.secretType}...\n`); - private_credential = await promptForRegistryPrivateCredential( - registryType.secretType + private_credential = await resolvePrivateCredential( + registryType.type, + registryType.secretType, + publicCredential ); } @@ -387,6 +449,85 @@ async function promptForRegistryPrivateCredential( return secret; } +function validateProviderSpecificCredentialFlags( + configureArgs: HandlerArgs, + registryType: ExternalRegistryKind | "cloudflare" +) { + for (const flag of providerSpecificCredentialFlags) { + if ( + configureArgs[flag.argName] !== undefined && + registryType !== flag.registryType + ) { + throw new UserError( + `--${flag.flagName} can only be used with ${flag.registryName}.`, + { telemetryMessage: flag.telemetryMessage } + ); + } + } +} + +async function resolvePrivateCredential( + registryType: ExternalRegistryKind, + secretType: string | undefined, + publicCredential: string +): Promise { + if (registryType === ExternalRegistryKind.GAR) { + const rawKey = await getGarServiceAccountKey(secretType); + return validateAndEncodeGarKey(rawKey, publicCredential); + } + return promptForRegistryPrivateCredential(secretType); +} + +/** + * Resolves the Google service-account key contents for a GAR registry from: + * - stdin (non-interactive): a file path, or the key contents (raw JSON or base64). + * - an interactive prompt: a file path, or base64-encoded key contents. + */ +async function getGarServiceAccountKey(secretType?: string): Promise { + if (isNonInteractiveOrCI()) { + const stdinInput = trimTrailingWhitespace(await readFromStdin()); + if (!stdinInput) { + throw new UserError( + "No input provided. In non-interactive mode, pipe the Google service account key (a file path, raw JSON, or base64) via stdin.", + { + telemetryMessage: + "containers registries configure missing gar key input", + } + ); + } + return existsSync(stdinInput) + ? readGarServiceAccountKeyFile(stdinInput) + : stdinInput; + } + + const input = await prompt( + `Enter ${secretType ?? "secret"} (file path or base64):`, + { isSecret: true } + ); + if (!input) { + throw new UserError("Secret cannot be empty.", { + telemetryMessage: "containers registries configure empty secret", + }); + } + + const trimmed = input.trim(); + return existsSync(trimmed) ? readGarServiceAccountKeyFile(trimmed) : input; +} + +function readGarServiceAccountKeyFile(path: string): string { + try { + return readFileSync(path); + } catch { + throw new UserError( + `Could not read the Google service account key file at "${path}".`, + { + telemetryMessage: + "containers registries configure gar key file unreadable", + } + ); + } +} + const registryListArgs = { json: { type: "boolean",