From 31d1624600235958f0572561e4d6d80cb0232f3c Mon Sep 17 00:00:00 2001 From: Sherry Liu Date: Thu, 18 Jun 2026 17:47:05 -0500 Subject: [PATCH] CC-7948 [wrangler] Add Google Artifact Registry support to containers registries configure Recognizes *-docker.pkg.dev (Google Artifact Registry) domains in the external registry configuration flow. The Google service account email is the public credential, supplied via --gar-email and validated against the client_email in the key. The service account JSON key is the private credential, provided via stdin (file path, raw JSON, or base64) or an interactive prompt (file path or base64). The key is validated and stored base64-encoded. 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; the email is validated against the key at pull time. --- .changeset/common-boats-help.md | 16 + .../src/client/models/ExternalRegistryKind.ts | 1 + packages/containers-shared/src/images.ts | 142 +++++- .../containers-shared/tests/images.test.ts | 228 ++++++++++ .../__tests__/containers/registries.test.ts | 412 +++++++++++++++++- .../wrangler/src/containers/registries.ts | 157 ++++++- 6 files changed, 946 insertions(+), 10 deletions(-) create mode 100644 .changeset/common-boats-help.md 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",