Skip to content

Commit 34e0cef

Browse files
CC-7948 Update wrangler CLI to support GAR (#14311)
Co-authored-by: Sherry Liu <sherryliu@cloudflare.com>
1 parent 8a5cf8c commit 34e0cef

6 files changed

Lines changed: 946 additions & 10 deletions

File tree

.changeset/common-boats-help.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@cloudflare/containers-shared": minor
3+
"wrangler": minor
4+
---
5+
6+
Add Google Artifact Registry support to `containers registries configure`
7+
8+
`wrangler containers registries configure` now recognizes `*-docker.pkg.dev` (Google Artifact Registry) domains.
9+
10+
- The Google service account email is the public credential, supplied with `--gar-email`. It must match the `client_email` in the service account key.
11+
- 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.
12+
- 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.
13+
14+
```sh
15+
<path-to-key>.json | npx wrangler@latest containers registries configure <region>-docker.pkg.dev --gar-email=<service-account-email> --secret-name=Google_Service_Account_JSON_Key
16+
```

packages/containers-shared/src/client/models/ExternalRegistryKind.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
export enum ExternalRegistryKind {
99
ECR = "ECR",
1010
DOCKER_HUB = "DockerHub",
11+
GAR = "GAR",
1112
}

packages/containers-shared/src/images.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ export function resolveImageName(accountId: string, image: string): string {
226226

227227
/**
228228
* Get type of container registry, and validate.
229-
* Currently we support Cloudflare managed registries and AWS ECR.
229+
* We support Cloudflare managed registries plus the external registries listed in
230+
* `acceptedRegistries` below (currently AWS ECR, DockerHub, and Google Artifact Registry).
230231
* When using Cloudflare managed registries we expect CLOUDFLARE_CONTAINER_REGISTRY to be set
231232
*/
232233
export const getAndValidateRegistryType = (domain: string): RegistryPattern => {
@@ -259,6 +260,12 @@ export const getAndValidateRegistryType = (domain: string): RegistryPattern => {
259260
name: "DockerHub",
260261
secretType: "DockerHub PAT Token",
261262
},
263+
{
264+
type: ExternalRegistryKind.GAR,
265+
pattern: /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?-docker\.pkg\.dev$/,
266+
name: "Google Artifact Registry",
267+
secretType: "Google Service Account JSON Key",
268+
},
262269
{
263270
type: "cloudflare",
264271
// Make a regex based on the env var CLOUDFLARE_CONTAINER_REGISTRY
@@ -293,3 +300,136 @@ interface RegistryPattern {
293300
pattern: RegExp;
294301
name: string;
295302
}
303+
304+
type ServiceAccountKey = {
305+
private_key: string;
306+
client_email: string;
307+
private_key_id?: string;
308+
};
309+
310+
function invalidGarCredentialError(): UserError {
311+
return new UserError(
312+
"The Google service account key must be a JSON key file or its base64-encoded form.",
313+
{
314+
telemetryMessage:
315+
"containers registries configure invalid gar credential",
316+
}
317+
);
318+
}
319+
320+
function tryParseJson(value: string): unknown | undefined {
321+
try {
322+
return JSON.parse(value) as unknown;
323+
} catch {
324+
return undefined;
325+
}
326+
}
327+
328+
function assertJsonObject(parsed: unknown): Record<string, unknown> {
329+
if (typeof parsed !== "object" || parsed === null) {
330+
throw invalidGarCredentialError();
331+
}
332+
return parsed as Record<string, unknown>;
333+
}
334+
335+
function validateServiceAccountKey(
336+
accountKey: Record<string, unknown>
337+
): ServiceAccountKey {
338+
const privateKey = accountKey.private_key;
339+
const clientEmail = accountKey.client_email;
340+
const rawPrivateKeyId = accountKey.private_key_id;
341+
if (typeof privateKey !== "string" || typeof clientEmail !== "string") {
342+
throw new UserError(
343+
"The Google service account key is missing required fields (private_key, client_email).",
344+
{
345+
telemetryMessage:
346+
"containers registries configure gar credential missing fields",
347+
}
348+
);
349+
}
350+
if (privateKey.length === 0 || clientEmail.length === 0) {
351+
throw new UserError(
352+
"The Google service account key has an empty private_key or client_email.",
353+
{
354+
telemetryMessage:
355+
"containers registries configure gar credential empty fields",
356+
}
357+
);
358+
}
359+
let privateKeyId: string | undefined;
360+
if (rawPrivateKeyId === undefined) {
361+
privateKeyId = undefined;
362+
} else if (
363+
typeof rawPrivateKeyId === "string" &&
364+
rawPrivateKeyId.length > 0
365+
) {
366+
privateKeyId = rawPrivateKeyId;
367+
} else {
368+
throw new UserError(
369+
"The Google service account key has an empty or invalid private_key_id.",
370+
{
371+
telemetryMessage:
372+
"containers registries configure gar credential invalid private key id",
373+
}
374+
);
375+
}
376+
return {
377+
private_key: privateKey,
378+
client_email: clientEmail,
379+
private_key_id: privateKeyId,
380+
};
381+
}
382+
383+
/**
384+
* Validates a Google service account JSON key and returns it base64-encoded for
385+
* storage as the private credential.
386+
*
387+
* Accepts the raw JSON key contents or its base64-encoded form. Throws a
388+
* `UserError` if the key is malformed, or if `expectedEmail` (the
389+
* `--gar-email` public credential) does not match the `client_email` in the key.
390+
*/
391+
export function validateAndEncodeGarKey(
392+
rawKey: string,
393+
expectedEmail: string
394+
): string {
395+
const trimmed = rawKey.trim();
396+
397+
let base64Key: string;
398+
let json: Record<string, unknown>;
399+
const rawJson = tryParseJson(trimmed);
400+
if (rawJson !== undefined) {
401+
json = assertJsonObject(rawJson);
402+
base64Key = Buffer.from(trimmed, "utf8").toString("base64");
403+
} else {
404+
if (trimmed.startsWith("-----BEGIN")) {
405+
throw new UserError(
406+
"The provided key appears to be a PEM private key. Provide the full Google service-account JSON key file, not just the private key.",
407+
{
408+
telemetryMessage:
409+
"containers registries configure gar credential pem key",
410+
}
411+
);
412+
}
413+
base64Key = trimmed.replace(/\s+/g, "");
414+
const decodedJson = tryParseJson(
415+
Buffer.from(base64Key, "base64").toString("utf8")
416+
);
417+
if (decodedJson === undefined) {
418+
throw invalidGarCredentialError();
419+
}
420+
json = assertJsonObject(decodedJson);
421+
}
422+
423+
const key = validateServiceAccountKey(json);
424+
425+
if (key.client_email !== expectedEmail) {
426+
throw new UserError(
427+
`The provided --gar-email "${expectedEmail}" does not match the service account email "${key.client_email}" in the key.`,
428+
{
429+
telemetryMessage: "containers registries configure gar email mismatch",
430+
}
431+
);
432+
}
433+
434+
return base64Key;
435+
}

0 commit comments

Comments
 (0)