diff --git a/.changeset/breezy-groups-warn.md b/.changeset/breezy-groups-warn.md new file mode 100644 index 000000000000..b342a063f48a --- /dev/null +++ b/.changeset/breezy-groups-warn.md @@ -0,0 +1,21 @@ +--- +"@cloudflare/containers-shared": patch +"wrangler": minor +--- + +Users are now able to configure DockerHub credentials and have containers reference images stored there. + +DockerHub can be configured as follows: + +```sh +echo $PAT_TOKEN | npx wrangler@latest containers registries configure docker.io --dockerhub-username=user --secret-name=DockerHub_PAT_Token +``` + +Containers can then specify an image from DockerHub in their `wrangler.jsonc` as follows: + +```jsonc +"containers": { + "image": "docker.io/namespace/image:tag", + ... +} +``` diff --git a/.changeset/dry-shoes-cheat.md b/.changeset/dry-shoes-cheat.md new file mode 100644 index 000000000000..8067f5a7b901 --- /dev/null +++ b/.changeset/dry-shoes-cheat.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fix SolidStart autoconfig for projects using version 2.0.0-alpha or later + +SolidStart v2.0.0-alpha introduced a breaking change where configuration moved from `app.config.(js|ts)` to `vite.config.(js|ts)`. Wrangler's autoconfig now detects the installed SolidStart version and based on it updates the appropriate configuration file diff --git a/.changeset/empty-radios-happen.md b/.changeset/empty-radios-happen.md index 1b890faab504..33c0f9c52b56 100644 --- a/.changeset/empty-radios-happen.md +++ b/.changeset/empty-radios-happen.md @@ -1,12 +1,10 @@ --- "@cloudflare/vite-plugin": minor "@cloudflare/containers-shared": minor -"@cloudflare/workers-utils": minor "miniflare": minor "wrangler": minor --- Add experimental support for containers to workers communication with interceptOutboundHttp -This feature is experimental and requires adding the "experimental" -compatibility flag to your Wrangler configuration. +This feature is experimental and requires adding the "experimental" compatibility flag to your Wrangler configuration. diff --git a/.changeset/quiet-queens-build.md b/.changeset/quiet-queens-build.md index 0a8f58fbac17..c4dcf8de35cc 100644 --- a/.changeset/quiet-queens-build.md +++ b/.changeset/quiet-queens-build.md @@ -1,5 +1,21 @@ --- +"@cloudflare/vite-plugin": minor "wrangler": minor --- -Add dev support for experimental `secrets` property. +Add local dev validation for the experimental `secrets` configuration property + +When the new `secrets` property is defined, `wrangler dev` and `vite dev` now validate secrets declared in `secrets.required`. When required secrets are missing from `.dev.vars` or `.env`/`process.env`, a warning is logged listing the missing secret names. + +When `secrets` is defined, only the keys listed in `secrets.required` are loaded. Additional keys in `.dev.vars` or `.env` are excluded. If you are not using `.dev.vars`, keys listed in `secrets.required` are loaded from `process.env` as well as `.env`. The `CLOUDFLARE_INCLUDE_PROCESS_ENV` environment variable is therefore not needed when using this feature. + +When `secrets` is not defined, the existing behavior is unchanged. + +```jsonc +// wrangler.jsonc +{ + "secrets": { + "required": ["API_KEY", "DB_PASSWORD"], + }, +} +``` diff --git a/.changeset/sharp-sheep-buy.md b/.changeset/sharp-sheep-buy.md index 206121c18a60..020beebdd386 100644 --- a/.changeset/sharp-sheep-buy.md +++ b/.changeset/sharp-sheep-buy.md @@ -2,6 +2,21 @@ "wrangler": minor --- -Add type generation support for experimental `secrets` property. +Add type generation for the experimental `secrets` configuration property -This has precedence over deriving secret types from .env and .dev.vars files. +When the new `secrets` property is defined, `wrangler types` now generates typed bindings from the names listed in `secrets.required`. + +When `secrets` is defined at any config level, type generation uses it exclusively and no longer infers secret names from `.dev.vars` or `.env` files. This enables running type generation in environments where these files are not present. + +Per-environment secrets are supported. Each named environment produces its own interface, and the aggregated `Env` marks secrets that only appear in some environments as optional. + +When `secrets` is not defined, the existing behavior is unchanged. + +```jsonc +// wrangler.jsonc +{ + "secrets": { + "required": ["API_KEY", "DB_PASSWORD"], + }, +} +``` diff --git a/packages/containers-shared/src/client/index.ts b/packages/containers-shared/src/client/index.ts index 1fb508009273..f08905fbe2f2 100644 --- a/packages/containers-shared/src/client/index.ts +++ b/packages/containers-shared/src/client/index.ts @@ -97,6 +97,7 @@ export type { EnvironmentVariableValue } from "./models/EnvironmentVariableValue export { EventName } from "./models/EventName"; export { EventType } from "./models/EventType"; export type { ExecFormParam } from "./models/ExecFormParam"; +export { ExternalRegistryKind } from "./models/ExternalRegistryKind"; export type { GenericErrorDetails } from "./models/GenericErrorDetails"; export type { GenericErrorResponseWithRequestID } from "./models/GenericErrorResponseWithRequestID"; export type { GenericMessageResponse } from "./models/GenericMessageResponse"; @@ -105,6 +106,7 @@ export type { GetPlacementError } from "./models/GetPlacementError"; export { HTTPMethod } from "./models/HTTPMethod"; export type { Identity } from "./models/Identity"; export type { Image } from "./models/Image"; +export type { ImageRegistryAuth } from "./models/ImageRegistryAuth"; export { ImageRegistryAlreadyExistsError } from "./models/ImageRegistryAlreadyExistsError"; export type { ImageRegistryCredentialsConfiguration } from "./models/ImageRegistryCredentialsConfiguration"; export { ImageRegistryIsPublic } from "./models/ImageRegistryIsPublic"; @@ -190,6 +192,7 @@ export type { SecretMetadata } from "./models/SecretMetadata"; export type { SecretName } from "./models/SecretName"; export { SecretNameAlreadyExists } from "./models/SecretNameAlreadyExists"; export { SecretNotFound } from "./models/SecretNotFound"; +export type { SecretsStoreRef } from "./models/SecretsStoreRef"; export type { SSHPublicKey } from "./models/SSHPublicKey"; export type { SSHPublicKeyID } from "./models/SSHPublicKeyID"; export type { SSHPublicKeyItem } from "./models/SSHPublicKeyItem"; diff --git a/packages/containers-shared/src/client/models/CustomerImageRegistry.ts b/packages/containers-shared/src/client/models/CustomerImageRegistry.ts index 4c899fdae4ca..967276f4abb5 100644 --- a/packages/containers-shared/src/client/models/CustomerImageRegistry.ts +++ b/packages/containers-shared/src/client/models/CustomerImageRegistry.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ +import type { DefaultImageRegistryKind } from "./DefaultImageRegistryKind"; import type { Domain } from "./Domain"; +import type { ExternalRegistryKind } from "./ExternalRegistryKind"; import type { ISO8601Timestamp } from "./ISO8601Timestamp"; +import type { SecretsStoreRef } from "./SecretsStoreRef"; /** * An image registry added in a customer account @@ -13,6 +16,11 @@ export type CustomerImageRegistry = { * A base64 representation of the public key that you can set to configure the registry. If null, the registry is public and doesn't have authentication setup with Cloudchamber */ public_key?: string; + private_credential?: SecretsStoreRef; domain: Domain; + /** + * The type of registry that is being configured. + */ + kind?: ExternalRegistryKind | DefaultImageRegistryKind; created_at: ISO8601Timestamp; }; diff --git a/packages/containers-shared/src/client/models/DefaultImageRegistryKind.ts b/packages/containers-shared/src/client/models/DefaultImageRegistryKind.ts new file mode 100644 index 000000000000..451ff70d109c --- /dev/null +++ b/packages/containers-shared/src/client/models/DefaultImageRegistryKind.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export enum DefaultImageRegistryKind { + DEFAULT = "default", +} diff --git a/packages/containers-shared/src/client/models/ExternalRegistryKind.ts b/packages/containers-shared/src/client/models/ExternalRegistryKind.ts index 41968ff729f5..30ae943c707f 100644 --- a/packages/containers-shared/src/client/models/ExternalRegistryKind.ts +++ b/packages/containers-shared/src/client/models/ExternalRegistryKind.ts @@ -7,4 +7,5 @@ */ export enum ExternalRegistryKind { ECR = "ECR", + DOCKER_HUB = "DockerHub", } diff --git a/packages/containers-shared/src/client/models/ImageRegistryNotAllowedError.ts b/packages/containers-shared/src/client/models/ImageRegistryNotAllowedError.ts index 531fed15bfc6..b80ba878be88 100644 --- a/packages/containers-shared/src/client/models/ImageRegistryNotAllowedError.ts +++ b/packages/containers-shared/src/client/models/ImageRegistryNotAllowedError.ts @@ -3,11 +3,11 @@ /* eslint-disable */ /** - * The registry is not allowed to be added + * The registry is not allowed to be modified */ export type ImageRegistryNotAllowedError = { /** - * The domain of the registry is not allowed to be added + * The domain of the registry is not allowed to be modified */ error: ImageRegistryNotAllowedError.error; /** @@ -18,7 +18,7 @@ export type ImageRegistryNotAllowedError = { export namespace ImageRegistryNotAllowedError { /** - * The domain of the registry is not allowed to be added + * The domain of the registry is not allowed to be modified */ export enum error { IMAGE_REGISTRY_NOT_ALLOWED = "IMAGE_REGISTRY_NOT_ALLOWED", diff --git a/packages/containers-shared/src/images.ts b/packages/containers-shared/src/images.ts index 61f828d85928..0d6d1c10b08c 100644 --- a/packages/containers-shared/src/images.ts +++ b/packages/containers-shared/src/images.ts @@ -259,6 +259,12 @@ export const getAndValidateRegistryType = (domain: string): RegistryPattern => { name: "AWS ECR", secretType: "AWS Secret Access Key", }, + { + type: ExternalRegistryKind.DOCKER_HUB, + pattern: /^docker\.io$/, + name: "DockerHub", + secretType: "DockerHub PAT Token", + }, { type: "cloudflare", // Make a regex based on the env var CLOUDFLARE_CONTAINER_REGISTRY diff --git a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts index fbf7dadc4104..3053d857b7f5 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts @@ -813,8 +813,6 @@ function getExperimentalFrameworkTestConfig( }, { name: "solid", - // quarantined: SolidStart moved from app.config to vite.config with Nitro plugin - quarantine: true, promptHandlers: [ { matcher: /Which template would you like to use/, diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index e2216a72d9eb..da77d98b3c7f 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -709,20 +709,18 @@ export interface EnvironmentNonInheritable { vars: Record; /** - * Secrets configuration. + * Secrets configuration (experimental). * * NOTE: This field is not automatically inherited from the top level environment, * and so must be specified in every named environment. * - * @default undefined * @nonInheritable */ secrets?: { /** * List of secret names that are required by your Worker. * When defined, this property: - * - Replaces .dev.vars/.env inference for type generation - * - Enables deploy-time validation to ensure secrets are configured + * - Replaces .dev.vars/.env/process.env inference for type generation * - Enables local dev validation with warnings for missing secrets */ required?: string[]; diff --git a/packages/wrangler/src/__tests__/containers/config.test.ts b/packages/wrangler/src/__tests__/containers/config.test.ts index 72b8cfc99f4c..3ca2df443795 100644 --- a/packages/wrangler/src/__tests__/containers/config.test.ts +++ b/packages/wrangler/src/__tests__/containers/config.test.ts @@ -708,7 +708,7 @@ describe("getNormalizedContainerOptions", () => { containers: [ { class_name: "TestContainer", - image: "docker.io/test:latest", + image: "unsupported.domain/test:latest", instance_type: "standard", name: "test-container", max_instances: 3, @@ -727,7 +727,7 @@ describe("getNormalizedContainerOptions", () => { const result = await getNormalizedContainerOptions(config, {}); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - image_uri: "docker.io/test:latest", + image_uri: "unsupported.domain/test:latest", }); }); it("should not try and add an account id to non containers registry uris", async () => { diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 0c523c698bb3..82362939813b 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -19,6 +19,33 @@ import { useMockStdin } from "../helpers/mock-stdin"; import { createFetchResult, msw } from "../helpers/msw"; import { runWrangler } from "../helpers/run-wrangler"; +describe("containers registries --help", () => { + const std = mockConsoleMethods(); + + it("should help", async () => { + await runWrangler("containers registries --help"); + expect(std.out).toMatchInlineSnapshot(` + "wrangler containers registries + + Configure and manage non-Cloudflare registries [open beta] + + COMMANDS + wrangler containers registries configure Configure credentials for a non-Cloudflare container registry [open beta] + wrangler containers registries list List all configured container registries [open beta] + wrangler containers registries delete Delete a configured container registry [open beta] + wrangler containers registries credentials [DOMAIN] Get a temporary password for a specific domain [open beta] + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); +}); + describe("containers registries configure", () => { const { setIsTTY } = useMockIsTTY(); const cliStd = mockCLIOutput(); @@ -31,15 +58,14 @@ describe("containers registries configure", () => { afterEach(() => { clearDialogs(); }); - it("should reject unsupported registry domains", async () => { await expect( runWrangler( - `containers registries configure docker.io --public-credential=test-id` + `containers registries configure unsupported.domain --public-credential=test-id` ) ).rejects.toThrowErrorMatchingInlineSnapshot(` - [Error: docker.io is not a supported image registry. - Currently we support the following non-Cloudflare registries: AWS ECR. + [Error: unsupported.domain is not a supported image registry. + Currently we support the following non-Cloudflare registries: AWS ECR, DockerHub. To use an existing image from another repository, see https://developers.cloudflare.com/containers/platform-details/image-management/#using-pre-built-container-images] `); }); @@ -81,15 +107,37 @@ describe("containers registries configure", () => { ); }); + it("should enforce mutual exclusivity for public credential arguments", async () => { + await expect( + runWrangler(`containers registries configure docker.io`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing required argument: dockerhub-username]` + ); + + await expect( + runWrangler( + `containers registries configure 123456789012.dkr.ecr.region.amazonaws.com` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing required argument: aws-access-key-id]` + ); + + await expect( + runWrangler( + `containers registries configure docker.io --public-credential=test-id --dockerhub-username=another-test-id` + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Arguments public-credential and dockerhub-username are mutually exclusive]` + ); + }); + it("should no-op on cloudflare registry (default)", async () => { await runWrangler( - `containers registries configure registry.cloudflare.com --public-credential=test-id` + `containers registries configure registry.cloudflare.com` ); expect(cliStd.stdout).toMatchInlineSnapshot(` "╭ Configure a container registry │ - │ Configuring Cloudflare Containers Managed Registry registry: registry.cloudflare.com - │ │ You do not need to configure credentials for Cloudflare managed registries. │ ╰ No configuration required @@ -406,6 +454,95 @@ describe("containers registries configure", () => { }); }); }); + + describe("DockerHub registry configuration", () => { + it("should configure DockerHub registry with interactive prompts", async () => { + setIsTTY(true); + const dockerHubDomain = "docker.io"; + const storeId = "test-store-id-123"; + mockPrompt({ + text: "Enter DockerHub PAT Token:", + options: { isSecret: true }, + result: "test-pat-token", + }); + mockPrompt({ + text: "Secret name:", + options: { isSecret: false, defaultValue: "DockerHub_PAT_Token" }, + result: "DockerHub_PAT_Token", + }); + + 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({ + domain: "docker.io", + is_public: false, + auth: { + public_credential: "cloudchambertest", + private_credential: { + store_id: storeId, + secret_name: "DockerHub_PAT_Token", + }, + }, + kind: "DockerHub", + }); + + await runWrangler( + `containers registries configure ${dockerHubDomain} --dockerhub-username=cloudchambertest` + ); + + expect(cliStd.stdout).toContain("Using existing Secret Store Default"); + }); + + describe("non-interactive", () => { + beforeEach(() => { + setIsTTY(false); + }); + const dockerHubDomain = "docker.io"; + const mockStdIn = useMockStdin({ isTTY: false }); + + it("should accept the secret from piped input", async () => { + const secret = "example-pat-token"; + const storeId = "test-store-id-999"; + + mockStdIn.send(secret); + 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({ + domain: dockerHubDomain, + is_public: false, + auth: { + public_credential: "cloudchambertest", + private_credential: { + store_id: storeId, + secret_name: "DockerHub_PAT_Token", + }, + }, + kind: "DockerHub", + }); + await runWrangler( + `containers registries configure ${dockerHubDomain} --public-credential=cloudchambertest --secret-name=DockerHub_PAT_Token` + ); + }); + }); + }); }); describe("containers registries list", () => { diff --git a/packages/wrangler/src/autoconfig/frameworks/solid-start.ts b/packages/wrangler/src/autoconfig/frameworks/solid-start.ts index 2861ea7ab138..ff777b0f2976 100644 --- a/packages/wrangler/src/autoconfig/frameworks/solid-start.ts +++ b/packages/wrangler/src/autoconfig/frameworks/solid-start.ts @@ -1,9 +1,12 @@ +import assert from "node:assert"; import { updateStatus } from "@cloudflare/cli"; import { blue } from "@cloudflare/cli/colors"; import { getLocalWorkerdCompatibilityDate } from "@cloudflare/workers-utils"; import * as recast from "recast"; +import semiver from "semiver"; import { mergeObjectProperties, transformFile } from "../c3-vendor/codemod"; import { usesTypescript } from "../uses-typescript"; +import { getInstalledPackageVersion } from "./utils/packages"; import { Framework } from "."; import type { ConfigurationOptions, ConfigurationResults } from "."; @@ -13,45 +16,13 @@ export class SolidStart extends Framework { dryRun, }: ConfigurationOptions): Promise { if (!dryRun) { - const filePath = `app.config.${usesTypescript(projectPath) ? "ts" : "js"}`; + const solidStartVersion = getSolidStartVersion(projectPath); - const { date: compatDate } = getLocalWorkerdCompatibilityDate({ - projectPath, - }); - - updateStatus(`Updating configuration in ${blue(filePath)}`); - - transformFile(filePath, { - visitCallExpression: function (n) { - const callee = n.node.callee as recast.types.namedTypes.Identifier; - if (callee.name !== "defineConfig") { - return this.traverse(n); - } - - const b = recast.types.builders; - mergeObjectProperties( - n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, - [ - b.objectProperty( - b.identifier("server"), - b.objectExpression([ - // preset: "cloudflare_module" - b.objectProperty( - b.identifier("preset"), - b.stringLiteral("cloudflare_module") - ), - b.objectProperty( - b.identifier("compatibilityDate"), - b.stringLiteral(compatDate) - ), - ]) - ), - ] - ); - - return false; - }, - }); + if (semiver(solidStartVersion, "2.0.0-alpha") < 0) { + updateAppConfigFile(projectPath); + } else { + updateViteConfigFile(projectPath); + } } return { @@ -65,3 +36,113 @@ export class SolidStart extends Framework { }; } } + +/** + * This functions updates the `vite.config.(js|ts)` files used by SolidStart applications + * to use the `cloudflare-module` preset to target Cloudflare Workers. + * + * Note: SolidStart projects prior to version `2.0.0-alpha` used to have an `app.config.(js|ts)` file instead + * + * @param projectPath The path of the project + */ +function updateViteConfigFile(projectPath: string): void { + const filePath = `vite.config.${usesTypescript(projectPath) ? "ts" : "js"}`; + + transformFile(filePath, { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "nitro") { + return this.traverse(n); + } + + const b = recast.types.builders; + const presetProp = b.objectProperty( + b.identifier("preset"), + b.stringLiteral("cloudflare-module") + ); + + if (n.node.arguments.length === 0) { + n.node.arguments.push(b.objectExpression([presetProp])); + } else { + mergeObjectProperties( + n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, + [presetProp] + ); + } + + return false; + }, + }); +} + +/** + * SolidStart apps used to have an `app.config.(js|ts)` before version `2.0.0-alpha` + * (afterwards this has been replaced by `vite.config.(js|ts)`). + * Reference: https://github.com/solidjs/templates/commit/c4cd73e08bdc + * + * This functions updates the `app.config.(js|ts)` to use the `cloudflare_module` preset + * to target Cloudflare Workers. + * + * @param projectPath The path of the project + */ +function updateAppConfigFile(projectPath: string): void { + const filePath = `app.config.${usesTypescript(projectPath) ? "ts" : "js"}`; + + const { date: compatDate } = getLocalWorkerdCompatibilityDate({ + projectPath, + }); + + updateStatus(`Updating configuration in ${blue(filePath)}`); + + transformFile(filePath, { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "defineConfig") { + return this.traverse(n); + } + + const b = recast.types.builders; + mergeObjectProperties( + n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, + [ + b.objectProperty( + b.identifier("server"), + b.objectExpression([ + // preset: "cloudflare_module" + b.objectProperty( + b.identifier("preset"), + b.stringLiteral("cloudflare_module") + ), + b.objectProperty( + b.identifier("compatibilityDate"), + b.stringLiteral(compatDate) + ), + ]) + ), + ] + ); + + return false; + }, + }); +} + +/** + * Gets the installed version of the "@solidjs/start" package + * + * @param projectPath The path of the project + */ +function getSolidStartVersion(projectPath: string): string { + const packageName = "@solidjs/start"; + const solidStartVersion = getInstalledPackageVersion( + packageName, + projectPath + ); + + assert( + solidStartVersion, + `Unable to discern the version of the \`${packageName}\` package` + ); + + return solidStartVersion; +} diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index d278236cddc2..71bc5e9c999e 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -7,6 +7,7 @@ import { } from "@cloudflare/cli"; import { ApiError, + ExternalRegistryKind, getAndValidateRegistryType, getCloudflareContainerRegistry, ImageRegistriesService, @@ -36,82 +37,76 @@ import { getAccountId } from "../user"; import { readFromStdin, trimTrailingWhitespace } from "../utils/std"; import { formatError } from "./deploy"; import { containersScope } from "."; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; +import type { HandlerArgs, NamedArgDefinitions } from "../core/types"; import type { DeleteImageRegistryResponse, + ImageRegistryAuth, ImageRegistryPermissions, } from "@cloudflare/containers-shared"; -import type { ImageRegistryAuth } from "@cloudflare/containers-shared/src/client/models/ImageRegistryAuth"; import type { Config } from "@cloudflare/workers-utils"; -function _registryConfigureYargs(args: CommonYargsArgv) { - return args - .positional("DOMAIN", { - describe: "Domain to configure for the registry", - type: "string", - demandOption: true, - }) - .option("public-credential", { - type: "string", - description: - "The public part of the registry credentials, e.g. `AWS_ACCESS_KEY_ID` for ECR", - demandOption: true, - alias: ["aws-access-key-id"], - }) - .option("secret-store-id", { - type: "string", - description: - "The ID of the secret store to use to store the registry credentials.", - demandOption: false, - conflicts: ["disable-secrets-store"], - }) - .option("secret-name", { - type: "string", - description: - "The name for the secret the private registry credentials should be stored under.", - demandOption: false, - conflicts: ["disable-secrets-store"], - }) - .option("disable-secrets-store", { - type: "boolean", - description: - "Whether to disable secrets store integration. This should be set iff the compliance region is FedRAMP High.", - demandOption: false, - conflicts: ["secret-store-id", "secret-name"], - }) - .option("skip-confirmation", { - type: "boolean", - description: "Skip confirmation prompt", - alias: "y", - default: false, - }) - .check((yargs) => { - if ( - yargs.skipConfirmation && - !yargs.secretName && - !yargs.disableSecretsStore - ) { - throw new Error( - "--secret-name is required when using --skip-confirmation" - ); - } - return true; - }); -} +const registryConfigureArgs = { + DOMAIN: { + describe: "Domain to configure for the registry", + type: "string", + demandOption: true, + }, + "public-credential": { + type: "string", + demandOption: false, + hidden: true, + deprecated: true, + conflicts: ["dockerhub-username", "aws-access-key-id"], + }, + "aws-access-key-id": { + type: "string", + description: "When configuring Amazon ECR, `AWS_ACCESS_KEY_ID`", + demandOption: false, + conflicts: ["public-credential", "dockerhub-username"], + }, + "dockerhub-username": { + type: "string", + description: "When configuring DockerHub, the DockerHub username", + demandOption: false, + conflicts: ["public-credential", "aws-access-key-id"], + }, + "secret-store-id": { + type: "string", + description: + "The ID of the secret store to use to store the registry credentials.", + demandOption: false, + conflicts: "disable-secrets-store", + }, + "secret-name": { + type: "string", + description: + "The name for the secret the private registry credentials should be stored under.", + demandOption: false, + conflicts: "disable-secrets-store", + }, + "disable-secrets-store": { + type: "boolean", + description: + "Whether to disable secrets store integration. This should be set iff the compliance region is FedRAMP High.", + demandOption: false, + conflicts: ["secret-store-id", "secret-name"], + }, + "skip-confirmation": { + type: "boolean", + description: "Skip confirmation prompts", + alias: "y", + default: false, + }, +} as const satisfies NamedArgDefinitions; async function registryConfigureCommand( - configureArgs: StrictYargsOptionsToInterface, + configureArgs: HandlerArgs, config: Config ) { startSection("Configure a container registry"); const registryType = getAndValidateRegistryType(configureArgs.DOMAIN); - log(`Configuring ${registryType.name} registry: ${configureArgs.DOMAIN}\n`); - if (registryType.type === "cloudflare") { log( "You do not need to configure credentials for Cloudflare managed registries.\n" @@ -120,6 +115,22 @@ async function registryConfigureCommand( return; } + const publicCredential = + configureArgs.awsAccessKeyId ?? + configureArgs.dockerhubUsername ?? + configureArgs.publicCredential; + if (!publicCredential) { + const arg = + registryType.type === ExternalRegistryKind.DOCKER_HUB + ? "dockerhub-username" + : registryType.type === ExternalRegistryKind.ECR + ? "aws-access-key-id" + : "public-credential"; + throw new UserError(`Missing required argument: ${arg}`); + } + + log(`Configuring ${registryType.name} registry: ${configureArgs.DOMAIN}\n`); + const isFedRAMPHigh = getCloudflareComplianceRegion(config) === "fedramp_high"; if (isFedRAMPHigh) { @@ -211,7 +222,7 @@ async function registryConfigureCommand( domain: configureArgs.DOMAIN, is_public: false, auth: { - public_credential: configureArgs.publicCredential, + public_credential: publicCredential, private_credential, }, kind: registryType.type, @@ -253,7 +264,7 @@ async function promptForSecretName(secretType?: string): Promise { } interface GetOrCreateSecretOptions { - configureArgs: StrictYargsOptionsToInterface; + configureArgs: HandlerArgs; config: Config; accountId: string; storeId: string; @@ -344,16 +355,16 @@ async function promptForRegistryPrivateCredential( return secret; } -function _registryListYargs(args: CommonYargsArgv) { - return args.option("json", { +const registryListArgs = { + json: { type: "boolean", description: "Format output as JSON", default: false, - }); -} + }, +} as const satisfies NamedArgDefinitions; async function registryListCommand( - listArgs: StrictYargsOptionsToInterface + listArgs: HandlerArgs ) { if (!listArgs.json && !isNonInteractiveOrCI()) { startSection("List configured container registries"); @@ -385,23 +396,22 @@ async function registryListCommand( } } -// Only used for its type. The underscore prefix prevents unused variable linting errors. -const _registryDeleteYargs = (yargs: CommonYargsArgv) => { - return yargs - .positional("DOMAIN", { - describe: "domain of the registry to delete", - type: "string", - demandOption: true, - }) - .option("skip-confirmation", { - type: "boolean", - description: "Skip confirmation prompt", - alias: "y", - default: false, - }); -}; +const registryDeleteArgs = { + DOMAIN: { + describe: "Domain of the registry to delete", + type: "string", + demandOption: true, + }, + "skip-confirmation": { + type: "boolean", + description: "Skip confirmation prompts for registry and secret deletion", + alias: "y", + default: false, + }, +} as const satisfies NamedArgDefinitions; + async function registryDeleteCommand( - deleteArgs: StrictYargsOptionsToInterface, + deleteArgs: HandlerArgs, config: Config ) { startSection(`Delete registry ${deleteArgs.DOMAIN}`); @@ -537,47 +547,7 @@ export const containersRegistriesConfigureCommand = createCommand({ status: "open beta", owner: "Product: Cloudchamber", }, - args: { - DOMAIN: { - describe: "Domain to configure for the registry", - type: "string", - demandOption: true, - }, - "public-credential": { - type: "string", - description: - "The public part of the registry credentials, e.g. `AWS_ACCESS_KEY_ID` for ECR", - demandOption: true, - alias: "aws-access-key-id", - }, - "secret-store-id": { - type: "string", - description: - "The ID of the secret store to use to store the registry credentials.", - demandOption: false, - conflicts: "disable-secrets-store", - }, - "secret-name": { - type: "string", - description: - "The name for the secret the private registry credentials should be stored under.", - demandOption: false, - conflicts: "disable-secrets-store", - }, - "disable-secrets-store": { - type: "boolean", - description: - "Whether to disable secrets store integration. This should be set iff the compliance region is FedRAMP High.", - demandOption: false, - conflicts: ["secret-store-id", "secret-name"], - }, - "skip-confirmation": { - type: "boolean", - description: "Skip confirmation prompts", - alias: "y", - default: false, - }, - }, + args: registryConfigureArgs, positionalArgs: ["DOMAIN"], validateArgs(args) { if ( @@ -601,18 +571,11 @@ export const containersRegistriesListCommand = createCommand({ description: "List all configured container registries", status: "open beta", owner: "Product: Cloudchamber", - hidden: true, }, behaviour: { printBanner: (args) => !args.json && !isNonInteractiveOrCI(), }, - args: { - json: { - type: "boolean", - description: "Format output as JSON", - default: false, - }, - }, + args: registryListArgs, async handler(args, { config }) { await fillOpenAPIConfiguration(config, containersScope); await registryListCommand(args); @@ -624,21 +587,8 @@ export const containersRegistriesDeleteCommand = createCommand({ description: "Delete a configured container registry", status: "open beta", owner: "Product: Cloudchamber", - hidden: true, - }, - args: { - DOMAIN: { - describe: "Domain of the registry to delete", - type: "string", - demandOption: true, - }, - "skip-confirmation": { - type: "boolean", - description: "Skip confirmation prompts for registry and secret deletion", - alias: "y", - default: false, - }, }, + args: registryDeleteArgs, positionalArgs: ["DOMAIN"], async handler(args, { config }) { await fillOpenAPIConfiguration(config, containersScope);