diff --git a/packages/containers-shared/index.ts b/packages/containers-shared/index.ts index b5c8169f50f..27d95828572 100644 --- a/packages/containers-shared/index.ts +++ b/packages/containers-shared/index.ts @@ -1,9 +1,15 @@ export * from "./src/client"; export * from "./src/build"; +export * from "./src/context"; +export * from "./src/deploy"; +export * from "./src/diff"; export * from "./src/login"; export * from "./src/knobs"; +export * from "./src/limits"; +export * from "./src/object"; export * from "./src/utils"; export * from "./src/types"; export * from "./src/inspect"; export * from "./src/registry"; export * from "./src/images"; +export * from "./src/spinner"; diff --git a/packages/containers-shared/package.json b/packages/containers-shared/package.json index 9a52dab6584..11db8a9cf5f 100644 --- a/packages/containers-shared/package.json +++ b/packages/containers-shared/package.json @@ -22,9 +22,12 @@ "test:watch": "pnpm run test --testTimeout=50000 --watch", "type:tests": "tsc -p ./tests/tsconfig.json" }, + "dependencies": { + "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/workers-utils": "workspace:*" + }, "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", - "@cloudflare/workers-utils": "workspace:*", "@types/node": "catalog:default", "typescript": "catalog:default", "vitest": "catalog:default" diff --git a/packages/containers-shared/src/build.ts b/packages/containers-shared/src/build.ts index 4d91dbce575..bbe8f0002dd 100644 --- a/packages/containers-shared/src/build.ts +++ b/packages/containers-shared/src/build.ts @@ -1,18 +1,47 @@ import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; +import crypto from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import { UserError } from "@cloudflare/workers-utils/errors"; +import { isDirectory } from "@cloudflare/workers-utils/fs-helpers"; +import { logger } from "./context"; +import { resolveImageName } from "./images"; +import { dockerImageInspect } from "./inspect"; +import { getCloudflareContainerRegistry } from "./knobs"; +import { ensureContainerLimits, getContainerAccount } from "./limits"; +import { dockerLoginImageRegistry } from "./login"; import { verifyDockerInstalled } from "./utils"; +import { runDockerCmd, runDockerCmdWithOutput } from "./utils"; import type { BuildArgs, - ContainerDevOptions, + ContainerNormalizedConfig, ImageURIConfig, - WranglerLogger, } from "./types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; -export async function constructBuildCommand( - options: BuildArgs, - logger?: WranglerLogger -) { +export type DockerfileContainerConfig = Exclude< + ContainerNormalizedConfig, + ImageURIConfig +>; + +export type BuiltContainerImage = { + containerConfig: DockerfileContainerConfig; + localTag: string; +}; + +export type BuiltContainerDeployment = { + container: DockerfileContainerConfig; + builtImage: BuiltContainerImage; +}; + +export function isDockerfileContainerConfig( + container: ContainerNormalizedConfig +): container is DockerfileContainerConfig { + return "dockerfile" in container; +} + +async function constructBuildCommand(options: BuildArgs) { const platform = options.platform ?? "linux/amd64"; const buildCmd = [ "build", @@ -41,6 +70,580 @@ export async function constructBuildCommand( return { buildCmd, dockerfile }; } +/** + * `{ remoteDigest: string }` implies the image was pushed to, or already exists in, + * the managed registry. Deployments should use this digest-pinned reference. + * + * `{ newTag: string }` implies the image was built locally without pushing. + */ +export type ImageRef = { remoteDigest: string } | { newTag: string }; + +export type ContainerBuildCommandArgs = { + PATH: string; + tag: string; + pathToDocker?: string; + push: boolean; + platform?: string; +}; + +export type ContainerPushCommandArgs = { + TAG: string; + pathToDocker?: string; +}; + +type StartedContainerBuild = Awaited>; + +/** + * Builds a container image from the given container options. + * + * @param build - Container configuration including the Dockerfile path, build context, and image tag. + * @param pathToDocker - Path to the Docker CLI executable. + * @param verifyDockerIsRunning - When `true` (the default), verifies Docker is installed + * and the daemon is running before building. Set to `false` when the caller has already + * performed this check. + * @returns An object with an `abort` function and a `ready` promise. + */ +export async function startContainerBuild({ + build, + pathToDocker, + verifyDockerIsRunning, +}: { + build: BuildArgs; + pathToDocker: string; + verifyDockerIsRunning?: boolean; +}): Promise { + const { buildCmd, dockerfile } = await constructBuildCommand(build); + return await dockerBuild(pathToDocker, { + buildCmd, + dockerfile, + verifyDockerIsRunning, + }); +} + +const DIGEST_SUFFIX_REGEXP = + /@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[a-fA-F0-9]{32,}$/; +const DIGEST_VALUE_REGEXP = + /^[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[a-fA-F0-9]{32,}$/; +const TAG_SUFFIX_REGEXP = /:[\w][\w.-]{0,127}$/; + +// Based on the Docker reference grammar used by containers/image. These only +// strip suffixes from refs that have already been normalized by resolveImageName(). +function getRepositoryOnly( + externalAccountId: string, + imageTag: string, + complianceConfig?: ComplianceConfig +): string { + return resolveImageName(externalAccountId, imageTag, complianceConfig) + .replace(DIGEST_SUFFIX_REGEXP, "") + .replace(TAG_SUFFIX_REGEXP, ""); +} + +function imageRefWithDigest( + externalAccountId: string, + imageTag: string, + digest: string, + complianceConfig?: ComplianceConfig +): string { + if (!DIGEST_VALUE_REGEXP.test(digest)) { + throw new Error( + `Expected image digest to match algorithm:hex format, got ${digest}` + ); + } + return `${getRepositoryOnly(externalAccountId, imageTag, complianceConfig)}@${digest}`; +} + +function findManifestDigest(manifestOutput: string): string { + const parsedManifest = JSON.parse(manifestOutput); + const digest = parsedManifest?.Descriptor?.digest; + if (typeof digest !== "string" || digest.length === 0) { + throw new Error( + `Expected docker manifest inspect output to include Descriptor.digest, got ${manifestOutput}` + ); + } + return digest; +} + +function findRemoteDigest( + repoDigestsJson: string, + externalAccountId: string, + imageTag: string, + complianceConfig?: ComplianceConfig +): string { + const parsedDigests = JSON.parse(repoDigestsJson); + if (!Array.isArray(parsedDigests)) { + throw new Error( + `Expected RepoDigests from docker inspect to be an array but got ${JSON.stringify(parsedDigests)}` + ); + } + + const repositoryOnly = getRepositoryOnly( + externalAccountId, + imageTag, + complianceConfig + ); + logger.debug("respositoryOnly:", repositoryOnly); + + // Make sure the repository + name provided in config matches the repository + // + name from the digests. + const digest = parsedDigests.find((d): d is string => { + if (typeof d !== "string" || !d.includes("@")) { + return false; + } + const resolved = resolveImageName(externalAccountId, d, complianceConfig); + logger.debug(`Comparing ${resolved.split("@")[0]} to ${repositoryOnly}`); + return resolved.split("@")[0] === repositoryOnly; + }); + if (!digest) { + throw new Error( + `Could not find a digest for the image ${repositoryOnly}. Found digests: ${parsedDigests.join(", ")}` + ); + } + + const [, hash] = digest.split("@"); + assertString(hash, `Expected digest "${digest}" to include a hash`); + return imageRefWithDigest( + externalAccountId, + imageTag, + hash, + complianceConfig + ); +} + +function assertString( + value: string | undefined, + message: string +): asserts value is string { + if (value === undefined) { + throw new Error(message); + } +} + +async function tagAndPushImage({ + pathToDocker, + sourceTag, + targetTag, + externalAccountId, + complianceConfig, + cleanupSourceTag, +}: { + pathToDocker: string; + sourceTag: string; + targetTag: string; + externalAccountId: string; + complianceConfig?: ComplianceConfig; + cleanupSourceTag?: boolean; +}): Promise { + const namespacedImageTag = resolveImageName( + externalAccountId, + targetTag, + complianceConfig + ); + await runDockerCmd(pathToDocker, ["tag", sourceTag, namespacedImageTag]); + if (cleanupSourceTag) { + logger.debug(`Untagging built image: ${sourceTag}.`); + await runDockerCmd(pathToDocker, ["image", "rm", sourceTag]); + } + await runDockerCmd(pathToDocker, ["push", namespacedImageTag]); + return namespacedImageTag; +} + +/** + * Checks the remote manifest to see if there are changes, and only push if there are + */ +async function pushImageIfChanged({ + pathToDocker, + sourceTag, + targetTag, + containerConfig, + accountId, + complianceConfig, + cleanupSourceTag, +}: { + pathToDocker: string; + sourceTag: string; + targetTag: string; + containerConfig?: DockerfileContainerConfig; + accountId?: string; + complianceConfig?: ComplianceConfig; + cleanupSourceTag?: boolean; +}): Promise { + /** + * Get `RepoDigests`: + * A Docker image digest (RepoDigest) is a unique, cryptographic identifier + * (SHA-256 hash) representing the content of a Docker image. Unlike tags, + * which can be reused or changed, a digest is immutable and ensures that the + * exact same image is pulled every time. This guarantees consistency across + * different environments and deployments. Crucially this is *not* affected by + * metadata changes (dockerfile only changes). + * From: https://docs.docker.com/dhi/core-concepts/digests/ + */ + const imageInfo = await dockerImageInspect(pathToDocker, { + imageTag: sourceTag, + formatString: "{{ json .RepoDigests }}", + }); + logger.debug(`'docker image inspect ${sourceTag}':`, imageInfo); + + const account = await getContainerAccount(accountId, complianceConfig); + + await ensureContainerLimits({ + pathToDocker, + imageTag: sourceTag, + account, + containerConfig, + }); + + await dockerLoginImageRegistry( + pathToDocker, + // Won't be an external registry since this is building from a Dockerfile + // rather than specifying an image URI. + getCloudflareContainerRegistry(complianceConfig), + accountId ?? account.external_account_id, + complianceConfig + ); + try { + // We don't try to parse until this point because we don't want to fail on + // parse errors if we won't be pushing the image anyway. + const remoteDigest = findRemoteDigest( + imageInfo, + account.external_account_id, + targetTag, + complianceConfig + ); + const [, hash] = remoteDigest.split("@"); + + logger.debug( + `'docker manifest inspect -v ${resolveImageName(account.external_account_id, remoteDigest, complianceConfig)}:` + ); + // NOTE: this is an experimental docker command so the API may change + // and break this flow. Hopefully not! + // http://docs.docker.com/reference/cli/docker/manifest/inspect/ + // Checks if this image already exists in the managed registry. If this + // succeeds it means this image already exists remotely. If this errors, + // it probably doesn't exist and we should push, which we will do in the + // catch block. + const remoteManifest = runDockerCmdWithOutput(pathToDocker, [ + "manifest", + "inspect", + "-v", + resolveImageName( + account.external_account_id, + remoteDigest, + complianceConfig + ), + ]); + const parsedRemoteManifest = JSON.parse(remoteManifest); + + if (parsedRemoteManifest.Descriptor.digest === hash) { + logger.log("Image already exists remotely, skipping push"); + logger.debug( + `Untagging built image: ${sourceTag} since there was no change.` + ); + + await runDockerCmd(pathToDocker, ["image", "rm", sourceTag]); + + return { remoteDigest }; + } + } catch (error) { + if (error instanceof Error) { + logger.debug( + `Checking for local image ${sourceTag} failed with error: ${error.message}` + ); + } + } + // Re-tag the image to include the account ID. + logger.log( + `Image does not exist remotely, pushing: ${resolveImageName( + account.external_account_id, + targetTag, + complianceConfig + )}` + ); + const namespacedImageTag = await tagAndPushImage({ + pathToDocker, + sourceTag, + targetTag, + externalAccountId: account.external_account_id, + complianceConfig, + cleanupSourceTag, + }); + + let remoteDigest: string; + try { + const pushedImageInfo = await dockerImageInspect(pathToDocker, { + imageTag: namespacedImageTag, + formatString: "{{ json .RepoDigests }}", + }); + remoteDigest = findRemoteDigest( + pushedImageInfo, + account.external_account_id, + namespacedImageTag, + complianceConfig + ); + } catch (error) { + if (error instanceof Error) { + logger.debug( + `Inspecting pushed image ${namespacedImageTag} failed with error: ${error.message}` + ); + } + const remoteManifest = runDockerCmdWithOutput(pathToDocker, [ + "manifest", + "inspect", + "-v", + namespacedImageTag, + ]); + remoteDigest = imageRefWithDigest( + account.external_account_id, + namespacedImageTag, + findManifestDigest(remoteManifest), + complianceConfig + ); + } + + return { remoteDigest }; +} + +/** + * Builds an image from the container build command arguments and optionally + * pushes it to the Cloudflare managed registry. + * + * @param args - Parsed container build command arguments. + * @param complianceConfig - Compliance configuration used to select the managed registry. + * @returns A promise that resolves when the build and optional push complete. + */ +export async function buildCommand( + args: ContainerBuildCommandArgs, + complianceConfig?: ComplianceConfig +) { + // TODO: merge args with Wrangler config if available. + if (existsSync(args.PATH) && !isDirectory(args.PATH)) { + throw new UserError( + `${args.PATH} is not a directory. Please specify a valid directory path.`, + { telemetryMessage: "container build invalid path" } + ); + } + if (args.platform !== undefined && args.platform !== "linux/amd64") { + throw new UserError( + `Unsupported platform: Platform "${args.platform}" is unsupported. Please use "linux/amd64" instead.`, + { telemetryMessage: "container build unsupported platform" } + ); + } + + const pathToDockerfile = join(args.PATH, "Dockerfile"); + const pathToDocker = args.pathToDocker ?? getDockerPath(); + + try { + const build = await startContainerBuild({ + pathToDocker, + build: { + tag: args.tag, + pathToDockerfile, + buildContext: args.PATH, + platform: args.platform, + // No option to add env vars at build time...? + setNetworkToHost: Boolean( + process.env.WRANGLER_CI_OVERRIDE_NETWORK_MODE_HOST + ), + }, + }); + await build.ready; + + if (args.push) { + await pushImageIfChanged({ + pathToDocker, + sourceTag: args.tag, + targetTag: args.tag, + complianceConfig, + }); + } + } catch (error) { + if (error instanceof Error) { + throw new UserError(error.message, { + cause: error, + telemetryMessage: "container build image operation failed", + }); + } + throw new UserError("An unknown error occurred", { + telemetryMessage: "container build unknown error", + }); + } +} + +export async function pushCommand( + args: ContainerPushCommandArgs, + accountId: string, + complianceConfig?: ComplianceConfig +) { + try { + const dockerPath = args.pathToDocker ?? getDockerPath(); + await dockerLoginImageRegistry( + dockerPath, + getCloudflareContainerRegistry(complianceConfig), + accountId, + complianceConfig + ); + + await checkImagePlatform(dockerPath, args.TAG); + const newTag = await tagAndPushImage({ + pathToDocker: dockerPath, + sourceTag: args.TAG, + targetTag: args.TAG, + externalAccountId: accountId, + complianceConfig, + }); + logger.log(`Pushed image: ${newTag}`); + } catch (error) { + if (error instanceof Error) { + throw new UserError(error.message, { + telemetryMessage: "container push failed", + }); + } + + throw new UserError("An unknown error occurred", { + telemetryMessage: "container push unknown error", + }); + } +} + +async function checkImagePlatform( + pathToDocker: string, + imageTag: string, + expectedPlatform: string = "linux/amd64" +) { + const platform = await dockerImageInspect(pathToDocker, { + imageTag, + formatString: "{{ .Os }}/{{ .Architecture }}", + }); + + if (platform !== expectedPlatform) { + throw new Error( + `Unsupported platform: Image platform (${platform}) does not match the expected platform (${expectedPlatform})` + ); + } +} + +async function buildContainerImage( + containerConfig: DockerfileContainerConfig, + pathToDocker: string, + verifyDockerIsRunning?: boolean +): Promise { + const localTag = `${getContainerImageRepositoryName( + containerConfig + )}:wrangler-${crypto.randomUUID()}`; + logger.log("Building image", localTag); + + try { + const build = await startContainerBuild({ + pathToDocker, + verifyDockerIsRunning, + build: { + tag: localTag, + pathToDockerfile: containerConfig.dockerfile, + buildContext: containerConfig.image_build_context, + args: containerConfig.image_vars, + }, + }); + await build.ready; + + return { containerConfig, localTag }; + } catch (error) { + if (error instanceof Error) { + throw new UserError(error.message, { + cause: error, + telemetryMessage: "container build image operation failed", + }); + } + throw new UserError("An unknown error occurred", { + telemetryMessage: "container build unknown error", + }); + } +} + +/** + * Builds configured Dockerfile-based container images for deployment. + * + * @param containers - Normalized container configuration. + * @param pathToDocker - Path to the Docker CLI executable. + * @param verifyDockerIsRunning - Whether to verify Docker before building. + * @returns The built image metadata paired with each Dockerfile-based container. + */ +export async function buildContainerImages( + containers: ContainerNormalizedConfig[], + pathToDocker: string, + verifyDockerIsRunning?: boolean +): Promise { + const builtContainerDeployments: BuiltContainerDeployment[] = []; + for (const container of containers.filter(isDockerfileContainerConfig)) { + builtContainerDeployments.push({ + container, + builtImage: await buildContainerImage( + container, + pathToDocker, + verifyDockerIsRunning + ), + }); + } + return builtContainerDeployments; +} + +/** + * Pushes a configured, already-built container image to the managed registry. + * + * @param builtImage - Built Dockerfile-based container image metadata. + * @param versionId - Version ID used to derive the pushed image tag. + * @param pathToDocker - Path to the Docker CLI executable. + * @param accountId - Account that owns the managed registry. + * @param complianceConfig - Compliance configuration used to select the managed registry. + * @returns An {@link ImageRef} describing the pushed image. + */ +export async function pushBuiltContainerImage( + builtImage: BuiltContainerImage, + versionId: string, + pathToDocker: string, + accountId: string, + complianceConfig?: ComplianceConfig +): Promise { + try { + return await pushImageIfChanged({ + pathToDocker, + sourceTag: builtImage.localTag, + targetTag: getContainerImageTag(builtImage.containerConfig, versionId), + containerConfig: builtImage.containerConfig, + accountId, + complianceConfig, + cleanupSourceTag: true, + }); + } catch (error) { + if (error instanceof Error) { + throw new UserError(error.message, { + cause: error, + telemetryMessage: "container build image operation failed", + }); + } + throw new UserError("An unknown error occurred", { + telemetryMessage: "container build unknown error", + }); + } +} + +export function getContainerImageTag( + containerConfig: DockerfileContainerConfig, + imageTag: string +): string { + return `${getContainerImageRepositoryName(containerConfig)}:${ + imageTag.split("-")[0] + }`; +} + +function getContainerImageRepositoryName( + containerConfig: DockerfileContainerConfig +): string { + // Docker rejects uppercase characters in an image repository name, and a + // container application name may embed a Durable Object class name verbatim, + // which is conventionally PascalCase. Lowercase the name for the image tag + // only; apply still needs the exact application name. + return containerConfig.name.toLowerCase(); +} + /** * Spawns a Docker build process and returns a handle to abort or await the build. * @@ -133,34 +736,3 @@ export async function dockerBuild( ready, }; } - -/** - * Builds a container image from the given container dev options. - * - * @param dockerPath - Path to the Docker CLI executable. - * @param options - Container configuration including the Dockerfile path, build context, and image tag. - * @param verifyDockerIsRunning - When `true` (the default), verifies Docker is installed - * and the daemon is running before building. Set to `false` when the caller has already - * performed this check. - * - * @returns An object with an `abort` function and a `ready` promise. - */ -export async function buildImage( - dockerPath: string, - options: Exclude, - verifyDockerIsRunning?: boolean -) { - const { buildCmd, dockerfile } = await constructBuildCommand({ - tag: options.image_tag, - pathToDockerfile: options.dockerfile, - buildContext: options.image_build_context, - args: options.image_vars, - platform: "linux/amd64", - }); - - return dockerBuild(dockerPath, { - buildCmd, - dockerfile, - verifyDockerIsRunning, - }); -} diff --git a/packages/containers-shared/src/context.ts b/packages/containers-shared/src/context.ts new file mode 100644 index 00000000000..69959c03e84 --- /dev/null +++ b/packages/containers-shared/src/context.ts @@ -0,0 +1,27 @@ +import type { FetchResultFetcher, Logger } from "@cloudflare/workers-utils"; + +const noop = () => {}; + +export let logger: Logger = { + debug: noop, + log: noop, + info: noop, + warn: noop, + error: noop, +}; + +export let fetchResult: FetchResultFetcher = () => { + throw new Error("initContainersSharedContext() must be called first"); +}; + +export type ContainersSharedContext = { + logger: Logger; + fetchResult: FetchResultFetcher; +}; + +export function initContainersSharedContext( + ctx: ContainersSharedContext +): void { + logger = ctx.logger; + fetchResult = ctx.fetchResult; +} diff --git a/packages/wrangler/src/containers/deploy.ts b/packages/containers-shared/src/deploy.ts similarity index 92% rename from packages/wrangler/src/containers/deploy.ts rename to packages/containers-shared/src/deploy.ts index edc7c890f6f..6f616e2a3d1 100644 --- a/packages/wrangler/src/containers/deploy.ts +++ b/packages/containers-shared/src/deploy.ts @@ -19,47 +19,31 @@ import { dim, green, } from "@cloudflare/cli-shared-helpers/colors"; +import { formatConfigSnippet } from "@cloudflare/workers-utils"; +import { FatalError, UserError } from "@cloudflare/workers-utils/errors"; import { ApiError, ApplicationsService, CreateApplicationRolloutRequest, - resolveImageName, RolloutsService, -} from "@cloudflare/containers-shared"; -import { - APIError, - FatalError, - formatConfigSnippet, - getDockerPath, - UserError, -} from "@cloudflare/workers-utils"; -import { fetchResult } from "../cfetch"; -import { - fillOpenAPIConfiguration, - promiseSpinner, -} from "../cloudchamber/common"; -import { inferInstanceType } from "../cloudchamber/instance-type/instance-type"; -import { buildContainer } from "../containers/build"; -import { getOrSelectAccountId } from "../user"; -import { Diff } from "../utils/diff"; -import { - sortObjectRecursive, - stripUndefined, -} from "../utils/sortObjectRecursive"; -import { fetchVersion } from "../versions/api"; -import { containersScope } from "."; -import type { ImageRef } from "../cloudchamber/build"; -import type { ApiVersion } from "../versions/types"; +} from "./client"; +import { fetchResult } from "./context"; +import { Diff } from "./diff"; +import { resolveImageName } from "./images"; +import { inferInstanceType } from "./limits"; +import { sortObjectRecursive, stripUndefined } from "./object"; +import { promiseSpinner } from "./spinner"; +import type { ImageRef } from "./build"; import type { Application, ApplicationID, ApplicationName, - ContainerNormalizedConfig, CreateApplicationRequest, ModifyApplicationRequestBody, Observability as ObservabilityConfiguration, RolloutStepRequest, -} from "@cloudflare/containers-shared"; +} from "./client"; +import type { ContainerNormalizedConfig } from "./types"; import type { ComplianceConfig, Config, @@ -73,36 +57,44 @@ type DeployContainersArgs = { scriptName: string; }; +export type ResolvedContainerDeployment = { + container: ContainerNormalizedConfig; + imageRef: ImageRef; +}; + +type ApiVersion = { + resources: { + bindings: WorkerMetadataBinding[]; + }; +}; + +export type DurableObjectNamespace = { + id: string; + class: string; + name: string; + script: string; + useSqlite: boolean; + /** + * Set when the namespace belongs to a Worker preview. For those, `script` is + * the parent Worker's name, so `preview.id` is what distinguishes a + * preview's namespace from the parent's and from other previews'. + */ + preview?: { id: string; slug: string; name: string }; +}; + export async function deployContainers( config: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], + containerDeployments: ResolvedContainerDeployment[], { versionId, accountId, scriptName }: DeployContainersArgs ) { - await fillOpenAPIConfiguration(config, containersScope); - - const pathToDocker = getDockerPath(); const boundDOs = new Set( config.durable_objects.bindings.map((b) => b.class_name) ); - let imageRef: ImageRef; let maybeVersionInfo: ApiVersion | undefined; let maybeAllDurableObjects: DurableObjectNamespace[] | undefined; - for (const container of normalisedContainerConfig) { - if ("dockerfile" in container) { - imageRef = await buildContainer( - container, - versionId, - false, // dry runs will have already exited by this point - pathToDocker, - false, - config - ); - } else { - imageRef = { newTag: container.image_uri }; - } - + for (const { container, imageRef } of containerDeployments) { // Only bound DOs are returned in version info. For unbound DOs, we need to list all DO namespaces. if (boundDOs.has(container.class_name)) { maybeVersionInfo ??= await fetchUploadedVersion( @@ -143,7 +135,8 @@ export async function deployContainers( durable_object_namespace_id: targetDurableObject.namespace_id, }, container, - config + config, + accountId ); } else { // The DO is unbound, so we need to list all DO namespaces to find the right one @@ -162,7 +155,8 @@ export async function deployContainers( durable_object_namespace_id: targetDurableObject.id, }, container, - config + config, + accountId ); } } @@ -176,13 +170,12 @@ async function fetchUploadedVersion( ): Promise { for (let attempt = 0; attempt < 5; attempt++) { try { - return await fetchVersion(config, accountId, scriptName, versionId); + return await fetchResult( + config, + `/accounts/${accountId}/workers/scripts/${scriptName}/versions/${versionId}` + ); } catch (error) { - if ( - !(error instanceof APIError) || - error.code !== 100146 || - attempt === 4 - ) { + if (!isUploadedVersionNotReadyError(error) || attempt === 4) { throw error; } await setTimeout(500); @@ -191,19 +184,14 @@ async function fetchUploadedVersion( throw new Error("Unable to fetch uploaded Worker version"); } -export type DurableObjectNamespace = { - id: string; - class: string; - name: string; - script: string; - useSqlite: boolean; - /** - * Set when the namespace belongs to a Worker preview. For those, `script` is - * the parent Worker's name, so `preview.id` is what distinguishes a - * preview's namespace from the parent's and from other previews'. - */ - preview?: { id: string; slug: string; name: string }; -}; +function isUploadedVersionNotReadyError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as { code?: unknown }).code === 100146 + ); +} + export async function listDurableObjects( complianceConfig: ComplianceConfig, accountId: string @@ -375,17 +363,16 @@ function formatContainerSnippetForDisplay< ]) ); - return formatConfigSnippet( - { - containers: [ - { - ...container, - configuration: configurationForDisplay, - } as unknown as ContainerApp, - ], - }, - configPath - ); + const snippet = { + containers: [ + { + ...container, + configuration: configurationForDisplay, + } as unknown as ContainerApp, + ], + }; + + return formatConfigSnippet(snippet, configPath); } export async function apply( @@ -394,7 +381,8 @@ export async function apply( durable_object_namespace_id: string; }, containerConfig: ContainerNormalizedConfig, - config: Config + config: Config, + accountId: string ) { if (!config.containers || config.containers.length === 0) { return; @@ -421,8 +409,6 @@ export async function apply( : args.imageRef.newTag; log(dim("Container application changes\n")); - const accountId = await getOrSelectAccountId(config); - // let's always convert normalised container config -> CreateApplicationRequest // since CreateApplicationRequest is a superset of ModifyApplicationRequestBody const appConfig = mergeIfUnsafe( diff --git a/packages/wrangler/src/utils/diff.ts b/packages/containers-shared/src/diff.ts similarity index 99% rename from packages/wrangler/src/utils/diff.ts rename to packages/containers-shared/src/diff.ts index dc324b9bb3d..7dc8dd2ad08 100644 --- a/packages/wrangler/src/utils/diff.ts +++ b/packages/containers-shared/src/diff.ts @@ -1,3 +1,4 @@ +// @ts-nocheck -- Verbatim compatibility copy of Wrangler's legacy line-diff helper. // Modified code from package jsdiff (https://github.com/kpdecker/jsdiff/tree/master) // It's been simplified so it can basically do line diffing only // and we can avoid the 600kb sized package. diff --git a/packages/containers-shared/src/images.ts b/packages/containers-shared/src/images.ts index 92e6e581327..f25f665d924 100644 --- a/packages/containers-shared/src/images.ts +++ b/packages/containers-shared/src/images.ts @@ -1,5 +1,5 @@ import { UserError } from "@cloudflare/workers-utils/errors"; -import { buildImage } from "./build"; +import { startContainerBuild } from "./build"; import { ExternalRegistryKind } from "./client/models/ExternalRegistryKind"; import { getCloudflareContainerRegistry } from "./knobs"; import { dockerLoginImageRegistry } from "./login"; @@ -50,6 +50,7 @@ export async function pullEgressInterceptorImage( * @param dockerPath - Path to the Docker CLI executable. * @param options - Container image and local development tag configuration. * @param logger - Logger used for recoverable registry credential warnings. + * @param accountId - Optional Cloudflare account ID used to request registry credentials. * @param complianceConfig - Compliance configuration used to identify the managed registry. * @returns An object with an `abort` function and a `ready` promise. */ @@ -57,6 +58,7 @@ export async function pullImage( dockerPath: string, options: Exclude, logger: WranglerLogger | ViteLogger, + accountId?: string, complianceConfig?: ComplianceConfig ): Promise<{ abort: () => void; ready: Promise }> { const domain = new URL(`http://${options.image_uri}`).hostname; @@ -64,7 +66,15 @@ export async function pullImage( const isExternalRegistry = domain !== getCloudflareContainerRegistry(complianceConfig); try { - await dockerLoginImageRegistry(dockerPath, domain); + if (accountId === undefined) { + throw new Error("An account ID is required to get registry credentials"); + } + await dockerLoginImageRegistry( + dockerPath, + domain, + accountId, + complianceConfig + ); } catch (e) { if (!isExternalRegistry) { throw e; @@ -126,6 +136,7 @@ export async function prepareContainerImagesForDev(args: { containerOptions: ContainerDevOptions; }) => void; logger: WranglerLogger | ViteLogger; + accountId?: string; complianceConfig?: ComplianceConfig; }): Promise { const { @@ -152,7 +163,17 @@ export async function prepareContainerImagesForDev(args: { }); for (const options of containerOptions) { if ("dockerfile" in options) { - const build = await buildImage(dockerPath, options, false); + const build = await startContainerBuild({ + pathToDocker: dockerPath, + verifyDockerIsRunning: false, + build: { + tag: options.image_tag, + pathToDockerfile: options.dockerfile, + buildContext: options.image_build_context, + args: options.image_vars, + platform: "linux/amd64", + }, + }); onContainerImagePreparationStart({ containerOptions: options, abort: () => { @@ -170,6 +191,7 @@ export async function prepareContainerImagesForDev(args: { dockerPath, options, args.logger, + args.accountId, args.complianceConfig ); onContainerImagePreparationStart({ diff --git a/packages/containers-shared/src/limits.ts b/packages/containers-shared/src/limits.ts new file mode 100644 index 00000000000..6009d9e612d --- /dev/null +++ b/packages/containers-shared/src/limits.ts @@ -0,0 +1,243 @@ +import { UserError } from "@cloudflare/workers-utils/errors"; +import { AccountService, InstanceType } from "./client"; +import { fetchResult, logger } from "./context"; +import { dockerImageInspect } from "./inspect"; +import type { + CompleteAccountCustomer, + CreateApplicationRequest, + UserDeploymentConfiguration, +} from "./client"; +import type { ContainerNormalizedConfig } from "./types"; +import type { ComplianceConfig, ContainerApp } from "@cloudflare/workers-utils"; + +const MB = 1000 * 1000; +const MiB = 1024 * 1024; + +const instanceTypes = { + // lite is the default instance type when REQUIRE_INSTANCE_TYPE is set + lite: { + vcpu: 0.0625, + memory_mib: 256, + disk_mb: 2000, + }, + dev: { + vcpu: 0.0625, + memory_mib: 256, + disk_mb: 2000, + }, + basic: { + vcpu: 0.25, + memory_mib: 1024, + disk_mb: 4000, + }, + standard: { + vcpu: 0.5, + memory_mib: 4096, + disk_mb: 8000, + }, + "standard-1": { + vcpu: 0.5, + memory_mib: 4096, + disk_mb: 8000, + }, + "standard-2": { + vcpu: 1, + memory_mib: 6144, + disk_mb: 12000, + }, + "standard-3": { + vcpu: 2, + memory_mib: 8192, + disk_mb: 16000, + }, + "standard-4": { + vcpu: 4, + memory_mib: 12_288, + disk_mb: 20000, + }, +} as const; + +const LEGACY_TO_CANONICAL: Record<"dev" | "standard", InstanceType> = { + dev: InstanceType.LITE, + standard: InstanceType.STANDARD_1, +}; + +function configToUsage(containerConfig: ContainerNormalizedConfig): { + vcpu: number; + memory_mib: number; + disk_mb: number; +} { + if ("instance_type" in containerConfig) { + return getInstanceTypeUsage(containerConfig.instance_type); + } + + return { + vcpu: containerConfig.vcpu, + memory_mib: containerConfig.memory_mib, + disk_mb: containerConfig.disk_bytes / MB, + }; +} + +function accountToLimits(account: CompleteAccountCustomer): { + vcpu: number; + memory_mib: number; + disk_mb: number; +} { + return { + vcpu: account.limits.vcpu_per_deployment, + memory_mib: account.limits.memory_mib_per_deployment, + disk_mb: account.limits.disk_mb_per_deployment, + }; +} + +export function getInstanceTypeUsage(instanceType: InstanceType): { + vcpu: number; + memory_mib: number; + disk_mb: number; +} { + return instanceTypes[instanceType]; +} + +// The API may return a legacy alias (e.g. "standard") for an instance +// type configured as its canonical name ("standard-1"). Normalizing ensures +// deploy diffs don't show a phantom EDIT for instance_type. +export function inferInstanceType( + config: UserDeploymentConfiguration +): InstanceType | undefined { + for (const [instanceType, configuration] of Object.entries(instanceTypes)) { + if ( + config.vcpu === configuration.vcpu && + config.memory_mib === configuration.memory_mib && + config.disk?.size_mb === configuration.disk_mb + ) { + const canonical = + instanceType in LEGACY_TO_CANONICAL + ? LEGACY_TO_CANONICAL[ + instanceType as keyof typeof LEGACY_TO_CANONICAL + ] + : undefined; + return (canonical ?? instanceType) as InstanceType; + } + } +} + +/** + * Removes any disk, memory, or vCPU set in an object's configuration. Used by + * Cloudchamber apply to render diffs using the equivalent `instance_type`. + */ +export function cleanForInstanceType( + app: CreateApplicationRequest +): ContainerApp { + if (!("configuration" in app)) { + return app as ContainerApp; + } + + const instance_type = inferInstanceType(app.configuration); + if (instance_type !== undefined) { + app.configuration.instance_type = instance_type; + } + + delete app.configuration.disk; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally cleaning up deprecated `memory` field + delete app.configuration.memory; + delete app.configuration.memory_mib; + delete app.configuration.vcpu; + + return app as ContainerApp; +} + +export async function ensureContainerLimits(options: { + pathToDocker: string; + imageTag: string; + account: CompleteAccountCustomer; + containerConfig?: ContainerNormalizedConfig; +}): Promise { + const limits = accountToLimits(options.account); + if (!options.containerConfig) { + // In this case we are only building an image. There is no container + // configuration to validate, but the image still needs to fit within the + // account-level disk limit. + await ensureImageFitsLimits({ + availableSizeInBytes: limits.disk_mb * MB, + pathToDocker: options.pathToDocker, + imageTag: options.imageTag, + }); + return; + } + + const usage = configToUsage(options.containerConfig); + + // Test configuration against account limits. + const errors = []; + if (usage.vcpu > limits.vcpu) { + errors.push( + `Your container configuration uses ${usage.vcpu} vCPU which exceeds the account limit of ${limits.vcpu} vCPU.` + ); + } + if (usage.memory_mib > limits.memory_mib) { + errors.push( + `Your container configuration uses ${usage.memory_mib} MiB of memory which exceeds the account limit of ${limits.memory_mib} MiB.` + ); + } + if (usage.disk_mb > limits.disk_mb) { + errors.push( + `Your container configuration uses ${usage.disk_mb} MB of disk which exceeds the account limit of ${limits.disk_mb} MB.` + ); + } + if (errors.length > 0) { + throw new UserError(`Exceeded account limits: ${errors.join(" ")}`, { + telemetryMessage: "cloudchamber limits account limit exceeded", + }); + } + + await ensureImageFitsLimits({ + availableSizeInBytes: usage.disk_mb * MB, + pathToDocker: options.pathToDocker, + imageTag: options.imageTag, + }); +} + +export async function ensureImageFitsLimits(options: { + availableSizeInBytes: number; + pathToDocker: string; + imageTag: string; +}): Promise { + const inspectOutput = await dockerImageInspect(options.pathToDocker, { + imageTag: options.imageTag, + formatString: "{{ .Size }} {{ len .RootFS.Layers }}", + }); + const [sizeStr, layerStr] = inspectOutput.split(" "); + if (sizeStr === undefined || layerStr === undefined) { + throw new Error( + `Expected docker image inspect output to include image size and layer count, got ${inspectOutput}` + ); + } + const size = parseInt(sizeStr, 10); + const layers = parseInt(layerStr, 10); + + const requiredSizeInBytes = Math.ceil(size * 1.1 + layers * 16 * MiB); + + logger.debug( + `Disk size limits when building container image: availableSize=${Math.ceil(options.availableSizeInBytes / MB)}MB, requiredSize=${Math.ceil(requiredSizeInBytes / MB)}MB` + ); + if (options.availableSizeInBytes < requiredSizeInBytes) { + throw new UserError( + `Image too large: needs ${Math.ceil(requiredSizeInBytes / MB)}MB, but your app is limited to images with size ${options.availableSizeInBytes / MB}MB. You need more disk for this image.`, + { telemetryMessage: "cloudchamber limits image too large" } + ); + } +} + +export async function getContainerAccount( + accountId?: string, + complianceConfig?: ComplianceConfig +): Promise { + if (accountId === undefined) { + return await AccountService.getMe(); + } + + return await fetchResult( + complianceConfig ?? {}, + `/accounts/${accountId}/containers/me` + ); +} diff --git a/packages/containers-shared/src/login.ts b/packages/containers-shared/src/login.ts index b0be6c4c9bd..8d6b2cfbc6e 100644 --- a/packages/containers-shared/src/login.ts +++ b/packages/containers-shared/src/login.ts @@ -1,22 +1,12 @@ import { spawn } from "node:child_process"; import { UserError } from "@cloudflare/workers-utils/errors"; -import { ImageRegistriesService, ImageRegistryPermissions } from "./client"; -import { OpenAPI } from "./client/core/OpenAPI"; - -export function configureOpenAPIForContainerPull( - accountId: string, - apiToken: string, - apiBase = "https://api.cloudflare.com/client/v4" -): void { - OpenAPI.BASE = `${apiBase}/accounts/${accountId}/containers`; - OpenAPI.CREDENTIALS = "omit"; - const existingHeaders = - typeof OpenAPI.HEADERS === "object" ? OpenAPI.HEADERS : {}; - OpenAPI.HEADERS = { - ...existingHeaders, - Authorization: `Bearer ${apiToken}`, - }; -} +import { ImageRegistryPermissions } from "./client"; +import { fetchResult } from "./context"; +import type { + AccountRegistryToken, + ImageRegistryCredentialsConfiguration, +} from "./client"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; /** * Gets push and pull credentials for a configured image registry @@ -25,19 +15,30 @@ export function configureOpenAPIForContainerPull( */ export async function dockerLoginImageRegistry( pathToDocker: string, - domain: string -) { - // how long the credentials should be valid for - const expirationMinutes = 15; + domain: string, + accountId: string, + complianceConfig?: ComplianceConfig +): Promise { + const credentials = await fetchResult( + complianceConfig ?? {}, + `/accounts/${accountId}/containers/registries/${domain}/credentials`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + // How long the credentials should be valid for. + expiration_minutes: 15, + permissions: [ + ImageRegistryPermissions.PUSH, + ImageRegistryPermissions.PULL, + ], + } satisfies ImageRegistryCredentialsConfiguration), + } + ); - const credentials = - await ImageRegistriesService.generateImageRegistryCredentials(domain, { - expiration_minutes: expirationMinutes, - permissions: [ - ImageRegistryPermissions.PUSH, - ImageRegistryPermissions.PULL, - ], - }); + if (credentials.password === undefined) { + throw new Error("Expected registry credentials to include a password"); + } const child = spawn( pathToDocker, diff --git a/packages/wrangler/src/utils/sortObjectRecursive.ts b/packages/containers-shared/src/object.ts similarity index 96% rename from packages/wrangler/src/utils/sortObjectRecursive.ts rename to packages/containers-shared/src/object.ts index f1a249d44bf..3d942ae98dd 100644 --- a/packages/wrangler/src/utils/sortObjectRecursive.ts +++ b/packages/containers-shared/src/object.ts @@ -1,5 +1,5 @@ /** - * Removes from the object every undefined property + * Removes from the object every undefined property. */ export function stripUndefined>(r: T): T { for (const k in r) { diff --git a/packages/containers-shared/src/spinner.ts b/packages/containers-shared/src/spinner.ts new file mode 100644 index 00000000000..2e247239915 --- /dev/null +++ b/packages/containers-shared/src/spinner.ts @@ -0,0 +1,24 @@ +import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; + +export async function promiseSpinner( + promise: Promise, + { + message, + }: { + message: string; + } = { + message: "Loading", + } +): Promise { + if (process.env.CI || !process.stdin.isTTY) { + return promise; + } + const { start, stop } = spinner(); + start(message); + const t = await promise.catch((err) => { + stop(); + throw err; + }); + stop(); + return t; +} diff --git a/packages/containers-shared/tests/build-and-push.test.ts b/packages/containers-shared/tests/build-and-push.test.ts new file mode 100644 index 00000000000..eb4ac31fe47 --- /dev/null +++ b/packages/containers-shared/tests/build-and-push.test.ts @@ -0,0 +1,678 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { + AccountService, + buildCommand, + buildContainerImages, + getCloudflareContainerRegistry, + getContainerImageTag, + initContainersSharedContext, + InstanceType, + pushBuiltContainerImage, + pushCommand, + SchedulingPolicy, +} from "../index"; +import type { CompleteAccountCustomer } from "../src/client"; +import type { ContainerNormalizedConfig } from "../src/types"; +import type { FetchResultFetcher, Logger } from "@cloudflare/workers-utils"; + +vi.mock("node:child_process"); + +const dockerfile = "FROM node:22\n"; + +const logger: Logger = { + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +const account = { + external_account_id: "some-account-id", + limits: { + vcpu_per_deployment: 1, + memory_mib_per_deployment: 1024, + disk_mb_per_deployment: 4000, + }, +} as CompleteAccountCustomer; + +const dockerfileContainer = { + name: "test-app", + class_name: "ExampleDurableObject", + dockerfile: "/tmp/Dockerfile", + image_build_context: "/tmp", + max_instances: 10, + scheduling_policy: SchedulingPolicy.DEFAULT, + rollout_step_percentage: [100], + rollout_kind: "full_auto", + rollout_active_grace_period: 0, + instance_type: InstanceType.DEV, + constraints: {}, + observability: { logs_enabled: true }, +} satisfies ContainerNormalizedConfig; + +let inspectOutputs: string[]; +let tempDirs: string[]; +let fetchResultMock: FetchResultFetcher; + +function createBuildArgs() { + const dir = mkdtempSync(join(tmpdir(), "containers-shared-build-")); + const pathToDockerfile = join(dir, "Dockerfile"); + writeFileSync(pathToDockerfile, dockerfile); + return { + dir, + args: { + tag: "test-app:tag", + pathToDockerfile, + buildContext: dir, + }, + }; +} + +function createFakeChildProcess(args: string[]): ChildProcess { + const child = new EventEmitter() as ChildProcess; + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdin = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + + Object.assign(child, { + pid: 1234, + stdout, + stderr, + stdin, + unref: vi.fn(), + kill: vi.fn(), + }); + + process.nextTick(() => { + if (args[0] === "image" && args[1] === "inspect") { + stdout.emit("data", inspectOutputs.shift() ?? ""); + } + child.emit("exit", 0); + child.emit("close", 0); + }); + + return child; +} + +function mockDockerProcesses() { + vi.mocked(spawn).mockImplementation((_dockerPath, args) => + createFakeChildProcess(Array.from(args ?? [])) + ); +} + +function expectSpawnWith(args: string[]) { + const calls = vi.mocked(spawn).mock.calls; + const match = calls.find(([, actualArgs]) => { + return JSON.stringify(actualArgs) === JSON.stringify(args); + }); + if (!match) { + throw new Error( + `Expected spawn to be called with ${JSON.stringify(args)}, got ${JSON.stringify( + calls.map(([, actualArgs]) => actualArgs) + )}` + ); + } +} + +function expectSpawnCommandWith(dockerPath: string, args: string[]) { + const calls = vi.mocked(spawn).mock.calls; + const match = calls.find(([actualDockerPath, actualArgs]) => { + return ( + actualDockerPath === dockerPath && + JSON.stringify(actualArgs) === JSON.stringify(args) + ); + }); + if (!match) { + throw new Error( + `Expected spawn to be called with ${dockerPath} ${JSON.stringify( + args + )}, got ${JSON.stringify( + calls.map(([actualDockerPath, actualArgs]) => [ + actualDockerPath, + actualArgs, + ]) + )}` + ); + } +} + +function expectNoSpawnWith(args: string[]) { + const calls = vi.mocked(spawn).mock.calls; + const match = calls.find(([, actualArgs]) => { + return JSON.stringify(actualArgs) === JSON.stringify(args); + }); + if (match) { + throw new Error( + `Expected spawn not to be called with ${JSON.stringify(args)}` + ); + } +} + +describe("buildCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + tempDirs = []; + inspectOutputs = [ + "[]", + "53387881 2", + '["registry.cloudflare.com/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]', + ]; + mockDockerProcesses(); + vi.mocked(execFileSync).mockReturnValue( + '{"Descriptor":{"digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' + ); + vi.spyOn(AccountService, "getMe").mockResolvedValue(account); + fetchResultMock = vi.fn( + async ( + _config: Parameters[0], + path: string + ) => { + if (path === "/accounts/some-account-id/containers/me") { + return account as ResponseType; + } + if ( + path === + "/accounts/some-account-id/containers/registries/registry.cloudflare.com/credentials" + ) { + return { + account_id: "some-account-id", + username: "username", + password: "password", + registry_host: getCloudflareContainerRegistry(), + } as ResponseType; + } + throw new Error(`Unexpected fetchResult path: ${path}`); + } + ) as FetchResultFetcher; + initContainersSharedContext({ logger, fetchResult: fetchResultMock }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs) { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it("builds without pushing when push is false", async ({ expect }) => { + const { dir, args } = createBuildArgs(); + tempDirs.push(dir); + + await expect( + buildCommand({ + PATH: dir, + tag: args.tag, + pathToDocker: "docker", + push: false, + }) + ).resolves.toBeUndefined(); + + expectSpawnWith([ + "build", + "--load", + "-t", + "test-app:tag", + "--platform", + "linux/amd64", + "--provenance=false", + "-f", + "-", + dir, + ]); + }); + + it("tags and pushes new images, returning the pushed digest", async ({ + expect, + }) => { + const { dir, args } = createBuildArgs(); + tempDirs.push(dir); + + await expect( + buildCommand({ + PATH: dir, + tag: args.tag, + pathToDocker: "docker", + push: true, + }) + ).resolves.toBeUndefined(); + + expectSpawnWith([ + "image", + "inspect", + "test-app:tag", + "--format", + "{{ json .RepoDigests }}", + ]); + expectSpawnWith([ + "image", + "inspect", + "test-app:tag", + "--format", + "{{ .Size }} {{ len .RootFS.Layers }}", + ]); + expect(vi.mocked(fetchResultMock)).toHaveBeenCalledWith( + {}, + "/accounts/some-account-id/containers/registries/registry.cloudflare.com/credentials", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expiration_minutes: 15, + permissions: ["push", "pull"], + }), + } + ); + expectSpawnWith([ + "tag", + "test-app:tag", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ]); + expectSpawnWith([ + "push", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ]); + }); + + it("skips pushing when the remote digest already exists", async ({ + expect, + }) => { + const { dir, args } = createBuildArgs(); + tempDirs.push(dir); + inspectOutputs = [ + '["registry.cloudflare.com/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]', + "53387881 2", + ]; + vi.mocked(execFileSync).mockReturnValue( + '{"Descriptor":{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' + ); + + await expect( + buildCommand({ + PATH: dir, + tag: args.tag, + pathToDocker: "docker", + push: true, + }) + ).resolves.toBeUndefined(); + + expectSpawnWith(["image", "rm", "test-app:tag"]); + expectNoSpawnWith([ + "push", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ]); + }); + + it("uses docker manifest inspect when pushed image inspect has no digest", async ({ + expect, + }) => { + const { dir, args } = createBuildArgs(); + tempDirs.push(dir); + inspectOutputs = ["[]", "53387881 2", ""]; + + await expect( + buildCommand({ + PATH: dir, + tag: args.tag, + pathToDocker: "docker", + push: true, + }) + ).resolves.toBeUndefined(); + expect(execFileSync).toHaveBeenCalledWith( + "docker", + [ + "manifest", + "inspect", + "-v", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ], + { encoding: "utf8" } + ); + }); +}); + +describe("deploy container image build and push", () => { + beforeEach(() => { + vi.clearAllMocks(); + tempDirs = []; + inspectOutputs = [ + "[]", + "53387881 2", + '["registry.cloudflare.com/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]', + ]; + mockDockerProcesses(); + vi.spyOn(crypto, "randomUUID").mockReturnValue( + "11111111-1111-4111-8111-111111111111" + ); + vi.spyOn(AccountService, "getMe").mockResolvedValue(account); + fetchResultMock = vi.fn( + async ( + _config: Parameters[0], + path: string + ) => { + if (path === "/accounts/some-account-id/containers/me") { + return account as ResponseType; + } + if ( + path === + "/accounts/some-account-id/containers/registries/registry.cloudflare.com/credentials" + ) { + return { + account_id: "some-account-id", + username: "username", + password: "password", + registry_host: getCloudflareContainerRegistry(), + } as ResponseType; + } + throw new Error(`Unexpected fetchResult path: ${path}`); + } + ) as FetchResultFetcher; + initContainersSharedContext({ logger, fetchResult: fetchResultMock }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds Dockerfile containers and pairs each build with the original config", async ({ + expect, + }) => { + const { dir, args } = createBuildArgs(); + tempDirs.push(dir); + const container = { + ...dockerfileContainer, + dockerfile: args.pathToDockerfile, + image_build_context: dir, + }; + const imageUriContainer = { + ...dockerfileContainer, + dockerfile: undefined, + image_build_context: undefined, + image_uri: "registry.cloudflare.com/some-account-id/test-app:tag", + } as unknown as ContainerNormalizedConfig; + delete (imageUriContainer as Record).dockerfile; + delete (imageUriContainer as Record).image_build_context; + + await expect( + buildContainerImages([container, imageUriContainer], "docker", false) + ).resolves.toStrictEqual([ + { + container, + builtImage: { + containerConfig: container, + localTag: "test-app:wrangler-11111111-1111-4111-8111-111111111111", + }, + }, + ]); + }); + + it("lowercases Docker image repository names for local and canonical tags", async ({ + expect, + }) => { + const { dir, args } = createBuildArgs(); + tempDirs.push(dir); + const container = { + ...dockerfileContainer, + name: "Test-App", + dockerfile: args.pathToDockerfile, + image_build_context: dir, + }; + + await expect( + buildContainerImages([container], "docker", false) + ).resolves.toStrictEqual([ + { + container, + builtImage: { + containerConfig: container, + localTag: "test-app:wrangler-11111111-1111-4111-8111-111111111111", + }, + }, + ]); + expect(getContainerImageTag(container, "Galaxy-Class")).toBe( + "test-app:Galaxy" + ); + }); + + it("retags and pushes a built image using the Worker version ID tag", async ({ + expect, + }) => { + await expect( + pushBuiltContainerImage( + { + containerConfig: dockerfileContainer, + localTag: "test-app:wrangler-11111111-1111-4111-8111-111111111111", + }, + "Galaxy-Class", + "docker", + "some-account-id", + undefined + ) + ).resolves.toStrictEqual({ + remoteDigest: + "registry.cloudflare.com/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + expect(vi.mocked(fetchResultMock)).toHaveBeenCalledWith( + {}, + "/accounts/some-account-id/containers/me" + ); + + expectSpawnWith([ + "image", + "inspect", + "test-app:wrangler-11111111-1111-4111-8111-111111111111", + "--format", + "{{ json .RepoDigests }}", + ]); + expectSpawnWith([ + "image", + "inspect", + "test-app:wrangler-11111111-1111-4111-8111-111111111111", + "--format", + "{{ .Size }} {{ len .RootFS.Layers }}", + ]); + expectSpawnWith([ + "tag", + "test-app:wrangler-11111111-1111-4111-8111-111111111111", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:Galaxy`, + ]); + expectSpawnWith([ + "image", + "rm", + "test-app:wrangler-11111111-1111-4111-8111-111111111111", + ]); + expectSpawnWith([ + "push", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:Galaxy`, + ]); + const tagCallIndex = vi + .mocked(spawn) + .mock.calls.findIndex( + ([, args]) => + JSON.stringify(args) === + JSON.stringify([ + "tag", + "test-app:wrangler-11111111-1111-4111-8111-111111111111", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:Galaxy`, + ]) + ); + const cleanupCallIndex = vi + .mocked(spawn) + .mock.calls.findIndex( + ([, args]) => + JSON.stringify(args) === + JSON.stringify([ + "image", + "rm", + "test-app:wrangler-11111111-1111-4111-8111-111111111111", + ]) + ); + const pushCallIndex = vi + .mocked(spawn) + .mock.calls.findIndex( + ([, args]) => + JSON.stringify(args) === + JSON.stringify([ + "push", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:Galaxy`, + ]) + ); + expect(tagCallIndex).toBeLessThan(cleanupCallIndex); + expect(cleanupCallIndex).toBeLessThan(pushCallIndex); + }); + + it("derives production tags from version IDs, not Worker tags", ({ + expect, + }) => { + expect(getContainerImageTag(dockerfileContainer, "Galaxy-Class")).toBe( + "test-app:Galaxy" + ); + expect( + getContainerImageTag( + dockerfileContainer, + "11111111-2222-4333-8444-555555555555" + ) + ).toBe("test-app:11111111"); + }); +}); + +describe("buildCommand arguments", () => { + beforeEach(() => { + vi.clearAllMocks(); + tempDirs = []; + inspectOutputs = []; + mockDockerProcesses(); + initContainersSharedContext({ logger, fetchResult: vi.fn() }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs) { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it("uses the explicit Docker path", async ({ expect }) => { + const { dir } = createBuildArgs(); + tempDirs.push(dir); + + await expect( + buildCommand({ + PATH: dir, + tag: "test-app:tag", + pathToDocker: "/custom/docker", + push: false, + platform: "linux/amd64", + }) + ).resolves.toBeUndefined(); + + expectSpawnCommandWith("/custom/docker", [ + "build", + "--load", + "-t", + "test-app:tag", + "--platform", + "linux/amd64", + "--provenance=false", + "-f", + "-", + dir, + ]); + }); +}); + +describe("pushCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + inspectOutputs = ["linux/amd64"]; + mockDockerProcesses(); + fetchResultMock = vi.fn( + async ( + _config: Parameters[0], + path: string + ) => { + if ( + path === + "/accounts/some-account-id/containers/registries/registry.cloudflare.com/credentials" + ) { + return { + account_id: "some-account-id", + username: "username", + password: "password", + registry_host: getCloudflareContainerRegistry(), + } as ResponseType; + } + throw new Error(`Unexpected fetchResult path: ${path}`); + } + ) as FetchResultFetcher; + initContainersSharedContext({ logger, fetchResult: fetchResultMock }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("validates platform, tags, and pushes to the managed registry", async ({ + expect, + }) => { + await pushCommand( + { TAG: "test-app:tag", pathToDocker: "docker" }, + "some-account-id" + ); + + expect(vi.mocked(fetchResultMock)).toHaveBeenCalledWith( + {}, + "/accounts/some-account-id/containers/registries/registry.cloudflare.com/credentials", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expiration_minutes: 15, + permissions: ["push", "pull"], + }), + } + ); + expectSpawnWith([ + "image", + "inspect", + "test-app:tag", + "--format", + "{{ .Os }}/{{ .Architecture }}", + ]); + expectSpawnWith([ + "tag", + "test-app:tag", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ]); + expectSpawnWith([ + "push", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ]); + }); + + it("rejects unsupported image platforms", async ({ expect }) => { + inspectOutputs = ["linux/arm64"]; + + await expect( + pushCommand( + { TAG: "test-app:tag", pathToDocker: "docker" }, + "some-account-id" + ) + ).rejects.toThrow("Unsupported platform"); + expectNoSpawnWith([ + "push", + `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, + ]); + }); +}); diff --git a/packages/containers-shared/tests/login.test.ts b/packages/containers-shared/tests/login.test.ts index 2c631febd49..9a5760343ed 100644 --- a/packages/containers-shared/tests/login.test.ts +++ b/packages/containers-shared/tests/login.test.ts @@ -1,33 +1,95 @@ -import { afterEach, describe, it } from "vitest"; -import { OpenAPI } from "../src/client/core/OpenAPI"; -import { configureOpenAPIForContainerPull } from "../src/login"; - -describe("configureOpenAPIForContainerPull", () => { - afterEach(() => { - OpenAPI.BASE = ""; - OpenAPI.HEADERS = undefined; - OpenAPI.CREDENTIALS = "include"; +import { spawn, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { Writable } from "node:stream"; +import { beforeEach, describe, it, vi } from "vitest"; +import { + getCloudflareContainerRegistry, + initContainersSharedContext, + dockerLoginImageRegistry, +} from "../index"; +import type { FetchResultFetcher, Logger } from "@cloudflare/workers-utils"; + +vi.mock("node:child_process"); + +const logger: Logger = { + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +let fetchResultMock: FetchResultFetcher; +let stdinChunks: string[]; + +function createFakeChildProcess(): ChildProcess { + const child = new EventEmitter() as ChildProcess; + const stdin = new Writable({ + write(chunk, _encoding, callback) { + stdinChunks.push(String(chunk)); + callback(); + }, }); - it("sets BASE, HEADERS, and CREDENTIALS", ({ expect }) => { - configureOpenAPIForContainerPull("abc123", "my-token"); - expect(OpenAPI.BASE).toBe( - "https://api.cloudflare.com/client/v4/accounts/abc123/containers" - ); - expect(OpenAPI.CREDENTIALS).toBe("omit"); - expect((OpenAPI.HEADERS as Record)["Authorization"]).toBe( - "Bearer my-token" - ); + Object.assign(child, { + stdin, }); - it("uses custom apiBase when provided", ({ expect }) => { - configureOpenAPIForContainerPull( - "abc123", - "my-token", - "https://staging.cloudflare.com/client/v4" + process.nextTick(() => { + child.emit("close", 0); + }); + + return child; +} + +describe("loginImageRegistry", () => { + beforeEach(() => { + vi.clearAllMocks(); + stdinChunks = []; + vi.mocked(spawn).mockReturnValue(createFakeChildProcess()); + fetchResultMock = vi.fn(async () => { + return { + account_id: "some-account-id", + username: "username", + password: "password", + registry_host: getCloudflareContainerRegistry(), + } as ResponseType; + }) as FetchResultFetcher; + initContainersSharedContext({ logger, fetchResult: fetchResultMock }); + }); + + it("gets registry credentials with fetchResult before running docker login", async ({ + expect, + }) => { + await dockerLoginImageRegistry( + "docker", + getCloudflareContainerRegistry(), + "some-account-id" + ); + + expect(vi.mocked(fetchResultMock)).toHaveBeenCalledWith( + {}, + "/accounts/some-account-id/containers/registries/registry.cloudflare.com/credentials", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expiration_minutes: 15, + permissions: ["push", "pull"], + }), + } ); - expect(OpenAPI.BASE).toBe( - "https://staging.cloudflare.com/client/v4/accounts/abc123/containers" + expect(spawn).toHaveBeenCalledWith( + "docker", + [ + "login", + "--password-stdin", + "--username", + "username", + getCloudflareContainerRegistry(), + ], + { stdio: ["pipe", "inherit", "inherit"] } ); + expect(stdinChunks).toStrictEqual(["password"]); }); }); diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index 0bd25be87c2..b87c7826b63 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/containers-shared": "workspace:*", "@cloudflare/workers-utils": "workspace:*", "blake3-wasm": "2.1.5", "chalk": "catalog:default", @@ -53,7 +54,6 @@ "undici": "catalog:default" }, "devDependencies": { - "@cloudflare/containers-shared": "workspace:*", "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cspotcode/source-map-support": "0.8.1", diff --git a/packages/deploy-helpers/scripts/deps.ts b/packages/deploy-helpers/scripts/deps.ts index aba9685ddd3..13348a299cf 100644 --- a/packages/deploy-helpers/scripts/deps.ts +++ b/packages/deploy-helpers/scripts/deps.ts @@ -8,6 +8,7 @@ export const EXTERNAL_DEPENDENCIES = [ // Workspace packages kept external so consumers share a single copy of // types and runtime code (e.g. ParseError instanceof checks). "@cloudflare/cli-shared-helpers", + "@cloudflare/containers-shared", "@cloudflare/workers-utils", "miniflare", diff --git a/packages/deploy-helpers/src/deploy/deploy.ts b/packages/deploy-helpers/src/deploy/deploy.ts index c8d768c1aac..77f9ca3049a 100644 --- a/packages/deploy-helpers/src/deploy/deploy.ts +++ b/packages/deploy-helpers/src/deploy/deploy.ts @@ -3,7 +3,11 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { URLSearchParams } from "node:url"; import { cancel } from "@cloudflare/cli-shared-helpers"; -import { verifyDockerInstalled } from "@cloudflare/containers-shared"; +import { + deployContainers, + initContainersSharedContext, + pushBuiltContainerImage, +} from "@cloudflare/containers-shared"; import { APIError, formatTime, @@ -74,10 +78,7 @@ import type { Percentage, VersionId, } from "./helpers/versions-types"; -import type { - ContainerNormalizedConfig, - ImageURIConfig, -} from "@cloudflare/containers-shared"; +import type { ResolvedContainerDeployment } from "@cloudflare/containers-shared"; import type { CfModule, CfWorkerInit, @@ -91,7 +92,7 @@ import type { FormData } from "undici"; /** * Wrangler-specific functions injected into `deploy()`. These remain in * wrangler because they depend on wrangler-only systems (account selection, - * metrics, the dev-mode worker registry, container orchestration, etc.). + * metrics, the dev-mode worker registry, etc.). */ export type DeployCallbacks = { syncWorkersSite: @@ -108,31 +109,6 @@ export type DeployCallbacks = { namespace: string | undefined; }>) | undefined; - getNormalizedContainerOptions: - | (( - config: Config, - args: { - containersRollout?: "gradual" | "immediate" | "none"; - dryRun?: boolean; - } - ) => Promise) - | undefined; - buildContainer: - | (( - containerConfig: Exclude, - imageTag: string, - dryRun: boolean, - pathToDocker: string, - verifyDockerIsRunning: boolean - ) => Promise) - | undefined; - deployContainers: - | (( - config: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], - args: { versionId: string; accountId: string; scriptName: string } - ) => Promise) - | undefined; analyseBundle: | ((workerBundle: string | FormData) => Promise>) | undefined; @@ -227,9 +203,10 @@ async function deployWorker( const isDryRun = props.dryRun; - const normalisedContainerConfig = callbacks.getNormalizedContainerOptions - ? await callbacks.getNormalizedContainerOptions(config, props) - : []; + const normalisedContainerConfig = props.normalisedContainerConfig; + const builtContainerDeployments = props.builtContainerDeployments; + const shouldDeployContainers = + normalisedContainerConfig.length > 0 && props.containersRollout !== "none"; const { modules, dependencies, @@ -401,47 +378,7 @@ async function deployWorker( let workerBundle: FormData; const dockerPath = getDockerPath(); - // lets fail earlier in the case where docker isn't installed - // and we have containers so that we don't get into a - // disjointed state where the worker updates but the container - // fails. - if (normalisedContainerConfig.length && props.containersRollout !== "none") { - // if you have a registry url specified, you don't need docker - const containersWithDockerfile = normalisedContainerConfig.filter( - (container) => "dockerfile" in container - ); - if (containersWithDockerfile.length > 0) { - await verifyDockerInstalled({ - dockerPath, - operation: `deploying${isDryRun ? " (even in dry-run mode)" : ""}`, - imageNoun: - containersWithDockerfile.length !== 1 - ? "the configured images" - : "the configured image", - hint: "If you cannot run Docker locally, you can still deploy your Worker by passing --containers-rollout=none. This will not deploy or update your Container.", - }); - } - } - if (isDryRun) { - if (normalisedContainerConfig.length) { - for (const container of normalisedContainerConfig) { - if ( - "dockerfile" in container && - props.containersRollout !== "none" && - callbacks.buildContainer - ) { - await callbacks.buildContainer( - container, - workerTag ?? "worker-tag", - isDryRun, - dockerPath, - false - ); - } - } - } - workerBundle = createWorkerUploadForm( worker, addWorkersSitesBindings( @@ -763,13 +700,37 @@ async function deployWorker( logger.log("Uploaded", workerName, formatTime(uploadMs)); - if ( - normalisedContainerConfig.length && - props.containersRollout !== "none" && - callbacks.deployContainers - ) { + if (shouldDeployContainers) { assert(versionId && accountId); - await callbacks.deployContainers(config, normalisedContainerConfig, { + initContainersSharedContext({ logger, fetchResult }); + const containerDeployments: ResolvedContainerDeployment[] = []; + for (const container of normalisedContainerConfig) { + if ("dockerfile" in container) { + const builtContainerDeployment = builtContainerDeployments.find( + (deployment) => deployment.container === container + ); + assert( + builtContainerDeployment, + "Expected container image to be built before upload" + ); + containerDeployments.push({ + container, + imageRef: await pushBuiltContainerImage( + builtContainerDeployment.builtImage, + versionId, + dockerPath, + accountId, + config + ), + }); + } else { + containerDeployments.push({ + container, + imageRef: { newTag: container.image_uri }, + }); + } + } + await deployContainers(config, containerDeployments, { versionId, accountId, scriptName, diff --git a/packages/deploy-helpers/src/preview/preview.ts b/packages/deploy-helpers/src/preview/preview.ts index 5d3547913bf..a4df4f49712 100644 --- a/packages/deploy-helpers/src/preview/preview.ts +++ b/packages/deploy-helpers/src/preview/preview.ts @@ -1,5 +1,11 @@ import path from "node:path"; -import { verifyDockerInstalled } from "@cloudflare/containers-shared"; +import { getLogLevel, setLogLevel } from "@cloudflare/cli-shared-helpers"; +import { + apply, + initContainersSharedContext, + listDurableObjects, + pushBuiltContainerImage, +} from "@cloudflare/containers-shared"; import { configFileName, getBindings, @@ -12,7 +18,7 @@ import { syncAssets } from "../deploy/helpers/assets"; import { moduleTypeMimeType } from "../deploy/helpers/create-worker-upload-form"; import { parseConfigPlacement } from "../deploy/helpers/placement"; import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; -import { confirm, logger } from "../shared/context"; +import { confirm, fetchResult, logger } from "../shared/context"; import { getSubdomainValues } from "../triggers/deploy"; import { createPreview, @@ -34,11 +40,9 @@ import { getPreviewOwnedContainerClassNames, getPullRequestMetadata, getRepositoryUrl, - previewContainerAppName, resolveWorkerName, shouldUseCIMetadataFallback, } from "./shared"; -import type { DeployCallbacks } from "../deploy/deploy"; import type { WorkerBuildResult } from "../shared/types"; import type { Binding, @@ -48,12 +52,12 @@ import type { PreviewResource, } from "./api"; import type { PullRequestMetadata } from "./shared"; -import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { - Config, - ContainerApp, - PreviewsConfig, -} from "@cloudflare/workers-utils"; + BuiltContainerDeployment, + ContainerNormalizedConfig, + DurableObjectNamespace, +} from "@cloudflare/containers-shared"; +import type { Config, Logger, PreviewsConfig } from "@cloudflare/workers-utils"; export type PreviewArgs = { script?: string; @@ -90,208 +94,27 @@ export type PreviewResult = { isNewPreview: boolean; }; +export type PreviewContainerPreparation = { + scopedContainerConfig: Config | undefined; + normalisedContainerConfig: ContainerNormalizedConfig[]; + builtContainerDeployments: BuiltContainerDeployment[]; +}; + // Building and applying a container to Cloudchamber requires wrangler-only -// dependencies (Docker, the containers API client) that deploy-helpers has -// no direct dependency on. As with `DeployCallbacks` (see ../deploy/deploy.ts), -// the wrangler-specific implementation is injected by the caller. -// -// `getNormalizedContainerOptions` validates and normalises container config -// without needing the preview deployment to exist yet, so `preview()` runs it -// before creating the deployment. A bad config or a missing Docker install -// then fails before the preview goes live, rather than leaving a preview -// running that advertises containers nothing ever built. `deployPreviewContainers` -// does need the deployment, since that's what resolves each container's DO -// namespace_id, so it still runs after. -export type PreviewCallbacks = Pick< - DeployCallbacks, - "getNormalizedContainerOptions" -> & { - deployPreviewContainers: +// dependencies (Docker, the containers API client) that deploy-helpers has no +// direct dependency on. As with `DeployCallbacks` (see ../deploy/deploy.ts), +// the wrangler-specific preparation implementation is injected by the caller. +export type PreviewCallbacks = { + preparePreviewContainers: | (( - scopedConfig: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], - deployment: DeploymentResource, - accountId: string, - // Building and applying containers prints progress to stdout, the - // same stream that carries the `--json` payload. Set this when - // stdout has to stay machine readable. + config: Config, + workerName: string, + previewSlug: string, options: { quiet: boolean } - ) => Promise) + ) => Promise) | undefined; - // Confirms the API token carries the scope needed to apply containers. - verifyContainersScope?: (scopedConfig: Config) => Promise; }; -/** - * Construct a synthetic `Config` for the preview's containers, so we can reuse - * `getNormalizedContainerOptions` and `apply` from the standard `wrangler - * deploy` container path without forking either. Containers come from - * `previews.containers`, defaulting each unnamed entry to a generated - * application name, and DO bindings come from `previews.durable_objects`. - * - * `observability` is carried over because a container application has its own - * observability setting, which `getNormalizedContainerOptions` reads from the - * config it is given. The container path does not read `logpush`, `limits`, or - * `cache`, so overlaying those here would have no effect. - * - * Throws if a container names no Durable Object class, or if the class it names - * is not one this script implements. Returns `undefined` if every container - * resolves only to a cross-script binding, since those are owned by another - * Worker. - */ -function buildPreviewContainerConfig( - config: Config, - parentWorkerName: string, - previewSlug: string, - previewContainers: ContainerApp[] -): Config | undefined { - const previews = config.previews as PreviewsConfig | undefined; - const previewDOBindings = previews?.durable_objects?.bindings ?? []; - const ownedDOClasses = getPreviewOwnedContainerClassNames(config, previews); - - // A preview container has to name its Durable Object class itself. The other - // direction of the link, a Durable Object naming its container through - // `exports[Class].container`, resolves against the top level `containers` - // array, so it can only ever reach a container this preview does not own. - const linkedContainers = previewContainers.map((container) => { - const className = container.class_name; - if (className === undefined) { - throw new UserError( - `A container entry in "previews.containers" is missing "class_name". A preview container must name the Durable Object class it backs, even where a Durable Object declared in "exports" names its container instead.`, - { - telemetryMessage: "preview container missing class_name", - } - ); - } - return { container, className }; - }); - - // A container whose class matches no Durable Object at all is a - // misconfiguration, almost always a typo, and silently dropping it would - // hand back a preview with no container and no explanation, so reject it - // here, before the preview deployment is created. - // - // A class that does match a binding carrying `script_name` is excluded - // rather than rejected: that DO is implemented by another Worker, which owns - // its own container application. - for (const { className } of linkedContainers) { - if ( - ownedDOClasses.has(className) || - previewDOBindings.some((b) => b.class_name === className) - ) { - continue; - } - throw new UserError( - `The container class_name "${className}" in "previews.containers" does not match any Durable Object class in your ${configFileName(config.configPath)} file. Declare the class in "migrations" or "exports", or bind it under "previews.durable_objects".`, - { - telemetryMessage: "no preview DO class matches container class_name", - } - ); - } - - const filteredContainers = linkedContainers - .filter(({ className }) => ownedDOClasses.has(className)) - .map(({ container, className }) => ({ - ...container, - name: previewContainerAppName(parentWorkerName, previewSlug, className), - })); - - if (filteredContainers.length === 0) { - return undefined; - } - - // `getNormalizedContainerOptions` resolves a container's Durable Object with - // `find()` on `class_name`, and rejects the container outright if that first - // match carries `script_name`. A class bound both locally and cross-script - // would then fail as though another Worker owned it, purely because of - // binding order. Put the locally implemented bindings first so the lookup - // lands on the one this preview owns. - const localBindingsFirst = [ - ...previewDOBindings.filter((b) => b.script_name === undefined), - ...previewDOBindings.filter((b) => b.script_name !== undefined), - ]; - - const observability = previews?.observability ?? config.observability; - return { - ...config, - containers: filteredContainers, - durable_objects: { - bindings: localBindingsFirst, - }, - observability, - }; -} - -/** - * Validate and normalise container config, and confirm Docker is installed - * for any container built from a Dockerfile. Called before the preview - * deployment is created, so a bad config or a missing Docker install fails - * before the preview goes live, rather than leaving a preview running that - * advertises containers nothing ever built. - * - * Returns an empty `normalisedContainerConfig` when there's nothing to - * deploy, whether because `previews.containers` is empty or every entry - * resolves to a cross-script DO binding owned by another Worker. Throws if an - * entry's `class_name` matches no DO binding in `previews.durable_objects`. - */ -async function prepareContainersForPreview( - config: Config, - workerName: string, - previewSlug: string, - callbacks: PreviewCallbacks -): Promise<{ - scopedContainerConfig: Config | undefined; - normalisedContainerConfig: ContainerNormalizedConfig[]; -}> { - const previewContainers = - (config.previews as PreviewsConfig | undefined)?.containers ?? []; - if ( - previewContainers.length === 0 || - !callbacks.getNormalizedContainerOptions - ) { - return { scopedContainerConfig: undefined, normalisedContainerConfig: [] }; - } - - const scopedContainerConfig = buildPreviewContainerConfig( - config, - workerName, - previewSlug, - previewContainers - ); - if (!scopedContainerConfig) { - return { scopedContainerConfig: undefined, normalisedContainerConfig: [] }; - } - - const normalisedContainerConfig = - await callbacks.getNormalizedContainerOptions(scopedContainerConfig, { - dryRun: false, - }); - - const containersNeedingDocker = normalisedContainerConfig.filter( - (container) => "dockerfile" in container - ); - if (containersNeedingDocker.length > 0) { - await verifyDockerInstalled({ - dockerPath: getDockerPath(), - operation: "creating a preview", - imageNoun: - containersNeedingDocker.length !== 1 - ? "the configured images" - : "the configured image", - hint: 'If you cannot run Docker locally, set "image" to a prebuilt registry image instead of a Dockerfile path for the affected entries in "previews.containers".', - }); - } - - // Applying containers checks the token's scope as well, but only after the - // deployment exists. Checking it here stops a badly scoped token from - // leaving a live preview that advertises containers nothing ever built. - if (callbacks.verifyContainersScope) { - await callbacks.verifyContainersScope(scopedContainerConfig); - } - - return { scopedContainerConfig, normalisedContainerConfig }; -} - export const NO_ACTIVE_PREVIEW_URLS_MESSAGE = "Note: This Preview deployment has no active URLs. To get one, enable Preview Deployments on workers.dev or a custom domain. See https://developers.cloudflare.com/workers/previews/custom-domains/ for more information"; @@ -762,13 +585,22 @@ export async function preview( } } - const { scopedContainerConfig, normalisedContainerConfig } = - await prepareContainersForPreview( - config, - workerName, - previewResource.slug, - callbacks - ); + const { + scopedContainerConfig, + normalisedContainerConfig, + builtContainerDeployments, + } = callbacks.preparePreviewContainers + ? await callbacks.preparePreviewContainers( + config, + workerName, + previewResource.slug, + { quiet: args.json === true } + ) + : { + scopedContainerConfig: undefined, + normalisedContainerConfig: [], + builtContainerDeployments: [], + }; const deploymentRequest = await assemblePreviewDeploymentSettings( config, @@ -793,21 +625,18 @@ export async function preview( deploymentRequest ); - if ( - normalisedContainerConfig.length > 0 && - scopedContainerConfig && - callbacks.deployPreviewContainers - ) { + if (normalisedContainerConfig.length > 0 && scopedContainerConfig) { try { - await callbacks.deployPreviewContainers( + await deployPreviewContainers( scopedContainerConfig, normalisedContainerConfig, + builtContainerDeployments, deployment, accountId, { quiet: args.json === true } ); } catch (error) { - // The deployment is live by this point, so say so before the build or + // The deployment is live by this point, so say so before the push or // apply error surfaces on its own. Written to stderr so it cannot // corrupt a `--json` payload. logger.warn( @@ -849,6 +678,167 @@ export async function preview( return { preview: previewResource, deployment, isNewPreview }; } +/** + * Resolve each normalised preview container's Durable Object namespace, then + * push and apply its Cloudchamber application. + * + * The DO namespace for a preview is provisioned by the workers control plane. + * For a bound Durable Object it comes back in the create-deployment response, + * so we read it from `deployment.env` rather than re-fetching. A Durable Object + * reached only through `ctx.exports` has no binding to carry it, so those fall + * back to the namespaces list API. + */ +async function deployPreviewContainers( + scopedConfig: Config, + normalisedContainerConfig: ContainerNormalizedConfig[], + builtContainerDeployments: BuiltContainerDeployment[], + deployment: DeploymentResource, + accountId: string, + options: { quiet: boolean } +): Promise { + await runPreviewContainerOperation(options, async () => { + const dockerPath = getDockerPath(); + const classNameToNamespaceId = new Map(); + + // Skip bindings carrying `script_name`. Those name a Durable Object + // implemented by another Worker, which owns its own container application, + // so their namespace belongs to that Worker. A preview may bind the same + // class name both locally and cross-script, and since this map is keyed on + // class name alone, an unfiltered cross-script entry could overwrite the + // preview's own namespace_id and attach the container to the wrong storage. + // `wrangler deploy` applies the same restriction. + for (const binding of Object.values(deployment.env ?? {})) { + if ( + binding.type === "durable_object_namespace" && + binding.class_name && + binding.namespace_id && + binding.script_name === undefined + ) { + classNameToNamespaceId.set(binding.class_name, binding.namespace_id); + } + } + + // Only bound Durable Objects appear in `deployment.env`. A class reached + // solely through `ctx.exports` still has a namespace provisioned for the + // preview, so fall back to the namespaces list and match on it, the same way + // `wrangler deploy` resolves an unbound Durable Object. + let allNamespaces: DurableObjectNamespace[] | undefined; + + for (const container of normalisedContainerConfig) { + let namespaceId = classNameToNamespaceId.get(container.class_name); + if (!namespaceId) { + allNamespaces ??= await listDurableObjects(scopedConfig, accountId); + // `script` is the parent Worker's name for every one of its previews, + // so match on the preview id to avoid attaching this container to the + // parent's namespace or to another preview's. + namespaceId = allNamespaces.find( + (namespace) => + namespace.class === container.class_name && + namespace.preview?.id === deployment.preview_id + )?.id; + } + if (!namespaceId) { + throw new UserError( + `Could not deploy preview container application "${container.name}": no Durable Object namespace was found for class "${container.class_name}" in preview "${deployment.preview_name}". This is likely a bug in Wrangler. Please file an issue.`, + { + telemetryMessage: + "preview containers deploy missing do namespace id", + } + ); + } + + const imageRef = + "dockerfile" in container + ? await pushBuiltPreviewContainerImage({ + container, + builtContainerDeployments, + deployment, + dockerPath, + accountId, + scopedConfig, + }) + : { newTag: container.image_uri }; + + await apply( + { imageRef, durable_object_namespace_id: namespaceId }, + container, + scopedConfig, + accountId + ); + } + }); +} + +async function pushBuiltPreviewContainerImage({ + container, + builtContainerDeployments, + deployment, + dockerPath, + accountId, + scopedConfig, +}: { + container: ContainerNormalizedConfig; + builtContainerDeployments: BuiltContainerDeployment[]; + deployment: DeploymentResource; + dockerPath: string; + accountId: string; + scopedConfig: Config; +}) { + const builtContainerDeployment = builtContainerDeployments.find( + (deployment) => deployment.container === container + ); + if (!builtContainerDeployment) { + throw new UserError( + `Could not deploy preview container application "${container.name}": no built image was found for class "${container.class_name}". This is likely a bug in Wrangler. Please file an issue.`, + { + telemetryMessage: "preview containers deploy missing built image", + } + ); + } + + return await pushBuiltContainerImage( + builtContainerDeployment.builtImage, + deployment.id, + dockerPath, + accountId, + scopedConfig + ); +} + +async function runPreviewContainerOperation( + options: { quiet: boolean }, + operation: () => Promise +): Promise { + initContainersSharedContext({ + logger: options.quiet ? quietLogger : logger, + fetchResult, + }); + + if (!options.quiet) { + return operation(); + } + + // Building and applying containers prints progress to stdout, the same stream + // that carries the `--json` payload. Keep both logging surfaces quiet while + // stdout has to stay machine-readable. + const previousLogLevel = getLogLevel(); + setLogLevel("error"); + try { + return await operation(); + } finally { + setLogLevel(previousLogLevel); + initContainersSharedContext({ logger, fetchResult }); + } +} + +const quietLogger: Logger = { + debug() {}, + log() {}, + info() {}, + warn: (...args: unknown[]) => logger.warn(...args), + error: (...args: unknown[]) => logger.error(...args), +}; + /** * Delete a preview and all its deployments. */ diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index 3b155fe3746..b2bf898a1a6 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -1,3 +1,7 @@ +import type { + BuiltContainerDeployment, + ContainerNormalizedConfig, +} from "@cloudflare/containers-shared"; import type { ValidatedAssetsOptions, LegacyAssetPaths, @@ -127,6 +131,10 @@ export type DeployProps = SharedDeployVersionsProps & { oldAssetTtl: number | undefined; /** From --containers-rollout arg. Deploy-only. */ containersRollout: "immediate" | "gradual" | "none" | undefined; + /** Normalized Wrangler container configuration, resolved before calling deploy-helpers. */ + normalisedContainerConfig: ContainerNormalizedConfig[]; + /** Dockerfile container images built by the caller before invoking deploy-helpers. */ + builtContainerDeployments: BuiltContainerDeployment[]; /** * When true, an existing Worker with the same name aborts the deploy instead * of updating it, because this run cannot confirm the local project owns the diff --git a/packages/deploy-helpers/tests/preview-containers.test.ts b/packages/deploy-helpers/tests/preview-containers.test.ts new file mode 100644 index 00000000000..13b199b4906 --- /dev/null +++ b/packages/deploy-helpers/tests/preview-containers.test.ts @@ -0,0 +1,242 @@ +import { + apply, + initContainersSharedContext, + listDurableObjects, + pushBuiltContainerImage, + SchedulingPolicy, +} from "@cloudflare/containers-shared"; +import { defaultWranglerConfig } from "@cloudflare/workers-utils"; +import { beforeEach, describe, it, vi } from "vitest"; +import { initDeployHelpersContext } from "../src"; +import { preview } from "../src/preview/preview"; +import type { WorkerBuildResult } from "../src/shared/types"; +import type { + BuiltContainerDeployment, + ContainerNormalizedConfig, + SharedContainerConfig, +} from "@cloudflare/containers-shared"; +import type { Config } from "@cloudflare/workers-utils"; + +const mockPreviewApi = vi.hoisted(() => ({ + createPreview: vi.fn(), + createPreviewDeployment: vi.fn(), + createPreviewParentWorker: vi.fn(), + deletePreview: vi.fn(), + editPreview: vi.fn(), + getPreview: vi.fn(), + getPreviewDeployment: vi.fn(), + getWorkerPreviewDefaults: vi.fn(), +})); + +vi.mock("@cloudflare/containers-shared", async (importOriginal) => ({ + ...(await importOriginal()), + apply: vi.fn(), + initContainersSharedContext: vi.fn(), + listDurableObjects: vi.fn(), + pushBuiltContainerImage: vi.fn(), +})); + +vi.mock("../src/preview/api", () => ({ + ...mockPreviewApi, +})); + +const ACCOUNT_ID = "some-account-id"; +const PREVIEW_ID = "preview-id"; +const DEPLOYMENT_ID = "deployment-id"; +const PREVIEW_APP_NAME = "test-worker_my-feature_MyContainer"; + +const previewResource = { + id: PREVIEW_ID, + name: "my-feature", + slug: "my-feature", + worker_name: "test-worker", + urls: [], +}; + +const deploymentResource = { + id: DEPLOYMENT_ID, + preview_id: PREVIEW_ID, + preview_name: "my-feature", + env: { + MY_CONTAINER: { + type: "durable_object_namespace", + class_name: "MyContainer", + namespace_id: "preview-do-ns-id", + }, + }, + urls: [], +}; + +function containerConfig( + overrides: Partial = {} +): ContainerNormalizedConfig { + return { + name: PREVIEW_APP_NAME, + class_name: "MyContainer", + max_instances: 0, + scheduling_policy: SchedulingPolicy.DEFAULT, + rollout_step_percentage: [90, 10], + rollout_kind: "full_auto", + rollout_active_grace_period: 0, + constraints: {}, + instance_type: "dev", + dockerfile: "/abs/path/Dockerfile", + image_build_context: "/abs/path", + ...overrides, + } as ContainerNormalizedConfig; +} + +function builtDeploymentFor( + container: ContainerNormalizedConfig +): BuiltContainerDeployment { + return { + container: container as BuiltContainerDeployment["container"], + builtImage: { + containerConfig: + container as BuiltContainerDeployment["builtImage"]["containerConfig"], + localTag: "preview:temp", + }, + }; +} + +const config = { + ...defaultWranglerConfig, + name: "test-worker", + compatibility_date: "2025-01-01", + durable_objects: { bindings: [] }, + migrations: [], +} as unknown as Config; + +const buildResult: WorkerBuildResult = { + modules: [], + sourceMaps: undefined, + dependencies: {}, + resolvedEntryPointPath: "index.js", + bundleType: "esm", + content: "export default {};", +}; + +describe("preview containers", () => { + beforeEach(() => { + vi.mocked(apply).mockReset(); + vi.mocked(initContainersSharedContext).mockReset(); + vi.mocked(listDurableObjects).mockReset(); + vi.mocked(listDurableObjects).mockResolvedValue([]); + vi.mocked(pushBuiltContainerImage).mockReset(); + vi.mocked(pushBuiltContainerImage).mockResolvedValue({ + newTag: "built:tag", + }); + mockPreviewApi.createPreview.mockReset(); + mockPreviewApi.createPreview.mockResolvedValue(previewResource); + mockPreviewApi.createPreviewDeployment.mockReset(); + mockPreviewApi.createPreviewDeployment.mockResolvedValue( + deploymentResource + ); + mockPreviewApi.getPreview.mockReset(); + mockPreviewApi.getPreview.mockResolvedValue(null); + + initDeployHelpersContext({ + logger: { + debug() {}, + log() {}, + info() {}, + warn() {}, + error() {}, + }, + fetchResult: vi.fn(), + fetchListResult: vi.fn(), + fetchPagedListResult: vi.fn(), + fetchKVGetValue: vi.fn(), + confirm: vi.fn(), + prompt: vi.fn(), + select: vi.fn(), + }); + }); + + it("pushes and applies prepared Dockerfile containers directly", async ({ + expect, + }) => { + const container = containerConfig(); + const builtDeployment = builtDeploymentFor(container); + + await preview( + ACCOUNT_ID, + { + name: "my-feature", + ignoreBaseConfig: false, + json: true, + }, + config, + buildResult, + undefined, + { + preparePreviewContainers: vi.fn(async () => ({ + scopedContainerConfig: config, + normalisedContainerConfig: [container], + builtContainerDeployments: [builtDeployment], + })), + } + ); + + expect(pushBuiltContainerImage).toHaveBeenCalledWith( + builtDeployment.builtImage, + DEPLOYMENT_ID, + expect.any(String), + ACCOUNT_ID, + config + ); + expect(apply).toHaveBeenCalledWith( + { + imageRef: { newTag: "built:tag" }, + durable_object_namespace_id: "preview-do-ns-id", + }, + container, + config, + ACCOUNT_ID + ); + }); + + it("uses image_uri directly for registry-image containers", async ({ + expect, + }) => { + const container = { + ...containerConfig(), + dockerfile: undefined, + image_build_context: undefined, + image_uri: "registry.cloudflare.com/some-account-id/test:latest", + } as unknown as ContainerNormalizedConfig; + delete (container as Record).dockerfile; + + await preview( + ACCOUNT_ID, + { + name: "my-feature", + ignoreBaseConfig: false, + json: true, + }, + config, + buildResult, + undefined, + { + preparePreviewContainers: vi.fn(async () => ({ + scopedContainerConfig: config, + normalisedContainerConfig: [container], + builtContainerDeployments: [], + })), + } + ); + + expect(pushBuiltContainerImage).not.toHaveBeenCalled(); + expect(apply).toHaveBeenCalledWith( + { + imageRef: { + newTag: "registry.cloudflare.com/some-account-id/test:latest", + }, + durable_object_namespace_id: "preview-do-ns-id", + }, + container, + config, + ACCOUNT_ID + ); + }); +}); diff --git a/packages/deploy-helpers/tsup.config.ts b/packages/deploy-helpers/tsup.config.ts index 59c6ec376aa..515205338b5 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -21,10 +21,7 @@ export default defineConfig(() => [ tsconfig: "tsconfig.json", metafile: true, sourcemap: process.env.SOURCEMAPS !== "false", - noExternal: [ - "@cloudflare/containers-shared", - /^@cloudflare\/workers-shared(\/.*)?$/, - ], + noExternal: [/^@cloudflare\/workers-shared(\/.*)?$/], external: [ /^@cloudflare\//, "blake3-wasm", diff --git a/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts index 287075457fa..9c495f4f9bd 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts @@ -1,6 +1,5 @@ -import { OpenAPI } from "@cloudflare/containers-shared"; -import { afterEach, beforeEach, describe, test, vi } from "vitest"; -import { configureContainerPull, getContainerOptions } from "../containers"; +import { describe, test } from "vitest"; +import { getContainerOptions } from "../containers"; import type { ResolvedWorkerConfig } from "../plugin-config"; type Containers = ResolvedWorkerConfig["containers"]; @@ -119,57 +118,3 @@ describe("getContainerOptions", () => { ).toEqual([]); }); }); - -describe("configureContainerPull", () => { - beforeEach(() => { - vi.stubEnv("CLOUDFLARE_API_BASE_URL", undefined); - vi.stubEnv("CF_API_BASE_URL", undefined); - vi.stubEnv("CLOUDFLARE_COMPLIANCE_REGION", undefined); - vi.stubEnv("WRANGLER_API_ENVIRONMENT", undefined); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - OpenAPI.BASE = ""; - OpenAPI.HEADERS = undefined; - OpenAPI.CREDENTIALS = "include"; - }); - - test("uses the FedRAMP High API for managed registry credentials", ({ - expect, - }) => { - configureContainerPull("abc123", "my-token", { - compliance_region: "fedramp_high", - }); - - expect(OpenAPI.BASE).toBe( - "https://api.fed.cloudflare.com/client/v4/accounts/abc123/containers" - ); - }); - - test("uses the staging FedRAMP High API for managed registry credentials", ({ - expect, - }) => { - vi.stubEnv("WRANGLER_API_ENVIRONMENT", "staging"); - - configureContainerPull("abc123", "my-token", { - compliance_region: "fedramp_high", - }); - - expect(OpenAPI.BASE).toBe( - "https://api.fed.staging.cloudflare.com/client/v4/accounts/abc123/containers" - ); - }); - - test("preserves the explicit API base override", ({ expect }) => { - vi.stubEnv("CLOUDFLARE_API_BASE_URL", "https://api.example.com/client/v4"); - - configureContainerPull("abc123", "my-token", { - compliance_region: "fedramp_high", - }); - - expect(OpenAPI.BASE).toBe( - "https://api.example.com/client/v4/accounts/abc123/containers" - ); - }); -}); diff --git a/packages/vite-plugin-cloudflare/src/containers.ts b/packages/vite-plugin-cloudflare/src/containers.ts index 12947f7dbe9..3233ee030f6 100644 --- a/packages/vite-plugin-cloudflare/src/containers.ts +++ b/packages/vite-plugin-cloudflare/src/containers.ts @@ -1,37 +1,57 @@ import path from "node:path"; import { - configureOpenAPIForContainerPull, getDevContainerImageName, + initContainersSharedContext, } from "@cloudflare/containers-shared"; import { COMPLIANCE_REGION_CONFIG_UNKNOWN, - getCloudflareApiBaseUrl, + fetchResultBase, isDockerfile, resolveContainerClassName, } from "@cloudflare/workers-utils"; import type { ResolvedWorkerConfig } from "./plugin-config"; -import type { ComplianceConfig } from "@cloudflare/workers-utils"; +import type { FetchResultFetcher, Logger } from "@cloudflare/workers-utils"; /** - * Configures the Containers API client used to retrieve image pull credentials. + * Configures the Containers shared context used to retrieve image pull credentials. * - * @param accountId - Cloudflare account ID that owns the managed registry. * @param apiToken - API token used to request registry credentials. - * @param complianceConfig - Compliance configuration used to select the API endpoint. + * @param logger - Logger used for API request logging. * @returns No value. */ export function configureContainerPull( - accountId: string, apiToken: string, - complianceConfig?: ComplianceConfig + logger: Pick ): void { - configureOpenAPIForContainerPull( - accountId, - apiToken, - getCloudflareApiBaseUrl( - complianceConfig ?? COMPLIANCE_REGION_CONFIG_UNKNOWN - ) - ); + const containersLogger = { + debug: () => {}, + debugWithSanitization: () => {}, + log: logger.info.bind(logger), + info: logger.info.bind(logger), + warn: logger.warn.bind(logger), + error: logger.error.bind(logger), + } satisfies Logger; + + const fetchResult: FetchResultFetcher = async ( + requestComplianceConfig, + resource, + init, + queryParams, + abortSignal + ) => { + return await fetchResultBase( + requestComplianceConfig ?? COMPLIANCE_REGION_CONFIG_UNKNOWN, + resource, + init, + "@cloudflare/vite-plugin", + containersLogger, + queryParams, + abortSignal, + { apiToken } + ); + }; + + initContainersSharedContext({ logger: containersLogger, fetchResult }); } /** diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index e810ae0748c..17af8dd0faa 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -252,6 +252,7 @@ export const devPlugin = createPlugin("dev", (ctx) => { getCloudflareContainerRegistry(ctx.entryWorkerConfig) ); + let containerPullAccountId: string | undefined; if (hasCFRegistryImages) { const apiToken = process.env.CLOUDFLARE_API_TOKEN; const accountId = @@ -268,7 +269,8 @@ export const devPlugin = createPlugin("dev", (ctx) => { ); } - configureContainerPull(accountId, apiToken, ctx.entryWorkerConfig); + configureContainerPull(apiToken, viteDevServer.config.logger); + containerPullAccountId = accountId; } await prepareContainerImagesForDev({ @@ -277,6 +279,7 @@ export const devPlugin = createPlugin("dev", (ctx) => { onContainerImagePreparationStart: () => {}, onContainerImagePreparationEnd: () => {}, logger: viteDevServer.config.logger, + accountId: containerPullAccountId, complianceConfig: ctx.entryWorkerConfig, }); diff --git a/packages/vite-plugin-cloudflare/src/plugins/preview.ts b/packages/vite-plugin-cloudflare/src/plugins/preview.ts index 3f9244e313f..3e8c3f01108 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/preview.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/preview.ts @@ -75,6 +75,7 @@ export const previewPlugin = createPlugin("preview", (ctx) => { getCloudflareContainerRegistry(ctx.allWorkerConfigs[0]) ); + let containerPullAccountId: string | undefined; if (hasCFRegistryImages) { const apiToken = process.env.CLOUDFLARE_API_TOKEN; const accountId = @@ -93,7 +94,8 @@ export const previewPlugin = createPlugin("preview", (ctx) => { ); } - configureContainerPull(accountId, apiToken, ctx.allWorkerConfigs[0]); + configureContainerPull(apiToken, vitePreviewServer.config.logger); + containerPullAccountId = accountId; } await prepareContainerImagesForDev({ @@ -102,6 +104,7 @@ export const previewPlugin = createPlugin("preview", (ctx) => { onContainerImagePreparationStart: () => {}, onContainerImagePreparationEnd: () => {}, logger: vitePreviewServer.config.logger, + accountId: containerPullAccountId, complianceConfig: ctx.allWorkerConfigs[0], }); diff --git a/packages/wrangler/e2e/containers.dev.test.ts b/packages/wrangler/e2e/containers.dev.test.ts index b638a408942..6afbaf6abbb 100644 --- a/packages/wrangler/e2e/containers.dev.test.ts +++ b/packages/wrangler/e2e/containers.dev.test.ts @@ -5,7 +5,7 @@ import { stripVTControlCharacters } from "node:util"; import { getDockerPath } from "@cloudflare/workers-utils"; import { fetch } from "undici"; import { afterAll, beforeAll, beforeEach, describe, it, vi } from "vitest"; -import { buildImage } from "../../containers-shared/src/build"; +import { startContainerBuild } from "../../containers-shared/src/build"; import { generateContainerBuildId } from "../../containers-shared/src/utils"; import { dedent } from "../src/utils/dedent"; import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id"; @@ -306,12 +306,15 @@ for (const source of imageSource) { const initialImageTag = `cloudflare-dev/test-cleanup:${fakeBuildID}`; // First, build an image directly to create a duplicate tag scenario - const build = await buildImage(dockerPath, { - dockerfile: path.resolve(helper.tmpPath, "./Dockerfile"), - image_tag: initialImageTag, - class_name: "TestContainer", - image_build_context: helper.tmpPath, - image_vars: {}, + const build = await startContainerBuild({ + pathToDocker: dockerPath, + build: { + tag: initialImageTag, + pathToDockerfile: path.resolve(helper.tmpPath, "./Dockerfile"), + buildContext: helper.tmpPath, + args: {}, + platform: "linux/amd64", + }, }); await build.ready; diff --git a/packages/wrangler/src/__tests__/cloudchamber/build.test.ts b/packages/wrangler/src/__tests__/cloudchamber/build.test.ts index 56299ddc7fe..869c0af7155 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/build.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/build.test.ts @@ -1,15 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; -import { - dockerBuild, - dockerImageInspect, - dockerLoginImageRegistry, - getCloudflareContainerRegistry, - runDockerCmd, - runDockerCmdWithOutput, -} from "@cloudflare/containers-shared"; -import { UserError } from "@cloudflare/workers-utils"; +import { buildCommand } from "@cloudflare/containers-shared"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { runWrangler } from "../helpers/run-wrangler"; @@ -18,575 +10,60 @@ import { mockAccountV4 as mockAccount } from "./utils"; vi.mock("@cloudflare/containers-shared", async (importOriginal) => { const actual = await importOriginal(); return Object.assign({}, actual, { - dockerLoginImageRegistry: vi.fn(), - runDockerCmd: vi.fn(), - runDockerCmdWithOutput: vi.fn(), - dockerBuild: vi.fn(async () => ({ - abort: () => {}, - ready: Promise.resolve(), - })), - dockerImageInspect: vi.fn(), + buildCommand: vi.fn(), }); }); const dockerfile = - 'FROM node:18\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD ["node", "index.js"]'; + 'FROM node:22\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD ["node", "index.js"]'; -describe("buildAndMaybePush", () => { +describe("containers build", () => { runInTempDir(); mockApiToken(); mockAccountId(); mockConsoleMethods(); + beforeEach(() => { vi.clearAllMocks(); - vi.mocked(dockerImageInspect) - // return empty array of repo digests (i.e. image does not exist remotely) - .mockResolvedValueOnce("[]") - // return image size and number of layers - .mockResolvedValueOnce("53387881 2") - // return digest after pushing the namespaced image - .mockResolvedValueOnce( - '["registry.cloudflare.com/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]' - ); - // we can set this to anything since there is nothing to match from docker image inspect - vi.mocked(runDockerCmdWithOutput).mockReturnValueOnce( - '{"Descriptor":{"digest":"wont-match-sha"}}' - ); mkdirSync("./container-context"); - writeFileSync("./container-context/Dockerfile", dockerfile); mockAccount(); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should be able to build image and push with test-app:tag", async ({ - expect, - }) => { - await runWrangler( - "containers build ./container-context -t test-app:tag -p" - ); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - // turn this into a relative path so that this works across different OSes - "./container-context", - ], - dockerfile, - }); - - // 2 calls: docker tag + docker push - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(dockerImageInspect).toHaveBeenNthCalledWith(1, "docker", { - imageTag: `test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(2, "docker", { - imageTag: `test-app:tag`, - formatString: "{{ .Size }} {{ len .RootFS.Layers }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(3, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); - }); - - it("should be able to build image and push with registry.cloudflare.com/test-app:tag", async ({ - expect, - }) => { - await runWrangler( - "containers build ./container-context -t registry.cloudflare.com/test-app:tag -p" - ); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `registry.cloudflare.com/test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - // turn this into a relative path so that this works across different OSes - "./container-context", - ], - dockerfile, - }); - - // 2 calls: docker tag + docker push - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `registry.cloudflare.com/test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(dockerImageInspect).toHaveBeenNthCalledWith(1, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(2, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/test-app:tag`, - formatString: "{{ .Size }} {{ len .RootFS.Layers }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(3, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); + vi.mocked(buildCommand).mockResolvedValue(undefined); }); - it("should be able to build image and push with registry.cloudflare.com/some-account-id/test-app:tag", async ({ - expect, - }) => { - await runWrangler( - "containers build ./container-context -t registry.cloudflare.com/some-account-id/test-app:tag -p" - ); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `registry.cloudflare.com/some-account-id/test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - // turn this into a relative path so that this works across different OSes - "./container-context", - ], - dockerfile, - }); - - // 2 calls: docker tag + docker push - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `registry.cloudflare.com/some-account-id/test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(dockerImageInspect).toHaveBeenNthCalledWith(1, "docker", { - imageTag: `registry.cloudflare.com/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(2, "docker", { - imageTag: `registry.cloudflare.com/some-account-id/test-app:tag`, - formatString: "{{ .Size }} {{ len .RootFS.Layers }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(3, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); - }); - - it("should use a custom docker path if provided", async ({ expect }) => { - vi.stubEnv("WRANGLER_DOCKER_BIN", "/custom/docker/path"); + it("calls the shared build command with parsed args", async ({ expect }) => { await runWrangler( "containers build ./container-context -t test-app:tag -p" ); - expect(dockerBuild).toHaveBeenCalledWith("/custom/docker/path", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - "./container-context", - ], - dockerfile, - }); - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(dockerImageInspect).toHaveBeenNthCalledWith( - 1, - "/custom/docker/path", - { - imageTag: `test-app:tag`, - formatString: "{{ json .RepoDigests }}", - } - ); - expect(dockerImageInspect).toHaveBeenNthCalledWith( - 2, - "/custom/docker/path", - { - imageTag: `test-app:tag`, - formatString: "{{ .Size }} {{ len .RootFS.Layers }}", - } - ); - expect(dockerImageInspect).toHaveBeenNthCalledWith( - 3, - "/custom/docker/path", - { - imageTag: `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - } - ); - expect(runDockerCmd).toHaveBeenCalledWith("/custom/docker/path", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(dockerLoginImageRegistry).toHaveBeenCalledWith( - "/custom/docker/path", - "registry.cloudflare.com" - ); - }); - it("should be able to build image and push", async ({ expect }) => { - await runWrangler( - "containers build ./container-context -t test-app:tag -p" + expect(buildCommand).toHaveBeenCalledOnce(); + expect(buildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + PATH: "./container-context", + tag: "test-app:tag", + pathToDocker: "docker", + push: true, + platform: "linux/amd64", + }), + expect.any(Object) ); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - // turn this into a relative path so that this works across different OSes - "./container-context", - ], - dockerfile, - }); - - // 2 calls: docker tag + docker push - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(dockerImageInspect).toHaveBeenNthCalledWith(1, "docker", { - imageTag: `test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(2, "docker", { - imageTag: `test-app:tag`, - formatString: "{{ .Size }} {{ len .RootFS.Layers }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(3, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); }); - it("should be able to build image and not push if it already exists in remote if config sha and digest both match", async ({ + it("passes a custom Docker path through to the shared command", async ({ expect, }) => { - vi.mocked(runDockerCmd).mockResolvedValueOnce({ - abort: () => {}, - ready: Promise.resolve({ aborted: false }), - }); - vi.mocked(dockerImageInspect).mockReset(); - vi.mocked(dockerImageInspect) - .mockResolvedValueOnce( - '["registry.cloudflare.com/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]' - ) - .mockResolvedValueOnce("53387881 2"); - vi.mocked(runDockerCmdWithOutput).mockReset(); - vi.mocked(runDockerCmdWithOutput).mockImplementationOnce(() => { - return '{"Descriptor":{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'; - }); - await runWrangler( - "containers build ./container-context -t test-app:tag -p" + "containers build ./container-context -t test-app:tag --path-to-docker /custom/docker" ); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - "./container-context", - ], - dockerfile, - }); - expect(runDockerCmdWithOutput).toHaveBeenCalledOnce(); - expect(runDockerCmdWithOutput).toHaveBeenCalledWith("docker", [ - "manifest", - "inspect", - "-v", - `${getCloudflareContainerRegistry()}/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, - ]); - expect(dockerImageInspect).toHaveBeenCalledTimes(2); - expect(dockerImageInspect).toHaveBeenNthCalledWith(1, "docker", { - imageTag: `test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - expect(dockerImageInspect).toHaveBeenNthCalledWith(2, "docker", { - imageTag: `test-app:tag`, - formatString: "{{ .Size }} {{ len .RootFS.Layers }}", - }); - expect(runDockerCmd).toHaveBeenCalledOnce(); - expect(runDockerCmd).toHaveBeenCalledWith("docker", [ - "image", - "rm", - "test-app:tag", - ]); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); - }); - - it("should inspect the pushed image digest if the local digest is not remote", async ({ - expect, - }) => { - vi.mocked(dockerImageInspect).mockReset(); - vi.mocked(dockerImageInspect) - .mockResolvedValueOnce( - '["registry.cloudflare.com/test-app@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]' - ) - .mockResolvedValueOnce("53387881 2") - .mockResolvedValueOnce( - '["registry.cloudflare.com/some-account-id/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]' - ); - vi.mocked(runDockerCmdWithOutput).mockReset(); - vi.mocked(runDockerCmdWithOutput).mockImplementationOnce(() => { - return '{"Descriptor":{"digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}}'; - }); - await runWrangler( - "containers build ./container-context -t test-app:tag -p" - ); - - expect(runDockerCmdWithOutput).toHaveBeenCalledOnce(); - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(dockerImageInspect).toHaveBeenNthCalledWith(3, "docker", { - imageTag: `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - formatString: "{{ json .RepoDigests }}", - }); - }); - - it("should match digests for images with registry ports", async ({ - expect, - }) => { - vi.mocked(runDockerCmd).mockResolvedValueOnce({ - abort: () => {}, - ready: Promise.resolve({ aborted: false }), - }); - vi.mocked(dockerImageInspect).mockReset(); - vi.mocked(dockerImageInspect) - .mockResolvedValueOnce( - '["localhost:5000/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]' - ) - .mockResolvedValueOnce("53387881 2"); - vi.mocked(runDockerCmdWithOutput).mockReset(); - vi.mocked(runDockerCmdWithOutput).mockImplementationOnce(() => { - return '{"Descriptor":{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'; - }); - - await runWrangler( - "containers build ./container-context -t localhost:5000/test-app:tag -p" - ); - - expect(runDockerCmdWithOutput).toHaveBeenCalledOnce(); - expect(runDockerCmdWithOutput).toHaveBeenCalledWith("docker", [ - "manifest", - "inspect", - "-v", - "localhost:5000/test-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ]); - expect(runDockerCmd).toHaveBeenCalledTimes(1); - expect(runDockerCmd).toHaveBeenCalledWith("docker", [ - "image", - "rm", - "localhost:5000/test-app:tag", - ]); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); - }); - - it("should be able to build image and not push", async ({ expect }) => { - await runWrangler("containers build ./container-context -t test-app"); - expect(dockerBuild).toHaveBeenCalledTimes(1); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - "./container-context", - ], - dockerfile, - }); - expect(dockerImageInspect).not.toHaveBeenCalled(); - expect(dockerLoginImageRegistry).not.toHaveBeenCalled(); - }); - - it("should fall back to manifest inspect if pushed image digests are unavailable", async ({ - expect, - }) => { - vi.mocked(dockerImageInspect).mockReset(); - vi.mocked(dockerImageInspect) - .mockResolvedValueOnce("[]") - .mockResolvedValueOnce("53387881 2") - .mockResolvedValueOnce("[]"); - vi.mocked(runDockerCmdWithOutput).mockReset(); - vi.mocked(runDockerCmdWithOutput).mockImplementationOnce(() => { - return '{"Descriptor":{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'; - }); - - await runWrangler( - "containers build ./container-context -t test-app:tag -p" - ); - - expect(dockerImageInspect).toHaveBeenCalledTimes(3); - expect(runDockerCmdWithOutput).toHaveBeenCalledOnce(); - expect(runDockerCmdWithOutput).toHaveBeenCalledWith("docker", [ - "manifest", - "inspect", - "-v", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(dockerLoginImageRegistry).toHaveBeenCalledOnce(); - }); - - it("should add --network=host flag if WRANGLER_CI_OVERRIDE_NETWORK_MODE_HOST is set", async ({ - expect, - }) => { - vi.stubEnv("WRANGLER_CI_OVERRIDE_NETWORK_MODE_HOST", "true"); - await runWrangler("containers build ./container-context -t test-app"); - expect(dockerBuild).toHaveBeenCalledTimes(1); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app`, - "--platform", - "linux/amd64", - "--provenance=false", - "--network", - "host", - "-f", - "-", - "./container-context", - ], - dockerfile, - }); - }); - - it("should be able to build image with platform specified", async ({ - expect, - }) => { - await runWrangler( - "containers build ./container-context -t test-app:tag -p --platform linux/amd64" - ); - expect(dockerBuild).toHaveBeenCalledWith("docker", { - buildCmd: [ - "build", - "--load", - "-t", - `test-app:tag`, - "--platform", - "linux/amd64", - "--provenance=false", - "-f", - "-", - "./container-context", - ], - dockerfile, - }); - }); - - it("should fail with an invalid platform", async ({ expect }) => { - await expect( - runWrangler( - "containers build ./container-context -t test-app:tag -p --platform linux/arm64" - ) - ).rejects.toThrow("Unsupported platform"); - }); - - it("should throw UserError when docker build fails", async ({ expect }) => { - const errorMessage = "Docker build failed"; - vi.mocked(dockerBuild).mockResolvedValue({ - abort: () => {}, - ready: Promise.reject(new Error(errorMessage)), - }); - await expect( - runWrangler("containers build ./container-context -t test-app:tag") - ).rejects.toThrow( - new UserError(errorMessage, { - telemetryMessage: "cloudchamber build image operation failed", - }) - ); - }); - - it("should throw UserError when docker login fails", async ({ expect }) => { - const errorMessage = "Docker login failed"; - vi.mocked(dockerBuild).mockRejectedValue(new Error(errorMessage)); - vi.mocked(dockerLoginImageRegistry).mockRejectedValue( - new Error(errorMessage) - ); - await expect( - runWrangler("containers build ./container-context -t test-app:tag -p") - ).rejects.toThrow( - new UserError(errorMessage, { - telemetryMessage: "cloudchamber build image operation failed", - }) + expect(buildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + PATH: "./container-context", + tag: "test-app:tag", + pathToDocker: "/custom/docker", + push: false, + }), + expect.any(Object) ); }); }); diff --git a/packages/wrangler/src/__tests__/containers/deploy.test.ts b/packages/wrangler/src/__tests__/containers/deploy.test.ts index 7077201bbc3..641d80ee8e1 100644 --- a/packages/wrangler/src/__tests__/containers/deploy.test.ts +++ b/packages/wrangler/src/__tests__/containers/deploy.test.ts @@ -1,4 +1,5 @@ import { execFileSync, spawn } from "node:child_process"; +import crypto from "node:crypto"; import * as fs from "node:fs"; import path from "node:path"; import { PassThrough, Writable } from "node:stream"; @@ -42,6 +43,9 @@ import type { ExpectStatic } from "vitest"; vi.mock("node:child_process"); +const TEST_CONTAINER_BUILD_TAG = + "wrangler-11111111-1111-4111-8111-111111111111"; + describe("wrangler deploy with containers", () => { runInTempDir(); const std = mockConsoleMethods(); @@ -50,6 +54,9 @@ describe("wrangler deploy with containers", () => { mockApiToken(); beforeEach(() => { setupCommonMocks(); + vi.spyOn(crypto, "randomUUID").mockReturnValue( + "11111111-1111-4111-8111-111111111111" + ); fs.writeFileSync( "index.js", `export class ExampleDurableObject {}; export default{};` @@ -144,6 +151,7 @@ describe("wrangler deploy with containers", () => { " ⛅️ wrangler x.x.x ────────────────── + Building image my-container:wrangler-11111111-1111-4111-8111-111111111111 Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms Your Worker has access to the following bindings: @@ -154,7 +162,6 @@ describe("wrangler deploy with containers", () => { - my-container (/Dockerfile) Uploaded test-name (TIMINGS) - Building image my-container:Galaxy Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy Deployed test-name triggers (TIMINGS) https://test-name.test-sub-domain.workers.dev @@ -593,6 +600,7 @@ describe("wrangler deploy with containers", () => { " ⛅️ wrangler x.x.x ────────────────── + Building image my-container:wrangler-11111111-1111-4111-8111-111111111111 Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms Your Worker has access to the following bindings: @@ -603,7 +611,6 @@ describe("wrangler deploy with containers", () => { - my-container (/Dockerfile) Uploaded test-name (TIMINGS) - Building image my-container:Galaxy Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy Deployed test-name triggers (TIMINGS) https://test-name.test-sub-domain.workers.dev @@ -662,6 +669,7 @@ describe("wrangler deploy with containers", () => { " ⛅️ wrangler x.x.x ────────────────── + Building image my-container:wrangler-11111111-1111-4111-8111-111111111111 Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms Your Worker has access to the following bindings: @@ -672,7 +680,6 @@ describe("wrangler deploy with containers", () => { - my-container (/Dockerfile) Uploaded test-name (TIMINGS) - Building image my-container:Galaxy Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy Deployed test-name triggers (TIMINGS) https://test-name.test-sub-domain.workers.dev @@ -1815,6 +1822,7 @@ describe("wrangler deploy with containers", () => { mockGetVersion("Galaxy-Class"); const containerName = "my-container"; const tag = "Galaxy"; + const localTag = TEST_CONTAINER_BUILD_TAG; vi.mocked(spawn).mockReset(); vi.mocked(spawn) .mockImplementationOnce(mockDockerInfo(expect)) @@ -1822,21 +1830,26 @@ describe("wrangler deploy with containers", () => { mockDockerBuild( expect, containerName, - tag, + localTag, "FROM scratch", process.cwd() ) ) .mockImplementationOnce( - mockDockerImageInspectDigestsWithRepoDigest(expect, containerName, tag) + mockDockerImageInspectDigestsWithRepoDigest( + expect, + containerName, + localTag, + tag + ) ) .mockImplementationOnce( - mockDockerImageInspectSize(expect, containerName, tag) + mockDockerImageInspectSize(expect, containerName, localTag) ) .mockImplementationOnce(mockDockerLogin(expect, "mockpassword")) // Mock docker image rm call since we skip the push .mockImplementationOnce( - mockDockerImageDelete(expect, containerName, tag) + mockDockerImageDelete(expect, containerName, localTag) ); // // Add fallback mocks in case we fall through to push (for debugging) // .mockImplementationOnce( @@ -1928,6 +1941,7 @@ describe("wrangler deploy with containers", () => { mockGetVersion("Galaxy-Class"); const containerName = "my-container"; const tag = "Galaxy"; + const localTag = TEST_CONTAINER_BUILD_TAG; vi.mocked(spawn).mockReset(); vi.mocked(spawn) .mockImplementationOnce(mockDockerInfo(expect)) @@ -1935,20 +1949,25 @@ describe("wrangler deploy with containers", () => { mockDockerBuild( expect, containerName, - tag, + localTag, "FROM scratch", process.cwd() ) ) .mockImplementationOnce( - mockDockerImageInspectDigestsWithRepoDigest(expect, containerName, tag) + mockDockerImageInspectDigestsWithRepoDigest( + expect, + containerName, + localTag, + tag + ) ) .mockImplementationOnce( - mockDockerImageInspectSize(expect, containerName, tag) + mockDockerImageInspectSize(expect, containerName, localTag) ) .mockImplementationOnce(mockDockerLogin(expect, "mockpassword")) .mockImplementationOnce( - mockDockerImageDelete(expect, containerName, tag) + mockDockerImageDelete(expect, containerName, localTag) ); vi.mocked(execFileSync).mockImplementation( (_file: string, args?: readonly string[]) => { @@ -2694,7 +2713,7 @@ describe("wrangler deploy with containers dry run", () => { mockDockerBuild( expect, "my-container", - "worker", + TEST_CONTAINER_BUILD_TAG, "FROM scratch", process.cwd() ) @@ -2715,8 +2734,8 @@ describe("wrangler deploy with containers dry run", () => { " ⛅️ wrangler x.x.x ────────────────── + Building image my-container:wrangler-11111111-1111-4111-8111-111111111111 Total Upload: xx KiB / gzip: xx KiB - Building image my-container:worker Your Worker has access to the following bindings: Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object @@ -3010,17 +3029,18 @@ function createDockerMockChain( dockerfilePath?: string, buildContext?: string ) { + const localTag = TEST_CONTAINER_BUILD_TAG; const mocks = [ mockDockerInfo(expect), mockDockerBuild( expect, containerName, - tag, + localTag, dockerfilePath || "FROM scratch", buildContext || process.cwd() ), - mockDockerImageInspectDigests(expect, containerName, tag), - mockDockerImageInspectSize(expect, containerName, tag), + mockDockerImageInspectDigests(expect, containerName, localTag), + mockDockerImageInspectSize(expect, containerName, localTag), mockDockerLogin(expect, "mockpassword"), // Default manifest inspect output is invalid JSON, so Dockerfile deploy tests exercise // the push path before using the post-push RepoDigests lookup. @@ -3028,8 +3048,10 @@ function createDockerMockChain( expect, containerName, "some-account-id/" + containerName, + localTag, tag ), + mockDockerImageDelete(expect, containerName, localTag), mockDockerPush(expect, "some-account-id/" + containerName, tag), mockDockerImageInspectDigestsForImage( expect, @@ -3067,7 +3089,8 @@ function setupDockerMocks( .mockImplementationOnce(mocks[4]) .mockImplementationOnce(mocks[5]) .mockImplementationOnce(mocks[6]) - .mockImplementationOnce(mocks[7]); + .mockImplementationOnce(mocks[7]) + .mockImplementationOnce(mocks[8]); // Default mock for execFileSync to handle docker verification and other calls vi.mocked(execFileSync).mockImplementation( (_file: string, args?: readonly string[]) => { @@ -3431,7 +3454,8 @@ function mockDockerImageInspectDigestsForImage( function mockDockerImageInspectDigestsWithRepoDigest( expect: ExpectStatic, containerName: string, - tag: string + tag: string, + _repoTag = tag ) { return (cmd: string, args: readonly string[]) => { expect(cmd).toBe("/usr/bin/docker"); @@ -3561,14 +3585,15 @@ function mockDockerTag( expect: ExpectStatic, from: string, to: string, - tag: string + fromTag: string, + toTag: string = fromTag ) { return (cmd: string, args: readonly string[]) => { expect(cmd).toBe("/usr/bin/docker"); expect(args).toEqual([ "tag", - `${from}:${tag}`, - `${getCloudflareContainerRegistry()}/${to}:${tag}`, + `${from}:${fromTag}`, + `${getCloudflareContainerRegistry()}/${to}:${toTag}`, ]); return defaultChildProcess(); }; diff --git a/packages/wrangler/src/__tests__/containers/push.test.ts b/packages/wrangler/src/__tests__/containers/push.test.ts index 7b7c667ea98..23b1827dda9 100644 --- a/packages/wrangler/src/__tests__/containers/push.test.ts +++ b/packages/wrangler/src/__tests__/containers/push.test.ts @@ -1,8 +1,4 @@ -import { - dockerImageInspect, - getCloudflareContainerRegistry, - runDockerCmd, -} from "@cloudflare/containers-shared"; +import { pushCommand } from "@cloudflare/containers-shared"; import { beforeEach, describe, it, vi } from "vitest"; import { mockAccount, setWranglerConfig } from "../cloudchamber/utils"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; @@ -13,9 +9,7 @@ import { runWrangler } from "../helpers/run-wrangler"; vi.mock("@cloudflare/containers-shared", async (importOriginal) => { const actual = await importOriginal(); return Object.assign({}, actual, { - dockerLoginImageRegistry: vi.fn(), - runDockerCmd: vi.fn(), - dockerImageInspect: vi.fn(), + pushCommand: vi.fn(), }); }); @@ -30,7 +24,7 @@ describe("containers push", () => { beforeEach(() => { setIsTTY(false); setWranglerConfig({}); - vi.mocked(dockerImageInspect).mockResolvedValue("linux/amd64"); + vi.mocked(pushCommand).mockResolvedValue(undefined); }); it("should help", async ({ expect }) => { @@ -59,97 +53,36 @@ describe("containers push", () => { `); }); - it("should push image with valid platform", async ({ expect }) => { - await runWrangler("containers push test-app:tag"); - - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - }); - - it("should reject pushing image if platform is not linux/amd64", async ({ - expect, - }) => { - vi.mocked(dockerImageInspect).mockResolvedValue("linux/arm64"); - await expect(runWrangler("containers push test-app:tag")).rejects.toThrow( - "Unsupported platform" - ); - }); - - it("should tag image with the correct uri if given an : argument", async ({ - expect, - }) => { - await runWrangler("containers push test-app:tag"); - - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - }); - - it("should tag image with the correct uri if given an /: argument", async ({ + it("calls the shared push command with parsed args and account id", async ({ expect, }) => { await runWrangler("containers push test-namespace/app:tag"); - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `test-namespace/app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-namespace/app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-namespace/app:tag`, - ]); - }); - - it("should tag image with the correct uri if given an registry.cloudflare.com/: argument", async ({ - expect, - }) => { - await runWrangler("containers push registry.cloudflare.com/test-app:tag"); - - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `registry.cloudflare.com/test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); + expect(pushCommand).toHaveBeenCalledOnce(); + expect(pushCommand).toHaveBeenCalledWith( + expect.objectContaining({ + TAG: "test-namespace/app:tag", + pathToDocker: "docker", + }), + "some-account-id", + expect.any(Object) + ); }); - it("should tag image with the correct uri if given an registry.cloudflare.com/some-account-id/: argument", async ({ + it("passes a custom Docker path through to the shared command", async ({ expect, }) => { await runWrangler( - "containers push registry.cloudflare.com/some-account-id/test-app:tag" + "containers push test-app:tag --path-to-docker /custom/docker" ); - expect(runDockerCmd).toHaveBeenCalledTimes(2); - expect(runDockerCmd).toHaveBeenNthCalledWith(1, "docker", [ - "tag", - `registry.cloudflare.com/some-account-id/test-app:tag`, - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); - expect(runDockerCmd).toHaveBeenNthCalledWith(2, "docker", [ - "push", - `${getCloudflareContainerRegistry()}/some-account-id/test-app:tag`, - ]); + expect(pushCommand).toHaveBeenCalledWith( + expect.objectContaining({ + TAG: "test-app:tag", + pathToDocker: "/custom/docker", + }), + "some-account-id", + expect.any(Object) + ); }); }); diff --git a/packages/wrangler/src/__tests__/preview/containers.test.ts b/packages/wrangler/src/__tests__/preview/containers.test.ts index 29d7b6bfc44..7835a65a77d 100644 --- a/packages/wrangler/src/__tests__/preview/containers.test.ts +++ b/packages/wrangler/src/__tests__/preview/containers.test.ts @@ -1,31 +1,32 @@ -import { SchedulingPolicy } from "@cloudflare/containers-shared"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { + buildContainerImages, + SchedulingPolicy, + verifyDockerInstalled, +} from "@cloudflare/containers-shared"; import { defaultWranglerConfig } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { beforeEach, describe, test, vi } from "vitest"; -import { logger } from "../../logger"; -import { deployPreviewContainers } from "../../preview/containers"; -import { mockConsoleMethods } from "../helpers/mock-console"; +import { preparePreviewContainers } from "../../preview/containers"; import type { + BuiltContainerDeployment, ContainerNormalizedConfig, SharedContainerConfig, } from "@cloudflare/containers-shared"; -import type { DeploymentResource } from "@cloudflare/deploy-helpers"; import type { Config } from "@cloudflare/workers-utils"; vi.mock("../../cloudchamber/common", async (importOriginal) => ({ ...(await importOriginal()), fillOpenAPIConfiguration: vi.fn(), })); -vi.mock("../../containers/build", () => ({ buildContainer: vi.fn() })); -vi.mock("../../containers/deploy", () => ({ - apply: vi.fn(), - listDurableObjects: vi.fn(), +vi.mock("@cloudflare/containers-shared", async (importOriginal) => ({ + ...(await importOriginal()), + buildContainerImages: vi.fn(), + verifyDockerInstalled: vi.fn(), })); -const { buildContainer } = await import("../../containers/build"); -const { apply, listDurableObjects } = await import("../../containers/deploy"); - const PREVIEW_APP_NAME = "test-worker_my-feature_MyContainer"; -const ACCOUNT_ID = "some-account-id"; function containerConfig( overrides: Partial = {} @@ -46,182 +47,74 @@ function containerConfig( } as ContainerNormalizedConfig; } -const deployment = { - id: "deployment-id", - env: { - MY_CONTAINER: { - type: "durable_object_namespace", - class_name: "MyContainer", - namespace_id: "preview-do-ns-id", +function builtDeploymentFor( + container: ContainerNormalizedConfig +): BuiltContainerDeployment { + return { + container: container as BuiltContainerDeployment["container"], + builtImage: { + containerConfig: + container as BuiltContainerDeployment["builtImage"]["containerConfig"], + localTag: "preview:temp", }, - }, -} as unknown as DeploymentResource; - -describe("deployPreviewContainers", () => { - const std = mockConsoleMethods(); - - beforeEach(() => { - vi.mocked(buildContainer).mockReset(); - vi.mocked(apply).mockReset(); - vi.mocked(listDurableObjects).mockReset(); - vi.mocked(listDurableObjects).mockResolvedValue([]); - vi.mocked(buildContainer).mockResolvedValue({ newTag: "built:tag" }); - }); - - // Docker rejects uppercase characters in an image repository name, but the - // Cloudchamber application name must stay byte-identical to the name the - // control plane generates, which embeds the PascalCase DO class name. - test("should lowercase the image repository name while preserving the application name", async ({ - expect, - }) => { - const container = containerConfig(); - const config = { - ...defaultWranglerConfig, - containers: [container], - } as unknown as Config; - - await deployPreviewContainers(config, [container], deployment, ACCOUNT_ID, { - quiet: false, - }); - - expect(vi.mocked(buildContainer).mock.calls[0]?.[0]).toMatchObject({ - name: "test-worker_my-feature_mycontainer", - }); - expect(vi.mocked(apply).mock.calls[0]?.[1]).toMatchObject({ - name: PREVIEW_APP_NAME, - }); - }); - - test("should lowercase an uppercase preview slug in the image repository name", async ({ - expect, - }) => { - const container = containerConfig({ - name: "test-worker_Feature-MyBranch_MyContainer", - }); - const config = { - ...defaultWranglerConfig, - containers: [container], - } as unknown as Config; - - await deployPreviewContainers(config, [container], deployment, ACCOUNT_ID, { - quiet: false, - }); - - expect(vi.mocked(buildContainer).mock.calls[0]?.[0]).toMatchObject({ - name: "test-worker_feature-mybranch_mycontainer", - }); - }); + }; +} - // The image push target is derived from the compliance region, so an account - // in a restricted region must not fall back to the public registry. - test("should forward the compliance region to the image build", async ({ - expect, - }) => { - const container = containerConfig(); - const config = { - ...defaultWranglerConfig, - compliance_region: "fedramp_high", +function previewConfig(container: { + class_name?: string; + image: string; +}): Config { + return { + ...defaultWranglerConfig, + name: "test-worker", + configPath: path.resolve("wrangler.toml"), + topLevelName: "test-worker", + previews: { containers: [container], - } as unknown as Config; + durable_objects: { + bindings: [ + { + name: "MY_CONTAINER", + class_name: "MyContainer", + }, + ], + }, + }, + durable_objects: { + bindings: [], + }, + migrations: [], + } as unknown as Config; +} - await deployPreviewContainers(config, [container], deployment, ACCOUNT_ID, { - quiet: false, - }); +describe("preparePreviewContainers", () => { + runInTempDir(); - expect(vi.mocked(buildContainer).mock.calls[0]?.[5]).toMatchObject({ - compliance_region: "fedramp_high", - }); + beforeEach(() => { + vi.mocked(buildContainerImages).mockReset(); + vi.mocked(verifyDockerInstalled).mockReset(); + vi.mocked(buildContainerImages).mockResolvedValue([ + builtDeploymentFor(containerConfig()), + ]); }); - // A cross-script binding names a Durable Object owned by another Worker, so - // its namespace must never be selected for this preview's container. The - // class-name-keyed map would otherwise let it overwrite the local entry. - test("should ignore a cross-script Durable Object binding that shares a class name", async ({ + test("should preserve an uppercase preview slug in the application name", async ({ expect, }) => { - const container = containerConfig(); - const config = { - ...defaultWranglerConfig, - containers: [container], - } as unknown as Config; - const deploymentWithCrossScriptBinding = { - id: "deployment-id", - env: { - MY_CONTAINER: { - type: "durable_object_namespace", - class_name: "MyContainer", - namespace_id: "preview-do-ns-id", - }, - // Same class name, implemented by another Worker. Declared last so - // an unfiltered map would end up holding this namespace_id. - FOREIGN_CONTAINER: { - type: "durable_object_namespace", - class_name: "MyContainer", - namespace_id: "other-worker-do-ns-id", - script_name: "owner-worker", - }, - }, - } as unknown as DeploymentResource; + const dockerfile = path.resolve("Dockerfile"); + writeFileSync(dockerfile, "FROM scratch"); - await deployPreviewContainers( - config, - [container], - deploymentWithCrossScriptBinding, - ACCOUNT_ID, + await preparePreviewContainers( + previewConfig({ class_name: "MyContainer", image: dockerfile }), + "test-worker", + "Feature-MyBranch", { quiet: false } ); - expect(vi.mocked(apply).mock.calls[0]?.[0]).toMatchObject({ - durable_object_namespace_id: "preview-do-ns-id", - }); - }); - - test("should not build an image for a container configured with an image URI", async ({ - expect, - }) => { - const container = { - ...containerConfig(), - dockerfile: undefined, - image_build_context: undefined, - image_uri: "registry.cloudflare.com/some-account-id/test:latest", - } as unknown as ContainerNormalizedConfig; - delete (container as Record).dockerfile; - const config = { - ...defaultWranglerConfig, - containers: [container], - } as unknown as Config; - - await deployPreviewContainers(config, [container], deployment, ACCOUNT_ID, { - quiet: false, - }); - - expect(buildContainer).not.toHaveBeenCalled(); - expect(vi.mocked(apply).mock.calls[0]?.[0]).toMatchObject({ - imageRef: { - newTag: "registry.cloudflare.com/some-account-id/test:latest", - }, - }); - }); - - // `logger` drops a message above its level instead of redirecting it, so a - // level of `error` discards warnings rather than leaving them on stderr. - test("should keep warnings on stderr while suppressing stdout", async ({ - expect, - }) => { - const container = containerConfig(); - const config = { - ...defaultWranglerConfig, - containers: [container], - } as unknown as Config; - vi.mocked(apply).mockImplementation(async () => { - logger.warn("a container warning"); - }); - - await deployPreviewContainers(config, [container], deployment, ACCOUNT_ID, { - quiet: true, - }); - - expect(std.warn).toContain("a container warning"); - expect(std.out).toBe(""); + expect(vi.mocked(buildContainerImages).mock.calls[0]?.[0][0]).toMatchObject( + { + name: "test-worker_Feature-MyBranch_MyContainer", + } + ); }); }); diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 786868f430a..f5a5bfddee5 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -13,9 +13,7 @@ import { import { watch } from "chokidar"; import { getWorkerRegistry } from "miniflare"; import { getAssetsOptions, validateAssetsArgsAndConfig } from "../../assets"; -import { fillOpenAPIConfiguration } from "../../cloudchamber/common"; import { readConfig, readNewConfig } from "../../config"; -import { containersScope } from "../../containers"; import { getNormalizedContainerOptions } from "../../containers/config"; import { getEntry } from "../../deployment-bundle/entry"; import { validateNodeCompatMode } from "../../deployment-bundle/node-compat"; @@ -497,16 +495,6 @@ async function resolveConfig( ); } - // for pulling containers, we need to make sure the OpenAPI config for the - // container API client is properly set so that we can get the correct permissions - // from the cloudchamber API to pull from the repository. - const needsPulling = resolved.containers.some( - (c) => "image_uri" in c && c.image_uri - ); - if (needsPulling && !resolved.dev.remote) { - await fillOpenAPIConfiguration(config, containersScope); - } - // TODO(queues) support remote wrangler dev const queues = extractBindingsOfType("queue", resolved.bindings); if ( diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index deee1db8897..dd465073ef5 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { cleanupContainers, getDevContainerImageName, + initContainersSharedContext, prepareContainerImagesForDev, runDockerCmdWithOutput, } from "@cloudflare/containers-shared"; @@ -15,11 +16,12 @@ import { Miniflare, Mutex, } from "miniflare"; +import { fetchResult } from "../../cfetch"; import * as MF from "../../dev/miniflare"; import { logger } from "../../logger"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import { getBinaryFileContents } from "./utils"; +import { getBinaryFileContents, unwrapHook } from "./utils"; import type { CfAccount } from "../../dev/create-worker-preview"; import type { RemoteProxySession } from "../remoteBindings"; import type { @@ -234,6 +236,19 @@ export async function convertToConfigBundle( }; } +export async function getContainerImagePullAccountId( + config: StartDevWorkerOptions, + containerDevOptions: ContainerDevOptions[] +): Promise { + // If you have a registry URL specified, you don't need Docker, but pulling + // containers still needs the API client configured with an account so it can + // request the correct image pull permissions from Cloudchamber. + if (!containerDevOptions.some((container) => "image_uri" in container)) { + return undefined; + } + return (await unwrapHook(config.dev.auth))?.accountId; +} + export class LocalRuntimeController extends RuntimeController { #log = MF.buildLog(); #currentBundleId = 0; @@ -373,6 +388,7 @@ export class LocalRuntimeController extends RuntimeController { this.containerImageTagsSeen.add(container.image_tag); } logger.log(chalk.dim("⎔ Preparing container image(s)...")); + initContainersSharedContext({ logger, fetchResult }); await prepareContainerImagesForDev({ dockerPath: this.dockerPath, containerOptions: containerDevOptions, @@ -386,6 +402,10 @@ export class LocalRuntimeController extends RuntimeController { this.containerBeingBuilt = undefined; }, logger: logger, + accountId: await getContainerImagePullAccountId( + data.config, + containerDevOptions + ), complianceConfig: { compliance_region: data.config.complianceRegion, }, diff --git a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts index d88dc15a5dd..b8b93f6035f 100644 --- a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts @@ -1,14 +1,19 @@ import assert from "node:assert"; import { randomUUID } from "node:crypto"; -import { prepareContainerImagesForDev } from "@cloudflare/containers-shared"; +import { + initContainersSharedContext, + prepareContainerImagesForDev, +} from "@cloudflare/containers-shared"; import { getDockerPath } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { convertV4MiniflareOptions, Miniflare, Mutex } from "miniflare"; +import { fetchResult } from "../../cfetch"; import * as MF from "../../dev/miniflare"; import { logger } from "../../logger"; import { castErrorCause } from "./events"; import { convertToConfigBundle, + getContainerImagePullAccountId, getContainerDevOptions, getUserWorkerInnerUrlOverrides, LocalRuntimeController, @@ -191,6 +196,7 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { for (const container of containerOptions ?? []) { this.containerImageTagsSeen.add(container.image_tag); } + initContainersSharedContext({ logger, fetchResult }); await prepareContainerImagesForDev({ dockerPath: this.dockerPath, containerOptions, @@ -204,6 +210,10 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { this.containerBeingBuilt = undefined; }, logger: logger, + accountId: await getContainerImagePullAccountId( + data.config, + containerOptions + ), complianceConfig: { compliance_region: data.config.complianceRegion, }, diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts index 8d56276c844..8ee6b0aa707 100644 --- a/packages/wrangler/src/cloudchamber/apply.ts +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -1,7 +1,3 @@ -/** - * Important! You are probably looking for containers/deploy.ts! - * This is used for cloudchamber apply, but has been duplicated and modified in containers/deploy.ts to deploy containers during wrangler deploy. - */ import { endSection, log, @@ -23,24 +19,24 @@ import { ApplicationsService, CreateApplicationRolloutRequest, DeploymentMutationError, + Diff, InstanceType, + configRolloutStepsToAPI, resolveImageName, RolloutsService, SchedulingPolicy, } from "@cloudflare/containers-shared"; +import { + sortObjectRecursive, + stripUndefined, +} from "@cloudflare/containers-shared"; import { FatalError, formatConfigSnippet, UserError, } from "@cloudflare/workers-utils"; -import { configRolloutStepsToAPI } from "../containers/deploy"; import { createCommand } from "../core/create-command"; import { getOrSelectAccountId } from "../user"; -import { Diff } from "../utils/diff"; -import { - sortObjectRecursive, - stripUndefined, -} from "../utils/sortObjectRecursive"; import { cloudchamberScope, fillOpenAPIConfiguration, diff --git a/packages/wrangler/src/cloudchamber/build.ts b/packages/wrangler/src/cloudchamber/build.ts index 8727f2a2e4d..d103d5cd12e 100644 --- a/packages/wrangler/src/cloudchamber/build.ts +++ b/packages/wrangler/src/cloudchamber/build.ts @@ -1,37 +1,17 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; import { - constructBuildCommand, - dockerBuild, - dockerImageInspect, - dockerLoginImageRegistry, - getCloudflareContainerRegistry, - resolveImageName, - runDockerCmd, - runDockerCmdWithOutput, + buildCommand, + initContainersSharedContext, + pushCommand, } from "@cloudflare/containers-shared"; -import { - getCIOverrideNetworkModeHost, - getDockerPath, - isDirectory, - UserError, -} from "@cloudflare/workers-utils"; +import { fetchResult } from "../cfetch"; import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getOrSelectAccountId } from "../user"; import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; -import { ensureContainerLimits } from "./limits"; -import { loadAccount } from "./locations"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "../yargs-types"; -import type { - BuildArgs, - ContainerNormalizedConfig, - ImageURIConfig, -} from "@cloudflare/containers-shared"; -import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; export function buildYargs(yargs: CommonYargsArgv) { return yargs @@ -80,382 +60,6 @@ export function pushYargs(yargs: CommonYargsArgv) { .positional("TAG", { type: "string", demandOption: true }); } -/** - * - * `{ remoteDigest: string }` implies the image was pushed to, or already exists in, - * the managed registry. Deployments should use this digest-pinned reference. - * - * `{ newTag: string }` implies the image was built locally without pushing. - */ -export type ImageRef = { remoteDigest: string } | { newTag: string }; - -// Based on the Docker reference grammar used by containers/image. These only -// strip suffixes from refs that have already been normalized by resolveImageName(). -const DIGEST_SUFFIX_REGEXP = - /@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[a-fA-F0-9]{32,}$/; -const DIGEST_VALUE_REGEXP = - /^[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[a-fA-F0-9]{32,}$/; -const TAG_SUFFIX_REGEXP = /:[\w][\w.-]{0,127}$/; - -function getRepositoryOnly( - externalAccountId: string, - imageTag: string, - complianceConfig?: ComplianceConfig -): string { - return resolveImageName(externalAccountId, imageTag, complianceConfig) - .replace(DIGEST_SUFFIX_REGEXP, "") - .replace(TAG_SUFFIX_REGEXP, ""); -} - -function imageRefWithDigest( - externalAccountId: string, - imageTag: string, - digest: string, - complianceConfig?: ComplianceConfig -): string { - if (!DIGEST_VALUE_REGEXP.test(digest)) { - throw new Error( - `Expected image digest to match algorithm:hex format, got ${digest}` - ); - } - return `${getRepositoryOnly(externalAccountId, imageTag, complianceConfig)}@${digest}`; -} - -function findManifestDigest(manifestOutput: string): string { - const parsedManifest = JSON.parse(manifestOutput); - const digest = parsedManifest?.Descriptor?.digest; - if (typeof digest !== "string" || digest.length === 0) { - throw new Error( - `Expected docker manifest inspect output to include Descriptor.digest, got ${manifestOutput}` - ); - } - return digest; -} - -function findRemoteDigest( - repoDigestsJson: string, - externalAccountId: string, - imageTag: string, - complianceConfig?: ComplianceConfig -): string { - const parsedDigests = JSON.parse(repoDigestsJson); - if (!Array.isArray(parsedDigests)) { - throw new Error( - `Expected RepoDigests from docker inspect to be an array but got ${JSON.stringify(parsedDigests)}` - ); - } - - const repositoryOnly = getRepositoryOnly( - externalAccountId, - imageTag, - complianceConfig - ); - logger.debug("respositoryOnly:", repositoryOnly); - - // make sure the repository + name provided in wrangler config - // matches the repository + name from the digests - const digest = parsedDigests.find((d): d is string => { - if (typeof d !== "string" || !d.includes("@")) { - return false; - } - const resolved = resolveImageName(externalAccountId, d, complianceConfig); - logger.debug(`Comparing ${resolved.split("@")[0]} to ${repositoryOnly}`); - return typeof d === "string" && resolved.split("@")[0] === repositoryOnly; - }); - if (!digest) { - throw new Error( - `Could not find a digest for the image ${repositoryOnly}. Found digests: ${parsedDigests.join(", ")}` - ); - } - - const [, hash] = digest.split("@"); - return imageRefWithDigest( - externalAccountId, - imageTag, - hash, - complianceConfig - ); -} - -/** - * Builds a Docker image and optionally pushes it to the Cloudflare managed registry. - * - * @param args - Build arguments including tag, Dockerfile path, build context, and platform. - * @param pathToDocker - Path to the Docker CLI executable. - * @param push - Whether to push the built image to the remote registry. - * @param containerConfig - Optional container configuration for limit validation. - * @param verifyDockerIsRunning - When `true` (the default), verifies Docker is installed and the - * daemon is running before building. Set to `false` when the caller has already performed this check. - * @param complianceConfig - Compliance configuration used to select the managed registry. - * - * @returns An {@link ImageRef} describing the built/pushed image. - */ -export async function buildAndMaybePush( - args: BuildArgs, - pathToDocker: string, - push: boolean, - containerConfig?: Exclude, - verifyDockerIsRunning?: boolean, - complianceConfig?: ComplianceConfig -): Promise { - try { - const imageTag = args.tag; - const { buildCmd, dockerfile } = await constructBuildCommand( - { - tag: imageTag, - pathToDockerfile: args.pathToDockerfile, - buildContext: args.buildContext, - args: args.args, - platform: args.platform, - setNetworkToHost: Boolean(getCIOverrideNetworkModeHost()), - }, - logger - ); - - const build = await dockerBuild(pathToDocker, { - buildCmd, - dockerfile, - verifyDockerIsRunning, - }); - await build.ready; - - if (push) { - /** - * Get `RepoDigests` and `Id`: - * A Docker image digest (RepoDigest) is a unique, cryptographic identifier (SHA-256 hash) - * representing the content of a Docker image. Unlike tags, which can be reused or changed, a digest is immutable and ensures that the exact same image is - * pulled every time. This guarantees consistency across different environments - * and deployments. Crucially this is *not* affected by metadata changes (dockerfile only changes). - * From: https://docs.docker.com/dhi/core-concepts/digests/ - * The image Id is a sha hash of the image's configuration, so it *does* capture metadata changes. - * We need both to know when to push the image to the managed registry. - */ - const imageInfo = await dockerImageInspect(pathToDocker, { - imageTag, - formatString: "{{ json .RepoDigests }}", - }); - logger.debug(`'docker image inspect ${imageTag}':`, imageInfo); - - const account = await loadAccount(); - - await ensureContainerLimits({ - pathToDocker, - imageTag, - account, - containerConfig, - }); - - await dockerLoginImageRegistry( - pathToDocker, - // Won't be an external registry since this is building from a Dockerfile - // rather than specifying an image uri. - getCloudflareContainerRegistry(complianceConfig) - ); - try { - // We don't try to parse until this point because we don't want to fail on - // parse errors if we won't be pushing the image anyways. - const remoteDigest = findRemoteDigest( - imageInfo, - account.external_account_id, - imageTag, - complianceConfig - ); - const [, hash] = remoteDigest.split("@"); - - // NOTE: this is an experimental docker command so the API may change - // and break this flow. Hopefully not! - // http://docs.docker.com/reference/cli/docker/manifest/inspect/ - // Checks if this image already exists in the managed registry - - // if this succeeds it means this image already exists remotely. - // If this errors, it probably doesn't exist and we should push, - // which we will do in the catch block. - logger.debug( - `'docker manifest inspect -v ${resolveImageName(account.external_account_id, remoteDigest, complianceConfig)}:` - ); - const remoteManifest = runDockerCmdWithOutput(pathToDocker, [ - "manifest", - "inspect", - "-v", - resolveImageName( - account.external_account_id, - remoteDigest, - complianceConfig - ), - ]); - const parsedRemoteManifest = JSON.parse(remoteManifest); - - if (parsedRemoteManifest.Descriptor.digest === hash) { - logger.log("Image already exists remotely, skipping push"); - logger.debug( - `Untagging built image: ${args.tag} since there was no change.` - ); - - await runDockerCmd(pathToDocker, ["image", "rm", imageTag]); - - return { remoteDigest }; - } - } catch (error) { - if (error instanceof Error) { - logger.debug( - `Checking for local image ${args.tag} failed with error: ${error.message}` - ); - } - } - // Re-tag the image to include the account ID - const namespacedImageTag = resolveImageName( - account.external_account_id, - args.tag, - complianceConfig - ); - logger.log( - `Image does not exist remotely, pushing: ${namespacedImageTag}` - ); - await runDockerCmd(pathToDocker, ["tag", imageTag, namespacedImageTag]); - await runDockerCmd(pathToDocker, ["push", namespacedImageTag]); - - let remoteDigest: string; - try { - const pushedImageInfo = await dockerImageInspect(pathToDocker, { - imageTag: namespacedImageTag, - formatString: "{{ json .RepoDigests }}", - }); - remoteDigest = findRemoteDigest( - pushedImageInfo, - account.external_account_id, - namespacedImageTag, - complianceConfig - ); - } catch (error) { - if (error instanceof Error) { - logger.debug( - `Inspecting pushed image ${namespacedImageTag} failed with error: ${error.message}` - ); - } - const remoteManifest = runDockerCmdWithOutput(pathToDocker, [ - "manifest", - "inspect", - "-v", - namespacedImageTag, - ]); - remoteDigest = imageRefWithDigest( - account.external_account_id, - namespacedImageTag, - findManifestDigest(remoteManifest), - complianceConfig - ); - } - - return { remoteDigest }; - } - - return { newTag: imageTag }; - } catch (error) { - if (error instanceof Error) { - throw new UserError(error.message, { - cause: error, - telemetryMessage: "cloudchamber build image operation failed", - }); - } - throw new UserError("An unknown error occurred", { - telemetryMessage: "cloudchamber build unknown error", - }); - } -} - -/** - * Builds an image from the Cloudchamber command arguments and optionally pushes it. - * - * @param args - Parsed Cloudchamber build command arguments. - * @param complianceConfig - Compliance configuration used to select the managed registry. - * @returns A promise that resolves when the build and optional push complete. - */ -export async function buildCommand( - args: StrictYargsOptionsToInterface, - complianceConfig?: ComplianceConfig -) { - // TODO: merge args with Wrangler config if available - if (existsSync(args.PATH) && !isDirectory(args.PATH)) { - throw new UserError( - `${args.PATH} is not a directory. Please specify a valid directory path.`, - { telemetryMessage: "cloudchamber build invalid path" } - ); - } - if (args.platform !== "linux/amd64") { - throw new UserError( - `Unsupported platform: Platform "${args.platform}" is unsupported. Please use "linux/amd64" instead.`, - { telemetryMessage: "cloudchamber build unsupported platform" } - ); - } - - const pathToDockerfile = join(args.PATH, "Dockerfile"); - - await buildAndMaybePush( - { - tag: args.tag, - pathToDockerfile, - buildContext: args.PATH, - platform: args.platform, - // no option to add env vars at build time...? - }, - getDockerPath() ?? args.pathToDocker, - args.push, - // this means we aren't validating defined limits for a container when building an image - // we will, however, still validate the image size against account level disk limits - undefined, - undefined, - complianceConfig - ); -} - -export async function pushCommand( - args: StrictYargsOptionsToInterface, - config: Config -) { - try { - await dockerLoginImageRegistry( - args.pathToDocker, - getCloudflareContainerRegistry(config) - ); - - const accountId = await getOrSelectAccountId(config); - const newTag = resolveImageName(accountId, args.TAG, config); - const dockerPath = args.pathToDocker ?? getDockerPath(); - await checkImagePlatform(dockerPath, args.TAG); - await runDockerCmd(dockerPath, ["tag", args.TAG, newTag]); - await runDockerCmd(dockerPath, ["push", newTag]); - logger.log(`Pushed image: ${newTag}`); - } catch (error) { - if (error instanceof Error) { - throw new UserError(error.message, { - telemetryMessage: "cloudchamber push failed", - }); - } - - throw new UserError("An unknown error occurred", { - telemetryMessage: "cloudchamber push unknown error", - }); - } -} - -async function checkImagePlatform( - pathToDocker: string, - imageTag: string, - expectedPlatform: string = "linux/amd64" -) { - const platform = await dockerImageInspect(pathToDocker, { - imageTag, - formatString: "{{ .Os }}/{{ .Architecture }}", - }); - - if (platform !== expectedPlatform) { - throw new Error( - `Unsupported platform: Image platform (${platform}) does not match the expected platform (${expectedPlatform})` - ); - } -} - -// --- New createCommand-based commands --- - export const cloudchamberBuildCommand = createCommand({ metadata: { description: "Build a container image", @@ -499,8 +103,12 @@ export const cloudchamberBuildCommand = createCommand({ }, positionalArgs: ["PATH"], async handler(args, { config }) { + initContainersSharedContext({ logger, fetchResult }); await fillOpenAPIConfiguration(config, cloudchamberScope); - await buildCommand(args, config); + await buildCommand( + args as StrictYargsOptionsToInterface, + config + ); }, }); @@ -526,7 +134,12 @@ export const cloudchamberPushCommand = createCommand({ }, positionalArgs: ["TAG"], async handler(args, { config }) { + initContainersSharedContext({ logger, fetchResult }); await fillOpenAPIConfiguration(config, cloudchamberScope); - await pushCommand(args, config); + await pushCommand( + args as StrictYargsOptionsToInterface, + await getOrSelectAccountId(config), + config + ); }, }); diff --git a/packages/wrangler/src/cloudchamber/images/images.ts b/packages/wrangler/src/cloudchamber/images/images.ts index a182c688edf..b6d6864c2fc 100644 --- a/packages/wrangler/src/cloudchamber/images/images.ts +++ b/packages/wrangler/src/cloudchamber/images/images.ts @@ -264,6 +264,11 @@ async function deleteTag( return digest; } +/** + * Configures the Containers API client used to retrieve image pull credentials. + * + * @param complianceConfig - Compliance configuration used to select the API endpoint. + */ async function getCreds(complianceConfig?: ComplianceConfig): Promise { const credentials = await ImageRegistriesService.generateImageRegistryCredentials( diff --git a/packages/wrangler/src/cloudchamber/instance-type/instance-type.ts b/packages/wrangler/src/cloudchamber/instance-type/instance-type.ts index 2e34eb1a199..ba861fe7571 100644 --- a/packages/wrangler/src/cloudchamber/instance-type/instance-type.ts +++ b/packages/wrangler/src/cloudchamber/instance-type/instance-type.ts @@ -1,85 +1,71 @@ import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; -import { InstanceType } from "@cloudflare/containers-shared"; -import { UserError } from "@cloudflare/workers-utils"; -import type { - CreateApplicationRequest, - UserDeploymentConfiguration, +import { + cleanForInstanceType as cleanForInstanceTypeFromShared, + getInstanceTypeUsage as getInstanceTypeUsageFromShared, + inferInstanceType as inferInstanceTypeFromShared, + InstanceType, } from "@cloudflare/containers-shared"; -import type { - CloudchamberConfig, - ContainerApp, -} from "@cloudflare/workers-utils"; +import { UserError } from "@cloudflare/workers-utils"; +import type { CloudchamberConfig } from "@cloudflare/workers-utils"; -const instanceTypes = { - // lite is the default instance type when REQUIRE_INSTANCE_TYPE is set - lite: { - vcpu: 0.0625, - memory_mib: 256, - disk_mb: 2000, - }, - dev: { - vcpu: 0.0625, - memory_mib: 256, - disk_mb: 2000, - }, - basic: { - vcpu: 0.25, - memory_mib: 1024, - disk_mb: 4000, - }, - standard: { - vcpu: 0.5, - memory_mib: 4096, - disk_mb: 8000, - }, - "standard-1": { - vcpu: 0.5, - memory_mib: 4096, - disk_mb: 8000, - }, - "standard-2": { - vcpu: 1, - memory_mib: 6144, - disk_mb: 12000, - }, - "standard-3": { - vcpu: 2, - memory_mib: 8192, - disk_mb: 16000, - }, - "standard-4": { - vcpu: 4, - memory_mib: 12_288, - disk_mb: 20000, - }, -} as const; +export { + cleanForInstanceTypeFromShared as cleanForInstanceType, + getInstanceTypeUsageFromShared as getInstanceTypeUsage, + inferInstanceTypeFromShared as inferInstanceType, +}; + +const instanceTypeNames: string[] = Object.values(InstanceType); +const promptInstanceTypes = [ + InstanceType.LITE, + InstanceType.BASIC, + InstanceType.STANDARD_1, + InstanceType.STANDARD_2, + InstanceType.STANDARD_3, + InstanceType.STANDARD_4, +] as const; + +type InstanceTypeOption = { + label: string; + value: string; +}; + +function formatVcpu(vcpu: number): string { + if (vcpu === 0.0625) { + return "1/16"; + } + if (vcpu === 0.25) { + return "1/4"; + } + if (vcpu === 0.5) { + return "1/2"; + } + return `${vcpu}`; +} + +function formatMemory(memoryMib: number): string { + if (memoryMib < 1024) { + return `${memoryMib} MiB`; + } + return `${memoryMib / 1024} GiB`; +} -const instanceTypeNames = Object.keys(instanceTypes); +function formatDisk(diskMb: number): string { + return `${diskMb / 1000} GB`; +} // prompts for instance type export async function promptForInstanceType( allowSkipping: boolean ): Promise { - let options = [ - { label: "lite: 1/16 vCPU, 256 MiB memory, 2 GB disk", value: "lite" }, - { label: "basic: 1/4 vCPU, 1 GiB memory, 4 GB disk", value: "basic" }, - { - label: "standard-1: 1/2 vCPU, 4 GiB memory, 8 GB disk", - value: "standard-1", - }, - { - label: "standard-2: 1/2 vCPU, 4 GiB memory, 12 GB disk", - value: "standard-2", - }, - { - label: "standard-3: 1/2 vCPU, 4 GiB memory, 16 GB disk", - value: "standard-3", - }, - { - label: "standard-4: 4 vCPU, 4 GiB memory, 20 GB disk", - value: "standard-4", - }, - ]; + let options: InstanceTypeOption[] = promptInstanceTypes.map( + (instanceType) => { + const usage = getInstanceTypeUsageFromShared(instanceType); + return { + label: `${instanceType}: ${formatVcpu(usage.vcpu)} vCPU, ${formatMemory(usage.memory_mib)} memory, ${formatDisk(usage.disk_mb)} disk`, + value: instanceType, + }; + } + ); if (allowSkipping) { options = [{ label: "Do not set", value: "skip" }].concat(options); } @@ -134,67 +120,3 @@ export function checkInstanceType( ); } } - -// get the usage for the provided instance type -export function getInstanceTypeUsage(instanceType: InstanceType): { - vcpu: number; - memory_mib: number; - disk_mb: number; -} { - return instanceTypes[instanceType]; -} - -// Legacy alias → canonical name mapping. -// The API may return the legacy alias (e.g. "standard") for an instance -// type configured as the canonical name ("standard-1"). Normalizing ensures -// the deploy diff doesn't show a phantom EDIT for instance_type. -const LEGACY_TO_CANONICAL: Record<"dev" | "standard", InstanceType> = { - dev: InstanceType.LITE, - standard: InstanceType.STANDARD_1, -}; - -// infers the instance type from a given configuration -export function inferInstanceType( - config: UserDeploymentConfiguration -): InstanceType | undefined { - for (const [instanceType, configuration] of Object.entries(instanceTypes)) { - if ( - config.vcpu === configuration.vcpu && - config.memory_mib === configuration.memory_mib && - config.disk?.size_mb === configuration.disk_mb - ) { - const canonical = - instanceType in LEGACY_TO_CANONICAL - ? LEGACY_TO_CANONICAL[ - instanceType as keyof typeof LEGACY_TO_CANONICAL - ] - : undefined; - return (canonical ?? instanceType) as InstanceType; - } - } -} - -/** - * THIS IS ONLY USED FOR CLOUDCHAMBER APPLY - * removes any disk, memory, or vcpu that have been set in an objects configuration. Used for rendering diffs. - */ -export function cleanForInstanceType( - app: CreateApplicationRequest -): ContainerApp { - if (!("configuration" in app)) { - return app as ContainerApp; - } - - const instance_type = inferInstanceType(app.configuration); - if (instance_type !== undefined) { - app.configuration.instance_type = instance_type; - } - - delete app.configuration.disk; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally cleaning up deprecated `memory` field - delete app.configuration.memory; - delete app.configuration.memory_mib; - delete app.configuration.vcpu; - - return app as ContainerApp; -} diff --git a/packages/wrangler/src/containers/build.ts b/packages/wrangler/src/containers/build.ts index d3d71be8036..9a62cb33736 100644 --- a/packages/wrangler/src/containers/build.ts +++ b/packages/wrangler/src/containers/build.ts @@ -1,18 +1,14 @@ import { - buildAndMaybePush, buildCommand, + initContainersSharedContext, pushCommand, -} from "../cloudchamber/build"; +} from "@cloudflare/containers-shared"; +import { fetchResult } from "../cfetch"; import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { getOrSelectAccountId } from "../user"; import { containersScope } from "."; -import type { ImageRef } from "../cloudchamber/build"; -import type { - ContainerNormalizedConfig, - ImageURIConfig, -} from "@cloudflare/containers-shared"; -import type { ComplianceConfig } from "@cloudflare/workers-utils"; // --- Command definitions --- @@ -58,6 +54,7 @@ export const containersBuildCommand = createCommand({ }, positionalArgs: ["PATH"], async handler(args, { config }) { + initContainersSharedContext({ logger, fetchResult }); await fillOpenAPIConfiguration(config, containersScope); await buildCommand(args, config); }, @@ -84,46 +81,8 @@ export const containersPushCommand = createCommand({ }, positionalArgs: ["TAG"], async handler(args, { config }) { + initContainersSharedContext({ logger, fetchResult }); await fillOpenAPIConfiguration(config, containersScope); - await pushCommand(args, config); + await pushCommand(args, await getOrSelectAccountId(config), config); }, }); - -// --- Helper functions --- - -/** - * Builds a configured container image and optionally pushes it to the managed registry. - * - * @param containerConfig - Normalized Dockerfile-based container configuration. - * @param imageTag - Tag component that will be prefixed with the container name. - * @param dryRun - Whether to build without pushing the image. - * @param pathToDocker - Path to the Docker CLI executable. - * @param verifyDockerIsRunning - Whether to verify Docker before building. - * @param complianceConfig - Compliance configuration used to select the managed registry. - * @returns An {@link ImageRef} describing the built or pushed image. - */ -export async function buildContainer( - containerConfig: Exclude, - imageTag: string, - dryRun: boolean, - pathToDocker: string, - verifyDockerIsRunning?: boolean, - complianceConfig?: ComplianceConfig -): Promise { - const imageFullName = containerConfig.name + ":" + imageTag.split("-")[0]; - logger.log("Building image", imageFullName); - - return await buildAndMaybePush( - { - tag: imageFullName, - pathToDockerfile: containerConfig.dockerfile, - buildContext: containerConfig.image_build_context, - args: containerConfig.image_vars, - }, - pathToDocker, - !dryRun, - containerConfig, - verifyDockerIsRunning, - complianceConfig - ); -} diff --git a/packages/wrangler/src/containers/images.ts b/packages/wrangler/src/containers/images.ts index 689b286d484..ad5c454e4d8 100644 --- a/packages/wrangler/src/containers/images.ts +++ b/packages/wrangler/src/containers/images.ts @@ -2,13 +2,11 @@ import { cancel } from "@cloudflare/cli-shared-helpers"; import { getCloudflareContainerRegistry, ImageRegistriesService, + promiseSpinner, } from "@cloudflare/containers-shared"; import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; import { fetch } from "undici"; -import { - fillOpenAPIConfiguration, - promiseSpinner, -} from "../cloudchamber/common"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { createCommand, createNamespace } from "../core/create-command"; import { confirm } from "../dialogs"; import { logger } from "../logger"; @@ -284,6 +282,11 @@ async function deleteTag( return digest; } +/** + * Configures the Containers API client used to retrieve image pull credentials. + * + * @param complianceConfig - Compliance configuration used to select the API endpoint. + */ async function getCreds(complianceConfig?: ComplianceConfig): Promise { const credentials = await ImageRegistriesService.generateImageRegistryCredentials( diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 6786d8a0f20..8044281b2cf 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -11,8 +11,10 @@ import { ExternalRegistryKind, getAndValidateRegistryType, getCloudflareContainerRegistry, + formatError, validateAndEncodeGarKey, ImageRegistriesService, + promiseSpinner, } from "@cloudflare/containers-shared"; import { APIError, @@ -21,10 +23,7 @@ import { UserError, } from "@cloudflare/workers-utils"; import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; -import { - fillOpenAPIConfiguration, - promiseSpinner, -} from "../cloudchamber/common"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { createCommand, createNamespace } from "../core/create-command"; import { confirm, prompt } from "../dialogs"; import { logger } from "../logger"; @@ -38,7 +37,6 @@ import { import { validateSecretName } from "../secrets-store/commands"; import { getOrSelectAccountId } from "../user"; import { readFromStdin, trimTrailingWhitespace } from "../utils/std"; -import { formatError } from "./deploy"; import { containersScope } from "."; import type { HandlerArgs, NamedArgDefinitions } from "../core/types"; import type { diff --git a/packages/wrangler/src/containers/ssh.ts b/packages/wrangler/src/containers/ssh.ts index 3af4327e454..d920ac6ca14 100644 --- a/packages/wrangler/src/containers/ssh.ts +++ b/packages/wrangler/src/containers/ssh.ts @@ -2,12 +2,13 @@ import { spawn } from "node:child_process"; import { createServer } from "node:net"; import { showCursor } from "@cloudflare/cli-shared-helpers"; import { bold } from "@cloudflare/cli-shared-helpers/colors"; -import { ApiError, DeploymentsService } from "@cloudflare/containers-shared"; -import { WebSocket } from "ws"; import { - fillOpenAPIConfiguration, + ApiError, + DeploymentsService, promiseSpinner, -} from "../cloudchamber/common"; +} from "@cloudflare/containers-shared"; +import { WebSocket } from "ws"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { containersScope } from "./index"; diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index bd01e55d38e..433ae45e58c 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -1,13 +1,15 @@ +import { initContainersSharedContext } from "@cloudflare/containers-shared"; import { deploy } from "@cloudflare/deploy-helpers"; import { getWorkerNameFromProject, isNonInteractiveOrCI, } from "@cloudflare/workers-utils"; +import { fetchResult } from "../cfetch"; import { analyseBundle } from "../check/commands"; -import { buildContainer } from "../containers/build"; -import { getNormalizedContainerOptions } from "../containers/config"; -import { deployContainers } from "../containers/deploy"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; +import { containersScope } from "../containers"; import { createCommand } from "../core/create-command"; +import { buildDeployContainerImages } from "../deployment-bundle/build-container-images"; import { sharedDeployVersionsArgs, validateDeployVersionsArgs, @@ -194,15 +196,25 @@ export async function runDeployCommandHandler( const buildResult = await buildWorker(buildProps, config); + initContainersSharedContext({ + logger, + fetchResult, + }); + props.builtContainerDeployments = await buildDeployContainerImages(props); + if ( + !props.dryRun && + props.containersRollout !== "none" && + props.normalisedContainerConfig.length > 0 + ) { + await fillOpenAPIConfiguration(config, containersScope); + } + const { sourceMapSize, assetUploadStats } = await deploy( props, config, buildResult, { syncWorkersSite, - getNormalizedContainerOptions, - buildContainer, - deployContainers, analyseBundle, } ); diff --git a/packages/wrangler/src/deployment-bundle/build-container-images.ts b/packages/wrangler/src/deployment-bundle/build-container-images.ts new file mode 100644 index 00000000000..9d3061cfea5 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/build-container-images.ts @@ -0,0 +1,39 @@ +import { + buildContainerImages, + isDockerfileContainerConfig, + verifyDockerInstalled, +} from "@cloudflare/containers-shared"; +import { getDockerPath } from "@cloudflare/workers-utils"; +import type { BuiltContainerDeployment } from "@cloudflare/containers-shared"; +import type { DeployProps } from "@cloudflare/deploy-helpers"; + +export async function buildDeployContainerImages( + props: DeployProps +): Promise { + if ( + props.containersRollout === "none" || + props.normalisedContainerConfig.length === 0 + ) { + return []; + } + + const containersWithDockerfile = props.normalisedContainerConfig.filter( + isDockerfileContainerConfig + ); + if (containersWithDockerfile.length === 0) { + return []; + } + + const dockerPath = getDockerPath(); + await verifyDockerInstalled({ + dockerPath, + operation: `deploying${props.dryRun ? " (even in dry-run mode)" : ""}`, + imageNoun: + containersWithDockerfile.length !== 1 + ? "the configured images" + : "the configured image", + hint: "If you cannot run Docker locally, you can still deploy your Worker by passing --containers-rollout=none. This will not deploy or update your Container.", + }); + + return buildContainerImages(containersWithDockerfile, dockerPath, false); +} diff --git a/packages/wrangler/src/deployment-bundle/merge-config-args.ts b/packages/wrangler/src/deployment-bundle/merge-config-args.ts index 00d895f7b95..f469fc60694 100644 --- a/packages/wrangler/src/deployment-bundle/merge-config-args.ts +++ b/packages/wrangler/src/deployment-bundle/merge-config-args.ts @@ -11,6 +11,7 @@ import { validateAssetsArgsAndConfig, validateAssetsOptions, } from "../assets"; +import { getNormalizedContainerOptions } from "../containers/config"; import { getFlag } from "../experimental-flags"; import { logger } from "../logger"; import { getMetricsUsageHeaders } from "../metrics"; @@ -137,6 +138,13 @@ export async function mergeDeployConfigArgs( })); const routes = args.routes ?? config.routes ?? (config.route ? [config.route] : []); + const normalisedContainerConfig = await getNormalizedContainerOptions( + config, + { + containersRollout: args.containersRollout, + dryRun: shared.dryRun, + } + ); return { props: { @@ -154,6 +162,8 @@ export async function mergeDeployConfigArgs( dispatchNamespace: args.dispatchNamespace, oldAssetTtl: args.oldAssetTtl, containersRollout: args.containersRollout, + normalisedContainerConfig, + builtContainerDeployments: [], }, buildProps: { ...buildProps, metafile: args.metafile }, }; diff --git a/packages/wrangler/src/preview/containers.ts b/packages/wrangler/src/preview/containers.ts index 3ba1ed4f754..19bee5c0094 100644 --- a/packages/wrangler/src/preview/containers.ts +++ b/packages/wrangler/src/preview/containers.ts @@ -1,14 +1,33 @@ import { getLogLevel, setLogLevel } from "@cloudflare/cli-shared-helpers"; -import { getDockerPath, UserError } from "@cloudflare/workers-utils"; +import { + buildContainerImages, + initContainersSharedContext, + verifyDockerInstalled, +} from "@cloudflare/containers-shared"; +import { + getPreviewOwnedContainerClassNames, + previewContainerAppName, +} from "@cloudflare/deploy-helpers"; +import { + configFileName, + getDockerPath, + UserError, +} from "@cloudflare/workers-utils"; +import { fetchResult } from "../cfetch"; import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { containersScope } from "../containers"; -import { buildContainer } from "../containers/build"; -import { apply, listDurableObjects } from "../containers/deploy"; -import { runWithLogLevel } from "../logger"; -import type { DurableObjectNamespace } from "../containers/deploy"; -import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; -import type { DeploymentResource } from "@cloudflare/deploy-helpers"; -import type { Config } from "@cloudflare/workers-utils"; +import { getNormalizedContainerOptions } from "../containers/config"; +import { logger, runWithLogLevel } from "../logger"; +import type { + BuiltContainerDeployment, + ContainerNormalizedConfig, +} from "@cloudflare/containers-shared"; +import type { PreviewContainerPreparation } from "@cloudflare/deploy-helpers"; +import type { + Config, + ContainerApp, + PreviewsConfig, +} from "@cloudflare/workers-utils"; /** * Confirm the API token carries the `containers` scope. `applyPreviewContainers` @@ -22,149 +41,210 @@ export async function verifyContainersScope( } /** - * Build and apply the container applications validated by - * `@cloudflare/deploy-helpers`'s `preview()` (via `getNormalizedContainerOptions`). - * For each normalised container, register or update a Cloudchamber - * application bound to the DO namespace_id resolved by the preview - * deployment API. + * Validate and normalise container config, and confirm Docker is installed for + * any container built from a Dockerfile. Called before the preview deployment is + * created, so a bad config or a missing Docker install fails before the preview + * goes live, rather than leaving a preview running that advertises containers + * nothing ever built. * - * The DO namespace for a preview is provisioned by the workers control plane. - * For a bound Durable Object it comes back in the create-deployment response, - * so we read it from `deployment.env` rather than re-fetching. A Durable Object - * reached only through `ctx.exports` has no binding to carry it, so those fall - * back to the namespaces list API. + * Returns an empty `normalisedContainerConfig` when there's nothing to deploy, + * whether because `previews.containers` is empty or every entry resolves to a + * cross-script DO binding owned by another Worker. Throws if an entry's + * `class_name` matches no DO binding in `previews.durable_objects`. */ -export async function deployPreviewContainers( - scopedConfig: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], - deployment: DeploymentResource, - accountId: string, +export async function preparePreviewContainers( + config: Config, + workerName: string, + previewSlug: string, options: { quiet: boolean } -): Promise { - if (!options.quiet) { - return applyPreviewContainers( - scopedConfig, - normalisedContainerConfig, - deployment, - accountId +): Promise { + initContainersSharedContext({ + logger, + fetchResult, + }); + + return runPreviewContainerOperation(options, async () => { + const previewContainers = + (config.previews as PreviewsConfig | undefined)?.containers ?? []; + if (previewContainers.length === 0) { + return emptyPreviewContainerPreparation(); + } + + const scopedContainerConfig = buildPreviewContainerConfig( + config, + workerName, + previewSlug, + previewContainers ); - } + if (!scopedContainerConfig) { + return emptyPreviewContainerPreparation(); + } - // Two independent log levels gate stdout here. `logger` reads an - // AsyncLocalStorage override and `@cloudflare/cli`'s `logRaw` reads module - // level state, so lowering one leaves the other printing. `logger` drops - // messages above its level instead of redirecting them, so it stays at - // `warn` to keep warnings and errors on stderr. `logRaw` only writes to - // stdout, so it can go lower. - const previousLogLevel = getLogLevel(); - setLogLevel("error"); - try { - return await runWithLogLevel("warn", () => - applyPreviewContainers( - scopedConfig, - normalisedContainerConfig, - deployment, - accountId - ) + const normalisedContainerConfig = await getNormalizedContainerOptions( + scopedContainerConfig, + { dryRun: false } ); - } finally { - setLogLevel(previousLogLevel); - } + + const containersNeedingDocker = normalisedContainerConfig.filter( + (container) => "dockerfile" in container + ); + if (containersNeedingDocker.length > 0) { + const dockerPath = getDockerPath(); + await verifyDockerInstalled({ + dockerPath, + operation: "creating a preview", + imageNoun: + containersNeedingDocker.length !== 1 + ? "the configured images" + : "the configured image", + hint: 'If you cannot run Docker locally, set "image" to a prebuilt registry image instead of a Dockerfile path for the affected entries in "previews.containers".', + }); + + // Applying containers checks the token's scope as well, but only after + // the deployment exists. Checking it here stops a badly scoped token from + // leaving a live preview that advertises containers nothing ever built. + await verifyContainersScope(scopedContainerConfig); + + return { + scopedContainerConfig, + normalisedContainerConfig, + builtContainerDeployments: await buildContainerImages( + normalisedContainerConfig, + dockerPath, + false + ), + }; + } + + await verifyContainersScope(scopedContainerConfig); + return { + scopedContainerConfig, + normalisedContainerConfig, + builtContainerDeployments: [], + }; + }); +} + +function emptyPreviewContainerPreparation(): PreviewContainerPreparation { + return { + scopedContainerConfig: undefined, + normalisedContainerConfig: [], + builtContainerDeployments: [], + }; } /** - * Resolve each normalised container's Durable Object namespace and build then - * apply its Cloudchamber application. - * - * @param scopedConfig - Synthetic config scoped to the preview's containers. - * @param normalisedContainerConfig - Containers to build and apply. - * @param deployment - The preview deployment the containers belong to. - * @param accountId - Account the preview belongs to. - * @returns A promise that resolves once every container has been applied. + * Construct a synthetic `Config` for the preview's containers, so we can reuse + * the standard Wrangler container config normalisation. Containers come from + * `previews.containers`, defaulting each unnamed entry to a generated + * application name, and DO bindings come from `previews.durable_objects`. */ -async function applyPreviewContainers( - scopedConfig: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], - deployment: DeploymentResource, - accountId: string -): Promise { - await fillOpenAPIConfiguration(scopedConfig, containersScope); - const dockerPath = getDockerPath(); - - // Skip bindings carrying `script_name`. Those name a Durable Object - // implemented by another Worker, which owns its own container application, - // so their namespace belongs to that Worker. A preview may bind the same - // class name both locally and cross-script, and since this map is keyed on - // class name alone, an unfiltered cross-script entry could overwrite the - // preview's own namespace_id and attach the container to the wrong storage. - // `wrangler deploy` applies the same restriction (see containers/deploy.ts). - const classNameToNamespaceId = new Map(); - for (const binding of Object.values(deployment.env ?? {})) { - if ( - binding.type === "durable_object_namespace" && - binding.class_name && - binding.namespace_id && - binding.script_name === undefined - ) { - classNameToNamespaceId.set(binding.class_name, binding.namespace_id); - } - } +function buildPreviewContainerConfig( + config: Config, + parentWorkerName: string, + previewSlug: string, + previewContainers: ContainerApp[] +): Config | undefined { + const previews = config.previews as PreviewsConfig | undefined; + const previewDOBindings = previews?.durable_objects?.bindings ?? []; + const ownedDOClasses = getPreviewOwnedContainerClassNames(config, previews); - // Only bound Durable Objects appear in `deployment.env`. A class reached - // solely through `ctx.exports` still has a namespace provisioned for the - // preview, so fall back to the namespaces list and match on it, the same way - // `wrangler deploy` resolves an unbound Durable Object. - let allNamespaces: DurableObjectNamespace[] | undefined; - - for (const container of normalisedContainerConfig) { - let namespaceId = classNameToNamespaceId.get(container.class_name); - if (!namespaceId) { - allNamespaces ??= await listDurableObjects(scopedConfig, accountId); - // `script` is the parent Worker's name for every one of its previews, - // so match on the preview id to avoid attaching this container to the - // parent's namespace or to another preview's. - namespaceId = allNamespaces.find( - (namespace) => - namespace.class === container.class_name && - namespace.preview?.id === deployment.preview_id - )?.id; - } - if (!namespaceId) { + const linkedContainers = previewContainers.map((container) => { + const className = container.class_name; + if (className === undefined) { + // A preview container has to name its Durable Object class itself. The + // other direction of the link, a Durable Object naming its container + // through `exports[Class].container`, resolves against the top-level + // `containers` array, so it can only ever reach a container this preview + // does not own. throw new UserError( - `Could not deploy preview container application "${container.name}": no Durable Object namespace was found for class "${container.class_name}" in preview "${deployment.preview_name}". This is likely a bug in Wrangler. Please file an issue.`, + `A container entry in "previews.containers" is missing "class_name". A preview container must name the Durable Object class it backs, even where a Durable Object declared in "exports" names its container instead.`, { - telemetryMessage: "preview containers deploy missing do namespace id", + telemetryMessage: "preview container missing class_name", } ); } + return { container, className }; + }); - let imageRef; - if ("dockerfile" in container) { - // Docker rejects uppercase characters in an image repository name, and - // a preview application name embeds the Durable Object class name - // verbatim, which is conventionally PascalCase. Lowercase the name for - // the local image tag only. `apply` below needs the exact application - // name, which the control plane matches on when reconciling previews. - imageRef = await buildContainer( - { ...container, name: container.name.toLowerCase() }, - deployment.id, - false, - dockerPath, - // `preview()` already verified Docker before creating the - // deployment, so skip the redundant per-container check. - false, - // Selects the managed registry for the account's compliance - // region. Without it the push defaults to the public registry. - scopedConfig - ); - } else { - imageRef = { newTag: container.image_uri }; + for (const { className } of linkedContainers) { + if ( + ownedDOClasses.has(className) || + previewDOBindings.some((b) => b.class_name === className) + ) { + continue; } - - await apply( - { imageRef, durable_object_namespace_id: namespaceId }, - container, - scopedConfig + // A container whose class matches no Durable Object at all is a + // misconfiguration, almost always a typo, and silently dropping it would + // hand back a preview with no container and no explanation, so reject it + // here, before the preview deployment is created. + throw new UserError( + `The container class_name "${className}" in "previews.containers" does not match any Durable Object class in your ${configFileName(config.configPath)} file. Declare the class in "migrations" or "exports", or bind it under "previews.durable_objects".`, + { + telemetryMessage: "no preview DO class matches container class_name", + } ); } + + // A class that matches only a binding carrying `script_name` is excluded + // rather than rejected: that DO is implemented by another Worker, which owns + // its own container application. + const filteredContainers = linkedContainers + .filter(({ className }) => ownedDOClasses.has(className)) + .map(({ container, className }) => ({ + ...container, + name: previewContainerAppName(parentWorkerName, previewSlug, className), + })); + + if (filteredContainers.length === 0) { + return undefined; + } + + // `getNormalizedContainerOptions` resolves a container's Durable Object with + // `find()` on `class_name`, and rejects the container outright if that first + // match carries `script_name`. A class bound both locally and cross-script + // would then fail as though another Worker owned it, purely because of + // binding order. Put the locally implemented bindings first so the lookup + // lands on the one this preview owns. + const localBindingsFirst = [ + ...previewDOBindings.filter((b) => b.script_name === undefined), + ...previewDOBindings.filter((b) => b.script_name !== undefined), + ]; + + // `observability` is carried over because a container application has its own + // observability setting, which `getNormalizedContainerOptions` reads from the + // config it is given. The container path does not read `logpush`, `limits`, or + // `cache`, so overlaying those here would have no effect. + const observability = previews?.observability ?? config.observability; + return { + ...config, + containers: filteredContainers, + durable_objects: { + bindings: localBindingsFirst, + }, + observability, + }; +} + +async function runPreviewContainerOperation( + options: { quiet: boolean }, + operation: () => Promise +): Promise { + if (!options.quiet) { + return operation(); + } + + // Two independent log levels gate stdout here. `logger` reads an + // AsyncLocalStorage override and `@cloudflare/cli`'s `logRaw` reads module + // level state, so lowering one leaves the other printing. `logger` drops + // messages above its level instead of redirecting them, so it stays at + // `warn` to keep warnings and errors on stderr. `logRaw` only writes to + // stdout, so it can go lower. + const previousLogLevel = getLogLevel(); + setLogLevel("error"); + try { + return await runWithLogLevel("warn", operation); + } finally { + setLogLevel(previousLogLevel); + } } diff --git a/packages/wrangler/src/preview/preview.ts b/packages/wrangler/src/preview/preview.ts index b60194c7115..9dbe32b2879 100644 --- a/packages/wrangler/src/preview/preview.ts +++ b/packages/wrangler/src/preview/preview.ts @@ -1,14 +1,13 @@ import { preview } from "@cloudflare/deploy-helpers"; import { getWranglerTmpDir } from "@cloudflare/workers-utils"; import { getAssetsOptions } from "../assets"; -import { getNormalizedContainerOptions } from "../containers/config"; import { createCommand } from "../core/create-command"; import { getEntry } from "../deployment-bundle/entry"; import { buildWorker } from "../deployment-bundle/maybe-build-worker"; import { cleanupDestination } from "../deployment-bundle/merge-config-args"; import { writeOutput } from "../output"; import { requireAuth } from "../user"; -import { deployPreviewContainers, verifyContainersScope } from "./containers"; +import { preparePreviewContainers } from "./containers"; export const previewCommand = createCommand({ metadata: { @@ -102,9 +101,7 @@ export const previewCommand = createCommand({ buildResult, assetsOptions, { - getNormalizedContainerOptions, - deployPreviewContainers, - verifyContainersScope, + preparePreviewContainers, } ); cleanupDestination(destination); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e84c0d7a59..d47898b1b88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1857,13 +1857,17 @@ importers: version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) packages/containers-shared: - devDependencies: - '@cloudflare/workers-tsconfig': + dependencies: + '@cloudflare/cli-shared-helpers': specifier: workspace:* - version: link:../workers-tsconfig + version: link:../cli '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig '@types/node': specifier: 22.15.17 version: 22.15.17 @@ -2032,6 +2036,9 @@ importers: '@cloudflare/cli-shared-helpers': specifier: workspace:* version: link:../cli + '@cloudflare/containers-shared': + specifier: workspace:* + version: link:../containers-shared '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2060,9 +2067,6 @@ importers: specifier: catalog:default version: 7.29.0 devDependencies: - '@cloudflare/containers-shared': - specifier: workspace:* - version: link:../containers-shared '@cloudflare/workers-shared': specifier: workspace:* version: link:../workers-shared