diff --git a/.changeset/fuzzy-pandas-launch.md b/.changeset/fuzzy-pandas-launch.md new file mode 100644 index 00000000000..6508c2d3287 --- /dev/null +++ b/.changeset/fuzzy-pandas-launch.md @@ -0,0 +1,13 @@ +--- +"@cloudflare/workers-utils": minor +"@cloudflare/deploy-helpers": minor +"wrangler": minor +--- + +Add Durable Object-managed Containers to top-level container configuration + +Wrangler now accepts `scheduling_policy: "durable_object"` in the top-level `containers` array and creates its namespace-backed application after the Worker upload resolves the Durable Object namespace ID. The namespace ID is also the application ID, so repeated deploys idempotently ensure the same application without name-based lookup, modification, or a Containers rollout. + +Durable Object-managed entries accept only `class_name`, `scheduling_policy`, and an optional named `images` map. Each image provides either a local `dockerfile` or a digest-pinned managed-registry `image`. Wrangler builds or resolves each image, waits while Cloudflare prepares it for the Containers runtime, and uploads the resulting references with the Worker version for access through `ctx.container.images` and `env.EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES`. + +Existing scheduler-backed entries and Durable Object migrations continue to work unchanged. diff --git a/.changeset/tidy-containers-develop.md b/.changeset/tidy-containers-develop.md new file mode 100644 index 00000000000..f9eae1a4821 --- /dev/null +++ b/.changeset/tidy-containers-develop.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Support Durable Object-managed Container images in local development + +`wrangler dev` now builds or pulls every named image configured on an experimental `scheduling_policy: "durable_object"` Container, exposes their local tags through `EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES`, and attaches the Container capability to its Durable Object. Calls to `ctx.container.start({ image })` can select among those images locally; Wrangler uses the first configured image as the Miniflare attachment so same-session container snapshot restores have a concrete fallback image. diff --git a/packages/containers-shared/src/client/index.ts b/packages/containers-shared/src/client/index.ts index 1dc75b37f2e..8c745061800 100644 --- a/packages/containers-shared/src/client/index.ts +++ b/packages/containers-shared/src/client/index.ts @@ -46,11 +46,14 @@ export type { Command } from "./models/Command"; export type { CompleteAccountCustomer } from "./models/CompleteAccountCustomer"; export type { CompleteAccountLocationCustomer } from "./models/CompleteAccountLocationCustomer"; export { ContainerNetworkMode } from "./models/ContainerNetworkMode"; +export type { ContainerImagePreparation } from "./models/ContainerImagePreparation"; +export { ContainerImagePreparationStatus } from "./models/ContainerImagePreparationStatus"; export type { CreateApplicationBadRequest } from "./models/CreateApplicationBadRequest"; export type { CreateApplicationJobBadRequest } from "./models/CreateApplicationJobBadRequest"; export type { CreateApplicationJobRequest } from "./models/CreateApplicationJobRequest"; export type { CreateApplicationRequest } from "./models/CreateApplicationRequest"; export { CreateApplicationRolloutRequest } from "./models/CreateApplicationRolloutRequest"; +export type { CreateDurableObjectApplicationRequest } from "./models/CreateDurableObjectApplicationRequest"; export type { CreateDeploymentBadRequest } from "./models/CreateDeploymentBadRequest"; export type { CreateDeploymentV2RequestBody } from "./models/CreateDeploymentV2RequestBody"; export type { CreateImageRegistryRequestBody } from "./models/CreateImageRegistryRequestBody"; @@ -182,6 +185,7 @@ export type { PlainTextSecretValue } from "./models/PlainTextSecretValue"; export type { Port } from "./models/Port"; export type { PortRange } from "./models/PortRange"; export type { PortRangeAllocation } from "./models/PortRangeAllocation"; +export type { PrepareContainerImageRequestBody } from "./models/PrepareContainerImageRequestBody"; export { ProvisionerConfiguration } from "./models/ProvisionerConfiguration"; export type { Ref } from "./models/Ref"; export type { Region } from "./models/Region"; @@ -215,6 +219,7 @@ export type { WranglerSSHResponse } from "./models/WranglerSSHResponse"; export { AccountService } from "./services/AccountService"; export { ApplicationsService } from "./services/ApplicationsService"; +export { ContainerImagePreparationsService } from "./services/ContainerImagePreparationsService"; export { DeploymentsService } from "./services/DeploymentsService"; export { ImageRegistriesService } from "./services/ImageRegistriesService"; export { IPsService } from "./services/IPsService"; diff --git a/packages/containers-shared/src/client/models/ContainerImagePreparation.ts b/packages/containers-shared/src/client/models/ContainerImagePreparation.ts new file mode 100644 index 00000000000..2be04c171b6 --- /dev/null +++ b/packages/containers-shared/src/client/models/ContainerImagePreparation.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ContainerImagePreparationStatus } from "./ContainerImagePreparationStatus"; + +export type ContainerImagePreparation = { + image: string; + status: ContainerImagePreparationStatus; + artifact_digest?: string; + reason?: string; +}; diff --git a/packages/containers-shared/src/client/models/ContainerImagePreparationStatus.ts b/packages/containers-shared/src/client/models/ContainerImagePreparationStatus.ts new file mode 100644 index 00000000000..1dba9184913 --- /dev/null +++ b/packages/containers-shared/src/client/models/ContainerImagePreparationStatus.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export enum ContainerImagePreparationStatus { + PENDING = "pending", + READY = "ready", + ERROR = "error", +} diff --git a/packages/containers-shared/src/client/models/CreateDurableObjectApplicationRequest.ts b/packages/containers-shared/src/client/models/CreateDurableObjectApplicationRequest.ts new file mode 100644 index 00000000000..5bf275bc552 --- /dev/null +++ b/packages/containers-shared/src/client/models/CreateDurableObjectApplicationRequest.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { DurableObjectsConfiguration } from "./DurableObjectsConfiguration"; +import type { SchedulingPolicy } from "./SchedulingPolicy"; + +/** + * Create a namespace-backed application whose instances are owned by Durable Objects. + */ +export type CreateDurableObjectApplicationRequest = { + /** + * The name for this application. + */ + name: string; + scheduling_policy: SchedulingPolicy.DURABLE_OBJECT; + /** + * The customer-owned Durable Object namespace that owns this application and its instances. + */ + durable_objects: DurableObjectsConfiguration; +}; diff --git a/packages/containers-shared/src/client/models/PrepareContainerImageRequestBody.ts b/packages/containers-shared/src/client/models/PrepareContainerImageRequestBody.ts new file mode 100644 index 00000000000..667525b53d1 --- /dev/null +++ b/packages/containers-shared/src/client/models/PrepareContainerImageRequestBody.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type PrepareContainerImageRequestBody = { + image: string; +}; diff --git a/packages/containers-shared/src/client/models/SchedulingPolicy.ts b/packages/containers-shared/src/client/models/SchedulingPolicy.ts index 4665415b82c..d86d718fc75 100644 --- a/packages/containers-shared/src/client/models/SchedulingPolicy.ts +++ b/packages/containers-shared/src/client/models/SchedulingPolicy.ts @@ -6,6 +6,7 @@ * The scheduling policy to use for an application */ export enum SchedulingPolicy { + DURABLE_OBJECT = "durable_object", MOON = "moon", GPU = "gpu", REGIONAL = "regional", diff --git a/packages/containers-shared/src/client/services/ApplicationsService.ts b/packages/containers-shared/src/client/services/ApplicationsService.ts index 16e55643b72..17bf6b81204 100644 --- a/packages/containers-shared/src/client/services/ApplicationsService.ts +++ b/packages/containers-shared/src/client/services/ApplicationsService.ts @@ -14,6 +14,7 @@ import type { ApplicationStatus } from "../models/ApplicationStatus"; import type { CreateApplicationJobRequest } from "../models/CreateApplicationJobRequest"; import type { CreateApplicationRequest } from "../models/CreateApplicationRequest"; import type { CreateApplicationRolloutRequest } from "../models/CreateApplicationRolloutRequest"; +import type { CreateDurableObjectApplicationRequest } from "../models/CreateDurableObjectApplicationRequest"; import type { DashApplication } from "../models/DashApplication"; import type { DashApplicationInstances } from "../models/DashApplicationInstances"; import type { DeploymentID } from "../models/DeploymentID"; @@ -40,7 +41,9 @@ export class ApplicationsService { * @throws ApiError */ public static createApplication( - requestBody: CreateApplicationRequest + requestBody: + | CreateApplicationRequest + | CreateDurableObjectApplicationRequest ): CancelablePromise { return __request(OpenAPI, { method: "POST", diff --git a/packages/containers-shared/src/client/services/ContainerImagePreparationsService.ts b/packages/containers-shared/src/client/services/ContainerImagePreparationsService.ts new file mode 100644 index 00000000000..a8be49b900b --- /dev/null +++ b/packages/containers-shared/src/client/services/ContainerImagePreparationsService.ts @@ -0,0 +1,30 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; +import type { CancelablePromise } from "../core/CancelablePromise"; +import type { ContainerImagePreparation } from "../models/ContainerImagePreparation"; +import type { PrepareContainerImageRequestBody } from "../models/PrepareContainerImageRequestBody"; + +export class ContainerImagePreparationsService { + /** + * Prepare a digest-pinned managed image for the Containers runtime. + */ + public static prepareContainerImage( + requestBody: PrepareContainerImageRequestBody + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/image-preparations", + body: requestBody, + mediaType: "application/json", + errors: { + 400: `The image is invalid or does not exist in this account`, + 401: `Unauthorized`, + 403: `Container image preparation is not enabled for this account`, + 500: `There has been an internal error`, + }, + }); + } +} diff --git a/packages/containers-shared/src/types.ts b/packages/containers-shared/src/types.ts index ea198b6267d..a73564fe6b7 100644 --- a/packages/containers-shared/src/types.ts +++ b/packages/containers-shared/src/types.ts @@ -37,6 +37,34 @@ export type BuildArgs = { export type ContainerNormalizedConfig = SharedContainerConfig & (ImageURIConfig | DockerfileConfig); + +/** + * A named image belonging to a Durable Object-managed Container application. + * + * Unlike a legacy Container application, the image is selected when + * `ctx.container.start()` is called. Local development still needs each image + * normalized into something Docker can build or pull, so Wrangler flattens the + * configured image map into one of these entries. + */ +export type DurableObjectContainerDevImageConfig = { + class_name: string; + scheduling_policy: "durable_object"; + image_name: string; +} & (ImageURIConfig | DockerfileConfig); + +/** + * An image-less marker keeps the Container attached to its Durable Object in + * local development when no named images are configured. + */ +export type DurableObjectContainerDevMarker = { + class_name: string; + scheduling_policy: "durable_object"; +}; + +export type ContainerDevConfig = + | ContainerNormalizedConfig + | DurableObjectContainerDevImageConfig + | DurableObjectContainerDevMarker; export type DockerfileConfig = { /** absolute path, resolved relative to the wrangler config file */ dockerfile: string; @@ -100,4 +128,6 @@ export type ContainerDevOptions = { image_tag: string; /** container's DO class name */ class_name: string; + /** configured image key for Durable Object-managed Containers */ + image_name?: string; } & (DockerfileConfig | ImageURIConfig); diff --git a/packages/deploy-helpers/src/deploy/deploy.ts b/packages/deploy-helpers/src/deploy/deploy.ts index ce042caf880..1655c91fe33 100644 --- a/packages/deploy-helpers/src/deploy/deploy.ts +++ b/packages/deploy-helpers/src/deploy/deploy.ts @@ -9,6 +9,7 @@ import { formatTime, getBindings, getDockerPath, + getDurableObjectContainerApps, hasDurableObjectExports, parseNonHyphenedUuid, printBindings, @@ -30,6 +31,11 @@ import { type BundleSize, } from "./helpers/bundle-reporter"; import { confirmLatestDeploymentOverwrite } from "./helpers/confirm-latest-deployment-overwrite"; +import { addContainerImagesBinding } from "./helpers/container-image-bindings"; +import { + getContainerMetadata, + getContainerMetadataForRolloutSkip, +} from "./helpers/container-metadata"; import { createWorkerUploadForm } from "./helpers/create-worker-upload-form"; import { deployWfpUserWorker } from "./helpers/deploy-wfp"; import { @@ -70,6 +76,7 @@ import type { DeployProps, WorkerBuildResult } from "../shared/types"; import type { AssetUploadStats } from "./helpers/assets"; import type { RetrieveSourceMapFunction } from "./helpers/sourcemap"; import type { + ApiDeployment, ApiVersion, Percentage, VersionId, @@ -133,6 +140,18 @@ export type DeployCallbacks = { args: { versionId: string; accountId: string; scriptName: string } ) => Promise) | undefined; + prepareDurableObjectContainerApplications: + | (( + config: Config, + args: { dryRun: boolean; scriptName: string } + ) => Promise>>) + | undefined; + deployDurableObjectContainerApplications: + | (( + config: Config, + args: { versionId: string; accountId: string; scriptName: string } + ) => Promise) + | undefined; analyseBundle: | ((workerBundle: string | FormData) => Promise>) | undefined; @@ -213,13 +232,15 @@ async function deployWorker( const { format } = entry; const { projectRoot } = entry; + let latestDeployment: ApiDeployment | undefined; if (!props.dispatchNamespace && accountId && scriptName) { - const yes = await confirmLatestDeploymentOverwrite( + const confirmation = await confirmLatestDeploymentOverwrite( config, accountId, scriptName ); - if (!yes) { + latestDeployment = confirmation.latestDeployment; + if (!confirmation.confirmed) { cancel("Aborting deploy..."); return { versionId, workerTag }; } @@ -238,6 +259,25 @@ async function deployWorker( content, sourceMaps, } = buildResult; + const skipContainerChanges = props.containersRollout === "none"; + const preparedContainerImages = skipContainerChanges + ? undefined + : await callbacks.prepareDurableObjectContainerApplications?.(config, { + dryRun: Boolean(isDryRun), + scriptName, + }); + const rolloutSkipContainerState = skipContainerChanges + ? await getContainerMetadataForRolloutSkip(config, { + accountId, + scriptName, + dispatchNamespace: props.dispatchNamespace, + workerExists, + latestDeployment, + }) + : undefined; + const containerMetadata = + rolloutSkipContainerState?.containers ?? + getContainerMetadata(config, preparedContainerImages); // Durable Object lifecycle is expressed through either legacy `migrations` // or the declarative `exports` map. Only one is sent on each upload. const { migrations, exports } = await resolveExportsUploadPayload({ @@ -308,6 +348,12 @@ async function deployWorker( type: "deploy", workerExists, }); + addContainerImagesBinding(config, bindings, preparedContainerImages ?? {}, { + preserveExisting: skipContainerChanges, + workerExists, + hasExistingBinding: + rolloutSkipContainerState?.hasExistingContainerImagesBinding, + }); if (workersSitesAssets.manifest) { modules.push({ @@ -333,7 +379,7 @@ async function deployWorker( migrations, exports, modules, - containers: config.containers, + containers: containerMetadata, sourceMaps, compatibility_date: compatibilityDate, compatibility_flags: compatibilityFlags, @@ -779,6 +825,18 @@ async function deployWorker( scriptName, }); } + if ( + !skipContainerChanges && + getDurableObjectContainerApps(config.containers).length > 0 && + callbacks.deployDurableObjectContainerApplications + ) { + assert(versionId && accountId); + await callbacks.deployDurableObjectContainerApplications(config, { + versionId, + accountId, + scriptName, + }); + } // Early exit for WfP since it doesn't need the below code if (props.dispatchNamespace !== undefined) { diff --git a/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts b/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts index 6a77784e955..042e75ea912 100644 --- a/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts +++ b/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts @@ -19,7 +19,10 @@ export async function confirmLatestDeploymentOverwrite( config: Config, accountId: string, scriptName: string -) { +): Promise<{ + confirmed: boolean; + latestDeployment: ApiDeployment | undefined; +}> { try { const latest = await fetchLatestDeployment(config, accountId, scriptName); if (latest && latest.versions.length >= 2) { @@ -39,20 +42,22 @@ export async function confirmLatestDeploymentOverwrite( versionCache ); - return inputPrompt({ + const confirmed = await inputPrompt({ type: "confirm", question: `"wrangler deploy" will upload a new version and deploy it globally immediately.\nAre you sure you want to continue?`, label: "", defaultValue: isNonInteractiveOrCI(), acceptDefault: isNonInteractiveOrCI(), }); + return { confirmed, latestDeployment: latest }; } + return { confirmed: true, latestDeployment: latest }; } catch (e) { if (!isWorkerNotFoundError(e)) { throw e; } } - return true; + return { confirmed: true, latestDeployment: undefined }; } async function printDeployment( diff --git a/packages/deploy-helpers/src/deploy/helpers/container-image-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/container-image-bindings.ts new file mode 100644 index 00000000000..3b88907728a --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/container-image-bindings.ts @@ -0,0 +1,61 @@ +import { + CONTAINER_IMAGES_BINDING, + getDurableObjectContainerApps, + UserError, +} from "@cloudflare/workers-utils"; +import type { Binding, Config } from "@cloudflare/workers-utils"; + +type PreparedContainerImages = Record>; + +export function addContainerImagesBinding( + config: Config, + bindings: Record, + preparedContainerImages: PreparedContainerImages, + options: { + preserveExisting?: boolean; + workerExists?: boolean; + hasExistingBinding?: boolean; + } = {} +): void { + const containers = getDurableObjectContainerApps(config.containers); + const shouldInheritExisting = + options.preserveExisting && + options.workerExists && + options.hasExistingBinding; + if (containers.length === 0 && !shouldInheritExisting) { + return; + } + + if (bindings[CONTAINER_IMAGES_BINDING] !== undefined) { + throw new UserError( + `The binding name "${CONTAINER_IMAGES_BINDING}" is reserved for Durable Object-managed Container images.`, + { + telemetryMessage: + "durable object container images binding name conflict", + } + ); + } + + if (options.preserveExisting) { + if (shouldInheritExisting) { + bindings[CONTAINER_IMAGES_BINDING] = { type: "inherit" }; + } + return; + } + + bindings[CONTAINER_IMAGES_BINDING] = { + type: "json", + value: Object.fromEntries( + containers.map((container) => { + const configuredImages = Object.keys(container.images ?? {}); + const preparedImages = preparedContainerImages[container.class_name]; + if (configuredImages.length > 0 && preparedImages === undefined) { + throw new Error( + `Container images for Durable Object class "${container.class_name}" were not prepared before upload.` + ); + } + return [container.class_name, preparedImages ?? {}]; + }) + ), + }; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/container-metadata.ts b/packages/deploy-helpers/src/deploy/helpers/container-metadata.ts new file mode 100644 index 00000000000..e08f8dc095f --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/container-metadata.ts @@ -0,0 +1,241 @@ +import { + CONTAINER_IMAGES_BINDING, + isDurableObjectContainerApp, + UserError, +} from "@cloudflare/workers-utils"; +import { fetchListResult } from "../../shared/context"; +import { fetchVersions } from "./versions-api"; +import type { ApiDeployment } from "./versions-types"; +import type { + CfWorkerInit, + Config, + WorkerMetadataBinding, +} from "@cloudflare/workers-utils"; + +type ContainerImages = Record>; + +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === "string") + ); +} + +function parseContainerImagesBinding( + bindings: WorkerMetadataBinding[] +): ContainerImages { + const binding = bindings.find( + (candidate) => candidate.name === CONTAINER_IMAGES_BINDING + ); + if (binding === undefined) { + return {}; + } + if (binding.type !== "json") { + throw new UserError( + `The existing ${CONTAINER_IMAGES_BINDING} binding has an invalid type.`, + { + telemetryMessage: + "rollout none invalid durable object container image binding type", + } + ); + } + + let value: unknown = binding.json; + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + throw new UserError( + `The existing ${CONTAINER_IMAGES_BINDING} binding contains invalid JSON.`, + { + telemetryMessage: + "rollout none invalid durable object container image binding json", + } + ); + } + } + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + !Object.values(value).every(isStringRecord) + ) { + throw new UserError( + `The existing ${CONTAINER_IMAGES_BINDING} binding has an invalid image map.`, + { + telemetryMessage: + "rollout none invalid durable object container image binding map", + } + ); + } + + return value as ContainerImages; +} + +export function getContainerMetadata( + config: Config, + preparedContainerImages: ContainerImages = {}, + options: { allowUnprepared?: boolean } = {} +): CfWorkerInit["containers"] { + const metadata = + config.containers?.map((container) => { + if (isDurableObjectContainerApp(container)) { + const configuredImages = Object.keys(container.images ?? {}); + const images = preparedContainerImages[container.class_name]; + if ( + configuredImages.length > 0 && + images === undefined && + !options.allowUnprepared + ) { + throw new Error( + `Container images for Durable Object class "${container.class_name}" were not prepared before upload.` + ); + } + return { + name: container.name, + class_name: container.class_name, + ...(images !== undefined && { images }), + }; + } + + return { + ...(container.name !== undefined && { name: container.name }), + ...(container.class_name !== undefined && { + class_name: container.class_name, + }), + }; + }) ?? []; + + return config.containers === undefined ? undefined : metadata; +} + +export async function getContainerMetadataForRolloutSkip( + config: Config, + { + accountId, + scriptName, + dispatchNamespace, + workerExists, + latestDeployment, + }: { + accountId: string | undefined; + scriptName: string; + dispatchNamespace: string | undefined; + workerExists: boolean; + latestDeployment: ApiDeployment | undefined; + } +): Promise<{ + containers: CfWorkerInit["containers"]; + hasExistingContainerImagesBinding: boolean; +}> { + const configuredMetadata = getContainerMetadata( + config, + {}, + { + allowUnprepared: true, + } + ); + if ( + config.containers === undefined || + !workerExists || + accountId === undefined + ) { + return { + containers: configuredMetadata, + hasExistingContainerImagesBinding: false, + }; + } + + if (dispatchNamespace !== undefined) { + const bindings = await fetchListResult( + config, + `/accounts/${accountId}/workers/dispatch/namespaces/${dispatchNamespace}/scripts/${scriptName}/bindings` + ); + return { + containers: getContainerMetadata( + config, + parseContainerImagesBinding(bindings), + { + allowUnprepared: true, + } + ), + hasExistingContainerImagesBinding: bindings.some( + (binding) => binding.name === CONTAINER_IMAGES_BINDING + ), + }; + } + + if ( + latestDeployment === undefined || + latestDeployment.versions.length === 0 + ) { + return { + containers: configuredMetadata, + hasExistingContainerImagesBinding: false, + }; + } + + const versions = await fetchVersions( + config, + accountId, + scriptName, + undefined, + latestDeployment.versions.map(({ version_id }) => version_id) + ); + const containerImagesBindingByVersion = versions.map((version) => + version.resources.bindings.some( + (binding) => binding.name === CONTAINER_IMAGES_BINDING + ) + ); + const hasExistingContainerImagesBinding = + containerImagesBindingByVersion[0] ?? false; + if ( + containerImagesBindingByVersion.some( + (hasBinding) => hasBinding !== hasExistingContainerImagesBinding + ) + ) { + throw new UserError( + `All currently deployed Worker Versions must have identical ${CONTAINER_IMAGES_BINDING} binding presence when using --containers-rollout=none.`, + { + telemetryMessage: + "rollout none inconsistent durable object container image binding", + } + ); + } + const containerMetadataByVersion = versions.map( + (version) => version.resources?.script_runtime?.containers + ); + const existingMetadata = containerMetadataByVersion.filter( + (metadata): metadata is NonNullable => + metadata !== undefined + ); + if (existingMetadata.length === 0) { + return { + containers: configuredMetadata, + hasExistingContainerImagesBinding, + }; + } + + const expectedMetadata = JSON.stringify(existingMetadata[0]); + if ( + existingMetadata.length !== containerMetadataByVersion.length || + existingMetadata.some( + (metadata) => JSON.stringify(metadata) !== expectedMetadata + ) + ) { + throw new UserError( + "All currently deployed Worker Versions must have identical Container metadata when using --containers-rollout=none.", + { + telemetryMessage: + "rollout none inconsistent deployed container metadata", + } + ); + } + + return { + containers: existingMetadata[0], + hasExistingContainerImagesBinding, + }; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts index 4bf564153ab..74364a796d3 100644 --- a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts +++ b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts @@ -882,6 +882,7 @@ export function createWorkerUploadForm( : worker.containers.map((c) => ({ ...(c.name !== undefined && { name: c.name }), ...(c.class_name !== undefined && { class_name: c.class_name }), + ...(c.images !== undefined && { images: c.images }), })), ...(compatibility_date && { compatibility_date }), diff --git a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts index 0ad9e8eef89..fb50a106f99 100644 --- a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts +++ b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts @@ -4,6 +4,7 @@ import { DEFAULT_COMPAT_DATE, experimental_patchConfig, formatConfigSnippet, + isDurableObjectContainerApp, isNonInteractiveOrCI, UserError, } from "@cloudflare/workers-utils"; @@ -117,7 +118,11 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m ); } } else { - if (config.containers && config.containers.length > 0) { + if ( + config.containers?.some( + (container) => !isDurableObjectContainerApp(container) + ) + ) { logger.warn( `Your Worker has Containers configured. Container configuration changes (such as image, max_instances, etc.) will not be gradually rolled out with versions. These changes will only take effect after running \`deploy\`.` ); @@ -165,12 +170,25 @@ export async function preUploadApiChecks( let tags: string[] = []; // arbitrary metadata tags, not to be confused with script tag or annotations let workerExists = true; - // Skip the service metadata fetch for dispatch namespace deploys (Workers for Platforms). - // Dispatch namespace scripts don't have standard service metadata. - const skipMetadataFetch = - props.command === "deploy" && !!props.dispatchNamespace; - - if (!skipMetadataFetch) { + if (props.command === "deploy" && props.dispatchNamespace) { + try { + await fetchResult( + config, + `/accounts/${accountId}/workers/dispatch/namespaces/${props.dispatchNamespace}/scripts/${name}` + ); + } catch (e) { + if ( + typeof e === "object" && + e !== null && + "code" in e && + e.code === 10092 + ) { + workerExists = false; + } else { + throw e; + } + } + } else { try { const serviceMetaData = await fetchResult<{ default_environment: { @@ -318,6 +336,7 @@ export async function preUploadApiChecks( // defaults to true only when there are no routes. const wantsWorkersDev = props.command === "deploy" && + props.dispatchNamespace === undefined && getSubdomainValues(config.workers_dev, config.preview_urls, props.routes) .workers_dev; @@ -327,8 +346,7 @@ export async function preUploadApiChecks( // new Worker that targets workers.dev, so the user gets a clear prompt // instead of a cryptic API failure. We skip it for: // - existing Workers (their account already has a subdomain), - // - dispatch namespace deploys (which skip the metadata fetch, so - // `workerExists` stays true), and + // - dispatch namespace deploys, and // - routes-only / `workers_dev: false` deploys, which don't publish to // workers.dev and previously never required a subdomain (workflows on // such deploys still get a correctly-worded prompt in the triggers phase). diff --git a/packages/deploy-helpers/src/deploy/helpers/versions-types.ts b/packages/deploy-helpers/src/deploy/helpers/versions-types.ts index 2ad2dce21f6..fa1d803b1c8 100644 --- a/packages/deploy-helpers/src/deploy/helpers/versions-types.ts +++ b/packages/deploy-helpers/src/deploy/helpers/versions-types.ts @@ -1,5 +1,6 @@ import type { CfUserLimits, + CfWorkerInit, WorkerMetadataBinding, } from "@cloudflare/workers-utils"; @@ -47,6 +48,7 @@ export type ApiVersion = { compatibility_flags?: string[]; usage_model: "bundled" | "unbound" | "standard"; limits: CfUserLimits; + containers?: CfWorkerInit["containers"]; }; }; startup_time_ms?: number; diff --git a/packages/deploy-helpers/src/deploy/versions-upload.ts b/packages/deploy-helpers/src/deploy/versions-upload.ts index 88d8e577a23..4c1d46703f3 100644 --- a/packages/deploy-helpers/src/deploy/versions-upload.ts +++ b/packages/deploy-helpers/src/deploy/versions-upload.ts @@ -6,6 +6,8 @@ import { APIError, formatTime, getBindings, + getDurableObjectContainerApps, + hasDurableObjectExports, ParseError, printBindings, retryOnAPIFailure, @@ -22,6 +24,8 @@ import { printBundleSize, type BundleSize, } from "./helpers/bundle-reporter"; +import { addContainerImagesBinding } from "./helpers/container-image-bindings"; +import { getContainerMetadata } from "./helpers/container-metadata"; import { createWorkerUploadForm } from "./helpers/create-worker-upload-form"; import { applyServiceAndEnvironmentTags, @@ -55,7 +59,14 @@ import type { RetrieveSourceMapFunction } from "./helpers/sourcemap"; import type { CfWorkerInit, Config } from "@cloudflare/workers-utils"; import type { FormData } from "undici"; -export type VersionsUploadCallbacks = Pick; +export type VersionsUploadCallbacks = Pick & + Partial< + Pick< + DeployCallbacks, + | "prepareDurableObjectContainerApplications" + | "deployDurableObjectContainerApplications" + > + >; type VersionsUploadResult = { versionId: string | null; @@ -152,6 +163,12 @@ async function uploadWorkerVersion( }; } + const preparedContainerImages = + await callbacks.prepareDurableObjectContainerApplications?.(config, { + dryRun: Boolean(props.dryRun), + scriptName, + }); + // Resolve which Durable Object lifecycle payload to forward — either // the legacy `migrations` steps or the declarative `exports` map. The // server's versions POST controller persists `exports` on the new @@ -188,6 +205,7 @@ async function uploadWorkerVersion( } addRequiredSecretsInheritBindings(config, bindings, { type: "upload" }); + addContainerImagesBinding(config, bindings, preparedContainerImages ?? {}); const placement = parseConfigPlacement(config); @@ -204,7 +222,7 @@ async function uploadWorkerVersion( migrations, exports, modules, - containers: config.containers, + containers: getContainerMetadata(config, preparedContainerImages), sourceMaps, compatibility_date: compatibilityDate, compatibility_flags: compatibilityFlags, @@ -428,6 +446,20 @@ async function uploadWorkerVersion( return { versionId, workerTag, bundleSize }; } assert(accountId); + // Declarative exports are reconciled only when this version is deployed, so + // only migration-managed namespaces can be resolved during version upload. + if ( + !hasDurableObjectExports(config.exports) && + getDurableObjectContainerApps(config.containers).length > 0 && + callbacks.deployDurableObjectContainerApplications + ) { + assert(versionId); + await callbacks.deployDurableObjectContainerApplications(config, { + versionId, + accountId, + scriptName, + }); + } const uploadMs = Date.now() - start; diff --git a/packages/deploy-helpers/tests/container-image-bindings.test.ts b/packages/deploy-helpers/tests/container-image-bindings.test.ts new file mode 100644 index 00000000000..9ff70a22ec5 --- /dev/null +++ b/packages/deploy-helpers/tests/container-image-bindings.test.ts @@ -0,0 +1,235 @@ +import { CONTAINER_IMAGES_BINDING } from "@cloudflare/workers-utils"; +import { describe, it } from "vitest"; +import { addContainerImagesBinding } from "../src/deploy/helpers/container-image-bindings"; +import type { Binding, Config } from "@cloudflare/workers-utils"; + +describe("addContainerImagesBinding", () => { + it("adds only Durable Object-managed images to the JSON binding", ({ + expect, + }) => { + const config = { + containers: [ + { + name: "scheduled", + class_name: "Scheduled", + scheduling_policy: "default", + image: "./Dockerfile", + }, + { + name: "sandbox", + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + { + name: "tools", + class_name: "Tools", + scheduling_policy: "durable_object", + }, + ], + } as unknown as Config; + const bindings: Record = {}; + + addContainerImagesBinding(config, bindings, { + Sandbox: { + sandbox: + "registry.cloudflare.com/account/sandbox@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }); + + expect(bindings[CONTAINER_IMAGES_BINDING]).toEqual({ + type: "json", + value: { + Sandbox: { + sandbox: + "registry.cloudflare.com/account/sandbox@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + Tools: {}, + }, + }); + }); + + it("does not add the binding for scheduler-backed containers", ({ + expect, + }) => { + const bindings: Record = {}; + + addContainerImagesBinding( + { + containers: [ + { + name: "scheduled", + class_name: "Scheduled", + scheduling_policy: "default", + image: "./Dockerfile", + }, + ], + } as unknown as Config, + bindings, + {} + ); + + expect(bindings).toEqual({}); + }); + + it("does not add the binding from Durable Object export configuration", ({ + expect, + }) => { + const bindings: Record = {}; + + addContainerImagesBinding( + { + exports: { + Sandbox: { + type: "durable-object", + storage: "sqlite", + container: { + images: { + sandbox: { + dockerfile: "./container/Dockerfile", + }, + }, + }, + }, + }, + } as unknown as Config, + bindings, + { + Sandbox: { + sandbox: + "registry.cloudflare.com/account/sandbox@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + } + ); + + expect(bindings).toEqual({}); + }); + + it("rejects a user binding with the reserved name", ({ expect }) => { + const config = { + containers: [ + { + name: "sandbox", + class_name: "Sandbox", + scheduling_policy: "durable_object", + }, + ], + } as unknown as Config; + const bindings: Record = { + [CONTAINER_IMAGES_BINDING]: { + type: "plain_text", + value: "user value", + }, + }; + + expect(() => addContainerImagesBinding(config, bindings, {})).toThrow( + `The binding name "${CONTAINER_IMAGES_BINDING}" is reserved` + ); + }); + + it("rejects configured images that were not prepared", ({ expect }) => { + const config = { + containers: [ + { + name: "sandbox", + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + ], + } as unknown as Config; + + expect(() => addContainerImagesBinding(config, {}, {})).toThrow( + 'Container images for Durable Object class "Sandbox" were not prepared before upload.' + ); + }); + + it("inherits the binding when the existing image map should be preserved", ({ + expect, + }) => { + const config = { + containers: [ + { + name: "sandbox", + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + ], + } as unknown as Config; + const bindings: Record = {}; + + addContainerImagesBinding( + config, + bindings, + {}, + { + preserveExisting: true, + workerExists: true, + hasExistingBinding: true, + } + ); + + expect(bindings).toEqual({ + [CONTAINER_IMAGES_BINDING]: { type: "inherit" }, + }); + }); + + it("inherits an existing image binding without local managed containers", ({ + expect, + }) => { + const bindings: Record = {}; + + addContainerImagesBinding( + { containers: [] } as unknown as Config, + bindings, + {}, + { + preserveExisting: true, + workerExists: true, + hasExistingBinding: true, + } + ); + + expect(bindings).toEqual({ + [CONTAINER_IMAGES_BINDING]: { type: "inherit" }, + }); + }); + + it("omits the binding when preserving images for a new Worker", ({ + expect, + }) => { + const config = { + containers: [ + { + name: "sandbox", + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + ], + } as unknown as Config; + const bindings: Record = {}; + + addContainerImagesBinding( + config, + bindings, + {}, + { + preserveExisting: true, + workerExists: false, + hasExistingBinding: true, + } + ); + + expect(bindings).toEqual({}); + }); +}); diff --git a/packages/deploy-helpers/tests/container-metadata.test.ts b/packages/deploy-helpers/tests/container-metadata.test.ts new file mode 100644 index 00000000000..b4e6f69ab22 --- /dev/null +++ b/packages/deploy-helpers/tests/container-metadata.test.ts @@ -0,0 +1,99 @@ +import { describe, it } from "vitest"; +import { getContainerMetadata } from "../src/deploy/helpers/container-metadata"; +import type { Config } from "@cloudflare/workers-utils"; + +describe("getContainerMetadata", () => { + it("returns undefined when container configuration is absent", ({ + expect, + }) => { + expect(getContainerMetadata({} as Config)).toBeUndefined(); + }); + + it("returns an empty list when container configuration is explicitly empty", ({ + expect, + }) => { + expect( + getContainerMetadata({ containers: [] } as unknown as Config) + ).toEqual([]); + }); + + it("includes the resolved name when no Durable Object-managed images are configured", ({ + expect, + }) => { + const metadata = getContainerMetadata({ + containers: [ + { + class_name: "Sandbox", + name: "sandbox-app", + scheduling_policy: "durable_object", + }, + ], + } as unknown as Config); + + expect(metadata).toEqual([{ name: "sandbox-app", class_name: "Sandbox" }]); + }); + + it("includes prepared named images for Durable Object-managed containers", ({ + expect, + }) => { + const metadata = getContainerMetadata( + { + containers: [ + { + class_name: "Sandbox", + name: "sandbox-app", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + ], + } as unknown as Config, + { + Sandbox: { + sandbox: + "registry.cloudflare.com/account/sandbox@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + } + ); + + expect(metadata).toEqual([ + { + name: "sandbox-app", + class_name: "Sandbox", + images: { + sandbox: + "registry.cloudflare.com/account/sandbox@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + ]); + }); + + it("keeps Durable Object metadata without images when preparation is skipped", ({ + expect, + }) => { + const metadata = getContainerMetadata( + { + containers: [ + { + class_name: "Sandbox", + name: "sandbox-app", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + ], + } as unknown as Config, + {}, + { allowUnprepared: true } + ); + + expect(metadata).toEqual([ + { + name: "sandbox-app", + class_name: "Sandbox", + }, + ]); + }); +}); diff --git a/packages/vite-plugin-cloudflare/e2e/helpers.ts b/packages/vite-plugin-cloudflare/e2e/helpers.ts index e6da6db5cee..8ab22b3d389 100644 --- a/packages/vite-plugin-cloudflare/e2e/helpers.ts +++ b/packages/vite-plugin-cloudflare/e2e/helpers.ts @@ -321,8 +321,12 @@ export async function waitForReady(proc: Process) { const match = await vi.waitUntil( () => proc.stdout.match(/Local:\s+(http:\/\/localhost:\d+)/), // buildAndPreview does a full build before starting the server, - // so allow more time than the default. - { interval: 100, timeout: 30_000 } + // so allow more time than the default. Windows package-manager startup + // is also slower on loaded CI runners. + { + interval: 100, + timeout: process.platform === "win32" ? 60_000 : 30_000, + } ); return match[1]; } diff --git a/packages/vite-plugin-cloudflare/playground/durable-objects/__tests__/durable-objects.spec.ts b/packages/vite-plugin-cloudflare/playground/durable-objects/__tests__/durable-objects.spec.ts index 8a576c4ee8a..ce148d5da1c 100644 --- a/packages/vite-plugin-cloudflare/playground/durable-objects/__tests__/durable-objects.spec.ts +++ b/packages/vite-plugin-cloudflare/playground/durable-objects/__tests__/durable-objects.spec.ts @@ -14,17 +14,19 @@ describe("in-worker defined durable objects", async () => { test("can bind and use a Durable Object defined in the worker", async ({ expect, }) => { - expect(await getTextResponse("/?name=my-do")).toEqual( - "Durable Object 'my-do' count: 0" + const name = `my-do-${crypto.randomUUID()}`; + + expect(await getTextResponse(`/?name=${name}`)).toEqual( + `Durable Object '${name}' count: 0` ); - expect(await getTextResponse("/increment?name=my-do")).toEqual( - "Durable Object 'my-do' count: 1" + expect(await getTextResponse(`/increment?name=${name}`)).toEqual( + `Durable Object '${name}' count: 1` ); - expect(await getTextResponse("/increment?name=my-do")).toEqual( - "Durable Object 'my-do' count: 2" + expect(await getTextResponse(`/increment?name=${name}`)).toEqual( + `Durable Object '${name}' count: 2` ); - expect(await getTextResponse("/decrement?name=my-do")).toEqual( - "Durable Object 'my-do' count: 1" + expect(await getTextResponse(`/decrement?name=${name}`)).toEqual( + `Durable Object '${name}' count: 1` ); }); test.skipIf(isBuild)( diff --git a/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts index 287075457fa..c62c9e23e67 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts @@ -41,6 +41,27 @@ describe("getContainerOptions", () => { ]); }); + test("skips Durable Object-managed containers", ({ expect }) => { + const containersConfig: Containers = [ + { + name: "managed-container", + class_name: "ManagedDO", + scheduling_policy: "durable_object", + images: { + app: { dockerfile: "./Dockerfile" }, + }, + }, + ]; + + expect( + getContainerOptions({ + containersConfig, + exports: {}, + containerBuildId: "build-id", + }) + ).toEqual([]); + }); + test("resolves class_name from a durable object export that references the container", ({ expect, }) => { diff --git a/packages/vite-plugin-cloudflare/src/containers.ts b/packages/vite-plugin-cloudflare/src/containers.ts index 12947f7dbe9..ddb7eec200e 100644 --- a/packages/vite-plugin-cloudflare/src/containers.ts +++ b/packages/vite-plugin-cloudflare/src/containers.ts @@ -7,6 +7,7 @@ import { COMPLIANCE_REGION_CONFIG_UNKNOWN, getCloudflareApiBaseUrl, isDockerfile, + isDurableObjectContainerApp, resolveContainerClassName, } from "@cloudflare/workers-utils"; import type { ResolvedWorkerConfig } from "./plugin-config"; @@ -68,6 +69,13 @@ export function getContainerOptions(options: { return containersConfig .map((container) => { + if ( + isDurableObjectContainerApp(container) || + container.image === undefined + ) { + return undefined; + } + // A container is linked to its Durable Object either by its own `class_name`, // or by the Durable Object's `exports` entry naming it via `container`. // Config validation rejects containers with neither. diff --git a/packages/workers-utils/src/config/containers.ts b/packages/workers-utils/src/config/containers.ts index e89589af1ca..ecb05d7e2ea 100644 --- a/packages/workers-utils/src/config/containers.ts +++ b/packages/workers-utils/src/config/containers.ts @@ -1,6 +1,29 @@ import { getDurableObjectExports } from "./durable-object-exports"; import type { ContainerApp, Exports } from "./environment"; +export const CONTAINER_IMAGES_BINDING = + "EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES"; + +export type DurableObjectContainerApp = ContainerApp & { + class_name: string; + name: string; + scheduling_policy: "durable_object"; +}; + +export function isDurableObjectContainerApp( + container: ContainerApp +): container is DurableObjectContainerApp { + return container.scheduling_policy === "durable_object"; +} + +export function getDurableObjectContainerApps( + containers: ContainerApp[] | undefined +): DurableObjectContainerApp[] { + return Array.isArray(containers) + ? containers.filter(isDurableObjectContainerApp) + : []; +} + /** * A container can be linked to a Durable Object from either direction: * diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 20913cdfb92..6d675d15eb4 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -98,7 +98,26 @@ type UnsafeBinding = { }; /** - * Configuration for a container application + * An image Wrangler prepares for a Durable Object-managed container. + */ +export type DurableObjectContainerImage = + | { + /** + * Path to the Dockerfile Wrangler builds and pushes. + */ + dockerfile: string; + image?: never; + } + | { + /** + * Digest-pinned image in the account's managed registry. + */ + image: string; + dockerfile?: never; + }; + +/** + * Configuration for a container application. */ export type ContainerApp = { // TODO: fill out the entire type @@ -132,7 +151,16 @@ export type ContainerApp = { /** * The path to a Dockerfile, or an image URI for the Cloudflare registry. */ - image: string; + image?: string; + + /** + * Named images available to a Durable Object-managed container through + * `ctx.container.images` and + * `env.EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES[className]`. + * + * Only supported when `scheduling_policy` is `"durable_object"`. + */ + images?: Record; /** * Build context of the application. @@ -159,9 +187,13 @@ export type ContainerApp = { /** * The scheduling policy of the application * @optional + * `"durable_object"` makes each Durable Object instance own its Container. + * In that mode, only `name`, `class_name`, `scheduling_policy`, and `images` are + * supported on this entry. + * * @default "default" */ - scheduling_policy?: "default" | "moon" | "regional"; + scheduling_policy?: "default" | "durable_object" | "moon" | "regional"; /** * The instance type to be used for the container. diff --git a/packages/workers-utils/src/config/index.ts b/packages/workers-utils/src/config/index.ts index f67fe837a38..c52f2ac1b98 100644 --- a/packages/workers-utils/src/config/index.ts +++ b/packages/workers-utils/src/config/index.ts @@ -41,10 +41,14 @@ export type { export { partitionExports } from "./exports"; export type { ExportType, PartitionedExports } from "./exports"; export { + CONTAINER_IMAGES_BINDING, getContainerDurableObjectClassNames, getContainerNameToClassNameMap, + getDurableObjectContainerApps, + isDurableObjectContainerApp, resolveContainerClassName, } from "./containers"; +export type { DurableObjectContainerApp } from "./containers"; export function configFormat( configPath: string | undefined diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index ccfc38dc5ac..7e27cdbc053 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -7,7 +7,11 @@ import { getCloudflareEnv } from "../environment-variables/misc-variables"; import { UserError } from "../errors"; import { isDirectory } from "../fs-helpers"; import { isRedirectedRawConfig } from "./config-helpers"; -import { getContainerNameToClassNameMap } from "./containers"; +import { + CONTAINER_IMAGES_BINDING, + getContainerNameToClassNameMap, + getDurableObjectContainerApps, +} from "./containers"; import { Diagnostics } from "./diagnostics"; import { getDurableObjectExports } from "./durable-object-exports"; import { ARTIFACTS_EVENT_TYPES } from "./environment"; @@ -3565,6 +3569,21 @@ function validatePreviewsContainers( }); return (diagnostics, field, value, config) => { if (Array.isArray(value)) { + const durableObjectPolicyFields = [...value.entries()] + .filter( + ([, entry]) => + entry && + typeof entry === "object" && + entry.scheduling_policy === "durable_object" + ) + .map(([index]) => `"${field}[${index}].scheduling_policy"`); + if (durableObjectPolicyFields.length > 0) { + diagnostics.errors.push( + `${durableObjectPolicyFields.join(", ")} cannot be "durable_object". Durable Object-managed Containers are configured only in the top-level "containers" array.` + ); + return false; + } + const nameFields = [...value.entries()] .filter( ([, entry]) => entry && typeof entry === "object" && "name" in entry @@ -3605,6 +3624,98 @@ function validatePreviewsContainers( }; } +function validateDurableObjectContainerImages( + diagnostics: Diagnostics, + field: string, + images: unknown +): boolean { + if (images === undefined) { + return true; + } + + if ( + typeof images !== "object" || + images === null || + Array.isArray(images) || + Object.keys(images).length === 0 + ) { + diagnostics.errors.push( + `"${field}" must be a non-empty object when present.` + ); + return false; + } + + let valid = true; + const entries = Object.entries(images); + if (entries.length > 100) { + diagnostics.errors.push(`"${field}" must contain at most 100 images.`); + valid = false; + } + + for (const [imageName, imageValue] of entries) { + const imageField = `${field}.${imageName}`; + if (imageName.length === 0 || imageName.length > 128) { + diagnostics.errors.push( + `"${field}" image names must be between 1 and 128 characters.` + ); + valid = false; + } + if ( + typeof imageValue !== "object" || + imageValue === null || + Array.isArray(imageValue) + ) { + diagnostics.errors.push( + `"${imageField}" must be an object with either a "dockerfile" or "image" field.` + ); + valid = false; + continue; + } + + const image = imageValue as Record; + const hasDockerfile = image.dockerfile !== undefined; + const hasImage = image.image !== undefined; + if (hasDockerfile === hasImage) { + diagnostics.errors.push( + `"${imageField}" must specify exactly one of "dockerfile" or "image".` + ); + valid = false; + } + if ( + hasDockerfile && + (typeof image.dockerfile !== "string" || image.dockerfile.length === 0) + ) { + diagnostics.errors.push( + `"${imageField}.dockerfile" must be a non-empty string.` + ); + valid = false; + } + if ( + hasImage && + (typeof image.image !== "string" || image.image.length === 0) + ) { + diagnostics.errors.push( + `"${imageField}.image" must be a non-empty string.` + ); + valid = false; + } + + const unsupportedFields = Object.keys(image).filter( + (property) => property !== "dockerfile" && property !== "image" + ); + if (unsupportedFields.length > 0) { + diagnostics.errors.push( + `Unexpected fields found in ${imageField} field: ${unsupportedFields + .map((property) => `"${property}"`) + .join(", ")}` + ); + valid = false; + } + } + + return valid; +} + function validateContainerApp( envName: string, topLevelName: string | undefined, @@ -3625,13 +3736,28 @@ function validateContainerApp( } for (const containerAppOptional of value) { - validateOptionalProperty( - diagnostics, - field, - "class_name", - containerAppOptional.class_name, - "string" - ); + const isDurableObjectManaged = + containerAppOptional.scheduling_policy === "durable_object"; + const hasValidDurableObjectClassName = + typeof containerAppOptional.class_name === "string" && + containerAppOptional.class_name.length > 0; + + if (isDurableObjectManaged) { + if (!hasValidDurableObjectClassName) { + diagnostics.errors.push( + `"containers.class_name" must be a non-empty string when "containers.scheduling_policy" is "durable_object".` + ); + } + } else { + validateOptionalProperty( + diagnostics, + field, + "class_name", + containerAppOptional.class_name, + "string" + ); + } + validateOptionalProperty( diagnostics, field, @@ -3639,8 +3765,13 @@ function validateContainerApp( containerAppOptional.name, "string" ); + // try and add a default name - if (generateDefaultName && !containerAppOptional.name) { + if ( + generateDefaultName && + !containerAppOptional.name && + (!isDurableObjectManaged || hasValidDurableObjectClassName) + ) { // The default name is derived from the class name, so without one there // is nothing to derive it from. Such a container must be linked to a // Durable Object from the `exports` side, which references it by name. @@ -3663,6 +3794,22 @@ function validateContainerApp( containerAppOptional.name = name.toLowerCase().replace(/ /g, "-"); } } + + if (isDurableObjectManaged) { + validateDurableObjectContainerImages( + diagnostics, + `${field}.images`, + containerAppOptional.images + ); + validateAdditionalProperties( + diagnostics, + field, + Object.keys(containerAppOptional), + ["name", "class_name", "scheduling_policy", "images"] + ); + continue; + } + if ( !containerAppOptional.configuration?.image && !containerAppOptional.image @@ -4912,7 +5059,9 @@ const validateBindingsHaveUniqueNames = ( // Add secrets to binding name validation (secrets is not a CfWorkerInit binding type, // but we want to validate that secret names don't conflict with other bindings) bindingsGroupedByType["Secret"] = config.secrets?.required ?? []; - + if (getDurableObjectContainerApps(config.containers).length > 0) { + bindingsGroupedByType["Container images"] = [CONTAINER_IMAGES_BINDING]; + } const bindingsGroupedByName: Record = {}; for (const bindingType in bindingsGroupedByType) { @@ -7240,7 +7389,6 @@ function validateContainerExportLinks( const durableObjectExports = getDurableObjectExports(exports); const liveExportClassNames = new Set(); const classNamesByContainerName = new Map(); - for (const [className, entry] of Object.entries(durableObjectExports)) { if ( entry.state !== undefined && diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index 71b9d39ad55..0826d80cd3a 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -14,10 +14,14 @@ export { hasDurableObjectExports, } from "./config/durable-object-exports"; export { + CONTAINER_IMAGES_BINDING, getContainerDurableObjectClassNames, getContainerNameToClassNameMap, + getDurableObjectContainerApps, + isDurableObjectContainerApp, resolveContainerClassName, } from "./config/containers"; +export type { DurableObjectContainerApp } from "./config/containers"; export { type RedirectedRawConfig, defaultWranglerConfig, diff --git a/packages/workers-utils/src/print-bindings.ts b/packages/workers-utils/src/print-bindings.ts index 8508d15f7c2..139c0ccaa9a 100644 --- a/packages/workers-utils/src/print-bindings.ts +++ b/packages/workers-utils/src/print-bindings.ts @@ -951,7 +951,11 @@ export function printBindings( log( `${containersTitle}\n${containers - .map((c) => `- ${c.name} (${c.image})`) + .map((container) => + container.scheduling_policy === "durable_object" + ? `- ${container.class_name} (durable_object)` + : `- ${container.name} (${container.image})` + ) .join("\n")}` ); log(""); diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index f60fe548c98..148d5f9ccef 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -294,7 +294,11 @@ type WorkerMetadataPut = { observability?: Observability | undefined; // `class_name` is omitted when the container is instead referenced from the // Durable Object's `exports` entry via its `container` field. - containers?: { name?: string; class_name?: string }[]; + containers?: { + name?: string; + class_name?: string; + images?: Record; + }[]; package_dependencies?: Array<{ name: string; packageJsonVersion: string; diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 187f087fc0b..6b41436dfa4 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -495,7 +495,13 @@ export interface CfWorkerInit { * A container is linked to its Durable Object either by `class_name`, or by * the Durable Object's `exports` entry naming the container by `name`. */ - containers: { name?: string; class_name?: string }[] | undefined; + containers: + | { + name?: string; + class_name?: string; + images?: Record; + }[] + | undefined; migrations: CfDurableObjectMigrations | undefined; /** diff --git a/packages/workers-utils/tests/config/containers.test.ts b/packages/workers-utils/tests/config/containers.test.ts index de701291373..bc119b9f847 100644 --- a/packages/workers-utils/tests/config/containers.test.ts +++ b/packages/workers-utils/tests/config/containers.test.ts @@ -2,6 +2,8 @@ import { describe, test } from "vitest"; import { getContainerDurableObjectClassNames, getContainerNameToClassNameMap, + getDurableObjectContainerApps, + isDurableObjectContainerApp, resolveContainerClassName, } from "../../src/config/containers"; import type { ContainerApp, Exports } from "../../src/config/environment"; @@ -10,6 +12,27 @@ function container(props: Partial): ContainerApp { return { image: "./Dockerfile", ...props }; } +describe("Durable Object-managed containers", () => { + test("selects only durable_object entries", ({ expect }) => { + const containers: ContainerApp[] = [ + container({ class_name: "Scheduled" }), + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { + dockerfile: "./Dockerfile", + }, + }, + }, + ]; + + expect(isDurableObjectContainerApp(containers[0])).toBe(false); + expect(isDurableObjectContainerApp(containers[1])).toBe(true); + expect(getDurableObjectContainerApps(containers)).toEqual([containers[1]]); + }); +}); + describe("getContainerNameToClassNameMap", () => { test("returns an empty map when exports are undefined", ({ expect }) => { expect(getContainerNameToClassNameMap(undefined)).toEqual(new Map()); diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index af9810f37fa..ca736686484 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -4619,6 +4619,276 @@ describe("normalizeAndValidateConfig()", () => { } }); + it("should accept a Durable Object-managed container alongside migrations", ({ + expect, + }) => { + const { diagnostics, config } = normalizeAndValidateConfig( + { + name: "test-worker-name", + durable_objects: { + bindings: [ + { + name: "SANDBOX", + class_name: "Sandbox", + }, + ], + }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: ["Sandbox"], + }, + ], + containers: [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { + dockerfile: "./Dockerfile", + }, + tools: { + image: + "registry.cloudflare.com/account/tools@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + expect(config.migrations).toEqual([ + { + tag: "v1", + new_sqlite_classes: ["Sandbox"], + }, + ]); + expect(config.containers).toEqual([ + { + class_name: "Sandbox", + name: "test-worker-name-sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { + dockerfile: "./Dockerfile", + }, + tools: { + image: + "registry.cloudflare.com/account/tools@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + }, + ]); + }); + + it("should preserve an explicit Durable Object-managed container name without named images", ({ + expect, + }) => { + const { diagnostics, config } = normalizeAndValidateConfig( + { + containers: [ + { + class_name: "Sandbox", + name: "sandbox-app", + scheduling_policy: "durable_object", + }, + ], + } as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + expect(config.containers).toEqual([ + { + class_name: "Sandbox", + name: "sandbox-app", + scheduling_policy: "durable_object", + }, + ]); + }); + + it("should reserve the Durable Object-managed image binding name", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + containers: [ + { + class_name: "Sandbox", + name: "sandbox-app", + scheduling_policy: "durable_object", + }, + ], + vars: { + EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES: "user value", + }, + } as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + "EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES assigned to Environment Variable and Container images bindings" + ); + }); + + it("should append the environment to a generated Durable Object-managed container name", ({ + expect, + }) => { + const { diagnostics, config } = normalizeAndValidateConfig( + { + name: "test-worker-name", + env: { + staging: { + containers: [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + }, + ], + }, + }, + } as RawConfig, + undefined, + undefined, + { env: "staging" } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + expect(config.containers).toEqual([ + { + class_name: "Sandbox", + name: "test-worker-name-sandbox-staging", + scheduling_policy: "durable_object", + }, + ]); + }); + + it("should reject scheduler fields on a Durable Object-managed container", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + containers: [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + name: "sandboxes", + image: "./Dockerfile", + max_instances: 5, + }, + ], + } as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderWarnings()).toContain( + 'Unexpected fields found in containers field: "image","max_instances"' + ); + }); + + it("should require a class name for a Durable Object-managed container", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + containers: [ + { + scheduling_policy: "durable_object", + }, + ], + } as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toContain( + '"containers.class_name" must be a non-empty string when "containers.scheduling_policy" is "durable_object".' + ); + }); + + it("should reject malformed Durable Object-managed container images", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + containers: [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + missing: {}, + docker: { dockerfile: "" }, + remote: { image: "" }, + both: { + dockerfile: "./Dockerfile", + image: + "registry.cloudflare.com/account/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + const errors = diagnostics.renderErrors(); + expect(errors).toContain( + '"containers.images.missing" must specify exactly one of "dockerfile" or "image".' + ); + expect(errors).toContain( + '"containers.images.docker.dockerfile" must be a non-empty string.' + ); + expect(errors).toContain( + '"containers.images.remote.image" must be a non-empty string.' + ); + expect(errors).toContain( + '"containers.images.both" must specify exactly one of "dockerfile" or "image".' + ); + }); + + it("should reject named images on a scheduler-backed container", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "test-worker", + containers: [ + { + class_name: "Sandbox", + image: "./Dockerfile", + images: { + sandbox: { dockerfile: "./Dockerfile" }, + }, + }, + ], + } as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderWarnings()).toContain( + 'Unexpected fields found in containers field: "images"' + ); + }); + it("should provide a name in a named environment that inherits the top level worker name", ({ expect, }) => { diff --git a/packages/workers-utils/tests/print-bindings.test.ts b/packages/workers-utils/tests/print-bindings.test.ts index 39ad15f972d..9517b8069cb 100644 --- a/packages/workers-utils/tests/print-bindings.test.ts +++ b/packages/workers-utils/tests/print-bindings.test.ts @@ -46,4 +46,28 @@ describe("printBindings", () => { expect(output).toContain("local [connected]"); }); + + test("prints Durable Object-managed containers by class and policy", ({ + expect, + }) => { + const output = captureBindings( + {}, + { + containers: [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { dockerfile: "./container/Dockerfile" }, + }, + }, + ], + } + ); + + expect(output).toContain( + "The following containers are available:\n- Sandbox (durable_object)" + ); + expect(output).not.toContain("undefined"); + }); }); diff --git a/packages/wrangler/e2e/containers.dev.test.ts b/packages/wrangler/e2e/containers.dev.test.ts index b638a408942..d2e26ec7b07 100644 --- a/packages/wrangler/e2e/containers.dev.test.ts +++ b/packages/wrangler/e2e/containers.dev.test.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { setTimeout } from "node:timers/promises"; import { stripVTControlCharacters } from "node:util"; @@ -298,6 +299,141 @@ for (const source of imageSource) { await worker.stop(); }); + it.runIf(source === "build")( + "supports named Durable Object-managed Container images", + async ({ expect }) => { + const originalWorkerSource = readFileSync( + path.join(helper.tmpPath, "src/index.ts"), + "utf8" + ); + await helper.seed({ + "wrangler.json": JSON.stringify({ + name: workerName, + main: "src/index.ts", + compatibility_date: "2025-04-03", + compatibility_flags: ["enable_ctx_exports"], + containers: [ + { + name: `${workerName}-container`, + class_name: "E2EContainer", + scheduling_policy: "durable_object", + images: { + one: { dockerfile: "./container/one/Dockerfile" }, + two: { dockerfile: "./container/two/Dockerfile" }, + }, + }, + ], + durable_objects: { + bindings: [{ class_name: "E2EContainer", name: "CONTAINER" }], + }, + exports: { + E2EContainer: { + type: "durable-object", + storage: "sqlite", + container: `${workerName}-container`, + }, + }, + }), + "src/index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + + export class E2EContainer extends DurableObject { + async fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/start/")) { + const imageName = url.pathname.slice("/start/".length); + const image = this.env.EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES + .E2EContainer[imageName]; + if (image === undefined) { + return new Response("Unknown image", { status: 404 }); + } + this.ctx.container!.start({ + image, + entrypoint: ["node", "app.js"], + enableInternet: false, + }); + return new Response(image); + } + + if (url.pathname === "/fetch") { + return this.ctx.container!.getTcpPort(8080).fetch(request); + } + + return new Response("Not found", { status: 404 }); + } + } + + export default { + async fetch(request, env): Promise { + const url = new URL(request.url); + const id = env.CONTAINER.idFromName( + url.searchParams.get("instance") ?? "default" + ); + return env.CONTAINER.get(id).fetch(request); + }, + } satisfies ExportedHandler; + `, + "container/one/Dockerfile": dedent` + FROM node:22-alpine + WORKDIR /usr/src/app + COPY app.js app.js + EXPOSE 8080 + `, + "container/one/app.js": createImageServer("one"), + "container/two/Dockerfile": dedent` + FROM node:22-alpine + WORKDIR /usr/src/app + COPY app.js app.js + EXPOSE 8080 + `, + "container/two/app.js": createImageServer("two"), + }); + + const worker = helper.runLongLived("wrangler dev"); + try { + // A cold Docker cache can require downloading the base image before + // Wrangler starts the local server. + const ready = await worker.waitForReady(90_000); + + const oneStart = await fetch(`${ready.url}/start/one?instance=one`); + const oneImage = await oneStart.text(); + expect(oneStart.status).toBe(200); + expect(oneImage).toMatch( + /^cloudflare-dev\/e2econtainer-one-[a-f0-9]{12}:/ + ); + + const twoStart = await fetch(`${ready.url}/start/two?instance=two`); + const twoImage = await twoStart.text(); + expect(twoStart.status).toBe(200); + expect(twoImage).toMatch( + /^cloudflare-dev\/e2econtainer-two-[a-f0-9]{12}:/ + ); + expect(twoImage).not.toBe(oneImage); + + await waitForLong(async () => { + const response = await fetch(`${ready.url}/fetch?instance=one`, { + signal: AbortSignal.timeout(5_000), + headers: { "MF-Disable-Pretty-Error": "true" }, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("one"); + }); + + await waitForLong(async () => { + const response = await fetch(`${ready.url}/fetch?instance=two`, { + signal: AbortSignal.timeout(5_000), + headers: { "MF-Disable-Pretty-Error": "true" }, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("two"); + }); + } finally { + await worker.stop(); + await helper.seed({ "src/index.ts": originalWorkerSource }); + } + } + ); + it("should clean up duplicate image tags after build", async ({ expect, }) => { @@ -601,3 +737,12 @@ const getContainerBuildIds = (dockerPath: string, className: string) => { ); return new Set(output.split("\n").filter((line) => line.trim())); }; + +const createImageServer = (imageName: string) => dedent` + const { createServer } = require("http"); + + createServer(function (_req, res) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(${JSON.stringify(imageName)}); + }).listen(8080); +`; diff --git a/packages/wrangler/src/__tests__/config-schema.test.ts b/packages/wrangler/src/__tests__/config-schema.test.ts index 89ac591802f..0e2427d9103 100644 --- a/packages/wrangler/src/__tests__/config-schema.test.ts +++ b/packages/wrangler/src/__tests__/config-schema.test.ts @@ -6,6 +6,24 @@ type WranglerSchema = { $ref?: string; allOf?: { $ref: string }[]; allowTrailingCommas?: boolean; + definitions?: { + ContainerApp?: { + properties?: { + images?: { + additionalProperties?: { $ref?: string }; + }; + scheduling_policy?: { + enum?: string[]; + }; + }; + }; + DurableObjectContainerImage?: { + anyOf?: { + properties?: Record; + required?: string[]; + }[]; + }; + }; }; function readSchema(): WranglerSchema { @@ -26,4 +44,23 @@ describe("config schema", () => { expect(schema).not.toHaveProperty("$ref"); expect(schema.allOf).toEqual([{ $ref: "#/definitions/RawConfig" }]); }); + + it("includes Durable Object-managed container configuration", ({ + expect, + }) => { + const schema = readSchema(); + const container = schema.definitions?.ContainerApp; + const image = schema.definitions?.DurableObjectContainerImage; + + expect(container?.properties?.scheduling_policy?.enum).toContain( + "durable_object" + ); + expect(container?.properties?.images?.additionalProperties?.$ref).toBe( + "#/definitions/DurableObjectContainerImage" + ); + expect(image?.anyOf?.map((variant) => variant.required)).toEqual([ + ["dockerfile"], + ["image"], + ]); + }); }); diff --git a/packages/wrangler/src/__tests__/containers/config.test.ts b/packages/wrangler/src/__tests__/containers/config.test.ts index 22f90f9148e..0cce973d9de 100644 --- a/packages/wrangler/src/__tests__/containers/config.test.ts +++ b/packages/wrangler/src/__tests__/containers/config.test.ts @@ -4,7 +4,15 @@ import { getCloudflareContainerRegistry } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { beforeEach, describe, it, vi } from "vitest"; -import { getNormalizedContainerOptions } from "../../containers/config"; +import { + getNormalizedContainerOptions, + getNormalizedContainerOptionsForDev, +} from "../../containers/config"; +import { + addDurableObjectContainerImagesBinding, + getContainerDevOptions, + getDurableObjectContainerDefaultImageNames, +} from "../../containers/dev"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import type { Config } from "@cloudflare/workers-utils"; @@ -81,6 +89,35 @@ describe("getNormalizedContainerOptions", () => { ); }); + it("should throw early when a Durable Object-managed container class is missing", async ({ + expect, + }) => { + const config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + class_name: "MissingContainer", + name: "missing-container", + scheduling_policy: "durable_object", + images: {}, + }, + ], + durable_objects: { + bindings: [], + }, + migrations: [], + } as Partial as Config; + + await expect( + getNormalizedContainerOptions(config, {}) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The container class_name MissingContainer does not match any durable object class_name defined in your Wrangler config file. Note that the durable object must be defined in the same script as the container.]` + ); + }); + it("should resolve class_name from a durable object export that references the container by name", async ({ expect, }) => { @@ -183,6 +220,75 @@ describe("getNormalizedContainerOptions", () => { ); }); + it("should throw early when a Durable Object-managed container uses an external class", async ({ + expect, + }) => { + const config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + class_name: "ExternalContainer", + name: "external-container", + scheduling_policy: "durable_object", + images: {}, + }, + ], + durable_objects: { + bindings: [ + { + name: "EXTERNAL_DO", + class_name: "ExternalContainer", + script_name: "other-worker", + }, + ], + }, + migrations: [], + } as Partial as Config; + + await expect( + getNormalizedContainerOptions(config, {}) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The container external-container is referencing the durable object ExternalContainer, which appears to be defined on the other-worker Worker instead (via the 'script_name' field). You cannot configure a container on a Durable Object that is defined in another Worker.]` + ); + }); + + it("should throw early when a Durable Object-managed container uses legacy storage", async ({ + expect, + }) => { + const config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + class_name: "LegacyContainer", + name: "legacy-container", + scheduling_policy: "durable_object", + images: {}, + }, + ], + durable_objects: { + bindings: [ + { + name: "LEGACY_DO", + class_name: "LegacyContainer", + }, + ], + }, + migrations: [{ tag: "v1", new_classes: ["LegacyContainer"] }], + } as Partial as Config; + + await expect( + getNormalizedContainerOptions(config, {}) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The container legacy-container references Durable Object class LegacyContainer, which uses the legacy KV storage backend. Durable Object-managed Containers require SQLite-backed Durable Objects.]` + ); + }); + it("should normalize and set defaults for container with dockerfile", async ({ expect, }) => { @@ -1013,4 +1119,176 @@ describe("getNormalizedContainerOptions", () => { }, ]); }); + + it("normalizes named Durable Object Container images for local development", async ({ + expect, + }) => { + mkdirSync("containers/minimal", { recursive: true }); + writeFileSync("containers/minimal/Dockerfile", "FROM scratch"); + const config = { + name: "test-worker", + configPath: path.join(process.cwd(), "wrangler.jsonc"), + userConfigPath: path.join(process.cwd(), "wrangler.jsonc"), + topLevelName: "test-worker", + containers: [ + { + class_name: "Sandbox", + name: "sandbox", + scheduling_policy: "durable_object", + images: { + minimal: { dockerfile: "./containers/minimal/Dockerfile" }, + toolbox: { image: "docker.io/library/alpine:latest" }, + }, + }, + ], + durable_objects: { + bindings: [{ name: "SANDBOX", class_name: "Sandbox" }], + }, + exports: { + Sandbox: { type: "durable-object", storage: "sqlite" }, + }, + migrations: [], + } as Partial as Config; + + await expect(getNormalizedContainerOptionsForDev(config)).resolves.toEqual([ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + image_name: "minimal", + dockerfile: path.join(process.cwd(), "containers/minimal/Dockerfile"), + image_build_context: path.join(process.cwd(), "containers/minimal"), + }, + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + image_name: "toolbox", + image_uri: "docker.io/library/alpine:latest", + }, + ]); + }); + + it("keeps an image-less Durable Object Container attached in local development", async ({ + expect, + }) => { + const config = { + name: "test-worker", + configPath: path.join(process.cwd(), "wrangler.jsonc"), + userConfigPath: path.join(process.cwd(), "wrangler.jsonc"), + topLevelName: "test-worker", + containers: [ + { + class_name: "Sandbox", + name: "sandbox", + scheduling_policy: "durable_object", + }, + ], + durable_objects: { + bindings: [{ name: "SANDBOX", class_name: "Sandbox" }], + }, + exports: { + Sandbox: { type: "durable-object", storage: "sqlite" }, + }, + migrations: [], + } as Partial as Config; + + await expect(getNormalizedContainerOptionsForDev(config)).resolves.toEqual([ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + }, + ]); + }); + + it("builds distinct local tags and exposes the named image binding", ({ + expect, + }) => { + const containers = [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object" as const, + image_name: "minimal", + dockerfile: "/minimal/Dockerfile", + image_build_context: "/minimal", + }, + { + class_name: "Sandbox", + scheduling_policy: "durable_object" as const, + image_name: "toolbox", + image_uri: "docker.io/library/alpine:latest", + }, + ]; + const bindings = {}; + const devOptions = getContainerDevOptions(containers, "build-id"); + const minimalImage = devOptions[0].image_tag; + const toolboxImage = devOptions[1].image_tag; + + addDurableObjectContainerImagesBinding(bindings, containers, "build-id"); + expect(bindings).toEqual({ + EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES: { + type: "json", + value: { + Sandbox: { + minimal: minimalImage, + toolbox: toolboxImage, + }, + }, + }, + }); + expect(minimalImage).toMatch( + /^cloudflare-dev\/sandbox-minimal-[a-f0-9]{12}:build-id$/ + ); + expect(toolboxImage).toMatch( + /^cloudflare-dev\/sandbox-toolbox-[a-f0-9]{12}:build-id$/ + ); + expect(devOptions).toEqual([ + { + class_name: "Sandbox", + image_name: "minimal", + image_tag: minimalImage, + dockerfile: "/minimal/Dockerfile", + image_build_context: "/minimal", + image_vars: undefined, + }, + { + class_name: "Sandbox", + image_name: "toolbox", + image_tag: toolboxImage, + image_uri: "docker.io/library/alpine:latest", + }, + ]); + expect( + getDurableObjectContainerDefaultImageNames(containers, "build-id") + ).toEqual(new Map([["Sandbox", minimalImage]])); + }); + + it("keeps local tags unique when named images have colliding slugs", ({ + expect, + }) => { + const containers = ["Foo", "foo", "a/b", "a-b"].map((imageName) => ({ + class_name: "Sandbox", + scheduling_policy: "durable_object" as const, + image_name: imageName, + image_uri: `example.com/${encodeURIComponent(imageName)}:latest`, + })); + const first = getContainerDevOptions(containers, "build-id"); + const second = getContainerDevOptions(containers, "build-id"); + const tags = first.map(({ image_tag }) => image_tag); + + expect(new Set(tags).size).toBe(containers.length); + expect(second.map(({ image_tag }) => image_tag)).toEqual(tags); + expect(tags).toEqual([ + expect.stringMatching( + /^cloudflare-dev\/sandbox-foo-[a-f0-9]{12}:build-id$/ + ), + expect.stringMatching( + /^cloudflare-dev\/sandbox-foo-[a-f0-9]{12}:build-id$/ + ), + expect.stringMatching( + /^cloudflare-dev\/sandbox-a-b-[a-f0-9]{12}:build-id$/ + ), + expect.stringMatching( + /^cloudflare-dev\/sandbox-a-b-[a-f0-9]{12}:build-id$/ + ), + ]); + }); }); diff --git a/packages/wrangler/src/__tests__/containers/deploy.test.ts b/packages/wrangler/src/__tests__/containers/deploy.test.ts index 7077201bbc3..a9b3f0c7c48 100644 --- a/packages/wrangler/src/__tests__/containers/deploy.test.ts +++ b/packages/wrangler/src/__tests__/containers/deploy.test.ts @@ -3,11 +3,13 @@ import * as fs from "node:fs"; import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import { + ContainerImagePreparationStatus, getCloudflareContainerRegistry, InstanceType, SchedulingPolicy, } from "@cloudflare/containers-shared"; import { ApplicationAffinityHardwareGeneration } from "@cloudflare/containers-shared/src/client/models/ApplicationAffinityHardwareGeneration"; +import { CONTAINER_IMAGES_BINDING } from "@cloudflare/workers-utils"; import { runInTempDir, writeWranglerConfig, @@ -15,6 +17,7 @@ import { import { http, HttpResponse } from "msw"; import { afterEach, assert, beforeEach, describe, it, vi } from "vitest"; import { clearCachedAccount } from "../../cloudchamber/locations"; +import { deployDurableObjectContainerApplications } from "../../containers/durable-object-applications"; import * as user from "../../user"; import { mockAccountV4 as mockContainersAccount } from "../cloudchamber/utils"; import { mockServiceScriptData } from "../deploy/helpers"; @@ -35,6 +38,7 @@ import type { AccountRegistryToken, Application, CreateApplicationRequest, + CreateDurableObjectApplicationRequest, ImageRegistryCredentialsConfiguration, } from "@cloudflare/containers-shared"; import type { ChildProcess } from "node:child_process"; @@ -59,6 +63,17 @@ describe("wrangler deploy with containers", () => { afterEach(() => { vi.unstubAllEnvs(); }); + + it("should upload an empty container list when explicitly configured", async () => { + writeWranglerConfig({ containers: [] }); + mockUploadWorkerRequest({ + expectedContainers: [], + useOldUploadApi: true, + }); + + await runWrangler("deploy index.js"); + }); + it("should fail early if no docker is detected when deploying a container from a dockerfile", async ({ expect, }) => { @@ -111,6 +126,594 @@ describe("wrangler deploy with containers", () => { Current account: "some-account-id"] `); }); + it("should deploy a Durable Object-managed container without scheduler lookups or rollouts", async ({ + expect, + }) => { + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + const image = + "registry.cloudflare.com/some-account-id/tools@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + mockGetVersion("Galaxy-Class", [ + { + ...defaultDOBinding, + namespace_id: namespaceId, + }, + ]); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + class_name: "ExampleDurableObject", + scheduling_policy: "durable_object", + images: { + tools: { image }, + }, + }, + ], + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "EXAMPLE_DO_BINDING", + type: "durable_object_namespace", + }, + { + json: { + ExampleDurableObject: { + tools: image, + }, + }, + name: CONTAINER_IMAGES_BINDING, + type: "json", + }, + ], + expectedContainers: [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + images: { tools: image }, + }, + ], + expectedExports: undefined, + useOldUploadApi: true, + }); + + const applicationRequests: unknown[] = []; + const preparationRequests: string[] = []; + let listRequests = 0; + let modifyRequests = 0; + let rolloutRequests = 0; + msw.use( + http.post("*/image-preparations", async ({ request }) => { + const body = (await request.json()) as { image: string }; + preparationRequests.push(body.image); + return HttpResponse.json( + createFetchResult({ + image: body.image, + status: ContainerImagePreparationStatus.READY, + }) + ); + }), + http.get("*/applications", () => { + listRequests++; + return HttpResponse.json(createFetchResult([])); + }), + http.patch("*/applications/:applicationId", () => { + modifyRequests++; + return HttpResponse.json(createFetchResult({})); + }), + http.post("*/applications/:applicationId/rollouts", () => { + rolloutRequests++; + return HttpResponse.json(createFetchResult({})); + }), + http.post("*/applications", async ({ request }) => { + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + + await runWrangler("deploy index.js"); + + expect(preparationRequests).toEqual([image]); + expect(applicationRequests).toEqual([ + expectedDurableObjectApplicationRequest( + "test-name-exampledurableobject", + namespaceId + ), + ]); + expect(listRequests).toBe(0); + expect(modifyRequests).toBe(0); + expect(rolloutRequests).toBe(0); + expect(spawn).not.toHaveBeenCalled(); + }); + it("should reuse the idempotent application create contract on repeat deployments", async ({ + expect, + }) => { + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + const config = { + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + name: "managed-app", + class_name: "ExampleDurableObject", + scheduling_policy: "durable_object", + images: {}, + }, + ], + } as Parameters[0]; + mockGetVersion("Galaxy-Class", [ + { + ...defaultDOBinding, + namespace_id: namespaceId, + }, + ]); + + const applicationRequests: unknown[] = []; + msw.use( + http.post("*/applications", async ({ request }) => { + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + + const args = { + versionId: "Galaxy-Class", + accountId: "some-account-id", + scriptName: "test-name", + }; + await deployDurableObjectContainerApplications(config, args); + await deployDurableObjectContainerApplications(config, args); + + expect(applicationRequests).toEqual([ + expectedDurableObjectApplicationRequest("managed-app", namespaceId), + expectedDurableObjectApplicationRequest("managed-app", namespaceId), + ]); + }); + it("should preserve Durable Object-managed container images when --containers-rollout=none", async ({ + expect, + }) => { + const configuredImage = + "registry.cloudflare.com/some-account-id/tools@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const deployedImage = + "registry.cloudflare.com/some-account-id/tools@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + mockGetVersion( + "test-name:version:0", + [ + defaultDOBinding, + { + name: CONTAINER_IMAGES_BINDING, + type: "json", + json: { + ExampleDurableObject: { tools: deployedImage }, + }, + }, + ], + [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + images: { tools: deployedImage }, + }, + ] + ); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + class_name: "ExampleDurableObject", + scheduling_policy: "durable_object", + images: { + tools: { image: configuredImage }, + }, + }, + ], + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "EXAMPLE_DO_BINDING", + type: "durable_object_namespace", + }, + { + name: CONTAINER_IMAGES_BINDING, + type: "inherit", + }, + ], + expectedBindingsInherit: "strict", + expectedContainers: [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + images: { tools: deployedImage }, + }, + ], + useOldUploadApi: true, + }); + + let preparationRequests = 0; + let applicationRequests = 0; + msw.use( + http.post("*/image-preparations", () => { + preparationRequests++; + return HttpResponse.json(createFetchResult({})); + }), + http.post("*/applications", () => { + applicationRequests++; + return HttpResponse.json(createFetchResult({})); + }) + ); + + await runWrangler("deploy index.js --containers-rollout=none"); + + expect(preparationRequests).toBe(0); + expect(applicationRequests).toBe(0); + expect(spawn).not.toHaveBeenCalled(); + }); + it("should preserve deployed managed containers when local configuration is empty and rollout is disabled", async ({ + expect, + }) => { + const deployedImage = + "registry.cloudflare.com/some-account-id/tools@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + mockGetVersion( + "test-name:version:0", + [ + defaultDOBinding, + { + name: CONTAINER_IMAGES_BINDING, + type: "json", + json: { + ExampleDurableObject: { tools: deployedImage }, + }, + }, + ], + [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + images: { tools: deployedImage }, + }, + ] + ); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [], + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "EXAMPLE_DO_BINDING", + type: "durable_object_namespace", + }, + { + name: CONTAINER_IMAGES_BINDING, + type: "inherit", + }, + ], + expectedBindingsInherit: "strict", + expectedContainers: [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + images: { tools: deployedImage }, + }, + ], + useOldUploadApi: true, + }); + + let preparationRequests = 0; + let applicationRequests = 0; + msw.use( + http.post("*/image-preparations", () => { + preparationRequests++; + return HttpResponse.json(createFetchResult({})); + }), + http.post("*/applications", () => { + applicationRequests++; + return HttpResponse.json(createFetchResult({})); + }) + ); + + await runWrangler("deploy index.js --containers-rollout=none"); + + expect(preparationRequests).toBe(0); + expect(applicationRequests).toBe(0); + expect(spawn).not.toHaveBeenCalled(); + }); + it("should preserve mixed scheduler and Durable Object-managed metadata when --containers-rollout=none", async ({ + expect, + }) => { + const configuredImage = + "registry.cloudflare.com/some-account-id/tools@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const deployedImage = + "registry.cloudflare.com/some-account-id/tools@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + mockGetVersion( + "test-name:version:0", + [ + { + name: CONTAINER_IMAGES_BINDING, + type: "json", + json: { + ManagedDurableObject: { tools: deployedImage }, + }, + }, + ], + [ + { + name: "scheduler-app", + class_name: "SchedulerDurableObject", + }, + { + name: "managed-app", + class_name: "ManagedDurableObject", + images: { tools: deployedImage }, + }, + ] + ); + fs.writeFileSync("./Dockerfile", "FROM scratch"); + fs.writeFileSync( + "index.js", + `export class SchedulerDurableObject {}; export class ManagedDurableObject {}; export default{};` + ); + writeWranglerConfig({ + durable_objects: { + bindings: [ + { + name: "SCHEDULER", + class_name: "SchedulerDurableObject", + }, + { + name: "MANAGED", + class_name: "ManagedDurableObject", + }, + ], + }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: [ + "SchedulerDurableObject", + "ManagedDurableObject", + ], + }, + ], + containers: [ + { + name: "scheduler-app", + class_name: "SchedulerDurableObject", + scheduling_policy: "default", + image: "./Dockerfile", + }, + { + name: "managed-app", + class_name: "ManagedDurableObject", + scheduling_policy: "durable_object", + images: { + tools: { image: configuredImage }, + }, + }, + ], + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "SchedulerDurableObject", + name: "SCHEDULER", + type: "durable_object_namespace", + }, + { + class_name: "ManagedDurableObject", + name: "MANAGED", + type: "durable_object_namespace", + }, + { + name: CONTAINER_IMAGES_BINDING, + type: "inherit", + }, + ], + expectedBindingsInherit: "strict", + expectedContainers: [ + { + name: "scheduler-app", + class_name: "SchedulerDurableObject", + }, + { + name: "managed-app", + class_name: "ManagedDurableObject", + images: { tools: deployedImage }, + }, + ], + useOldUploadApi: true, + }); + + let preparationRequests = 0; + let applicationRequests = 0; + msw.use( + http.post("*/image-preparations", () => { + preparationRequests++; + return HttpResponse.json(createFetchResult({})); + }), + http.post("*/applications", () => { + applicationRequests++; + return HttpResponse.json(createFetchResult({})); + }) + ); + + await runWrangler("deploy index.js --containers-rollout=none"); + + expect(preparationRequests).toBe(0); + expect(applicationRequests).toBe(0); + expect(spawn).not.toHaveBeenCalled(); + }); + it("should reject legacy-storage Durable Object-managed containers before upload", async ({ + expect, + }) => { + writeWranglerConfig({ + durable_objects: { + bindings: [ + { + name: "EXAMPLE_DO_BINDING", + class_name: "ExampleDurableObject", + }, + ], + }, + migrations: [{ tag: "v1", new_classes: ["ExampleDurableObject"] }], + containers: [ + { + class_name: "ExampleDurableObject", + scheduling_policy: "durable_object", + }, + ], + }); + + let preparationRequests = 0; + let uploadRequests = 0; + msw.use( + http.post("*/image-preparations", () => { + preparationRequests++; + return HttpResponse.json(createFetchResult({})); + }), + http.put("*/accounts/:accountId/workers/scripts/:scriptName", () => { + uploadRequests++; + return HttpResponse.json(createFetchResult({})); + }) + ); + + await expect(runWrangler("deploy index.js")).rejects.toThrow( + "Durable Object-managed Containers require SQLite-backed Durable Objects." + ); + expect(preparationRequests).toBe(0); + expect(uploadRequests).toBe(0); + }); + it("should deploy scheduler-backed and Durable Object-managed containers together", async ({ + expect, + }) => { + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + const image = + "registry.cloudflare.com/some-account-id/tools@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const durableObjectBinding = { + type: "durable_object_namespace", + namespace_id: namespaceId, + class_name: "ManagedDurableObject", + }; + mockGetVersion("Galaxy-Class", [defaultDOBinding, durableObjectBinding]); + fs.writeFileSync( + "index.js", + `export class ExampleDurableObject {}; export class ManagedDurableObject {}; export default{};` + ); + writeWranglerConfig({ + durable_objects: { + bindings: [ + { + name: "SCHEDULED", + class_name: "ExampleDurableObject", + }, + { + name: "MANAGED", + class_name: "ManagedDurableObject", + }, + ], + }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: ["ExampleDurableObject", "ManagedDurableObject"], + }, + ], + containers: [ + { + name: "scheduler-app", + class_name: "ExampleDurableObject", + image: "registry.cloudflare.com/hello:world", + scheduling_policy: "default", + }, + { + name: "managed-app", + class_name: "ManagedDurableObject", + scheduling_policy: "durable_object", + images: { + tools: { image }, + }, + }, + ], + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "SCHEDULED", + type: "durable_object_namespace", + }, + { + class_name: "ManagedDurableObject", + name: "MANAGED", + type: "durable_object_namespace", + }, + { + json: { + ManagedDurableObject: { + tools: image, + }, + }, + name: CONTAINER_IMAGES_BINDING, + type: "json", + }, + ], + expectedContainers: [ + { + name: "scheduler-app", + class_name: "ExampleDurableObject", + }, + { + name: "managed-app", + class_name: "ManagedDurableObject", + images: { tools: image }, + }, + ], + expectedExports: undefined, + useOldUploadApi: true, + }); + + const applicationRequests: unknown[] = []; + const preparationRequests: string[] = []; + msw.use( + http.post("*/image-preparations", async ({ request }) => { + const body = (await request.json()) as { image: string }; + preparationRequests.push(body.image); + return HttpResponse.json( + createFetchResult({ + image: body.image, + status: ContainerImagePreparationStatus.READY, + }) + ); + }), + http.get("*/applications", () => + HttpResponse.json(createFetchResult([])) + ), + http.post("*/applications", async ({ request }) => { + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + + await runWrangler("deploy index.js"); + + expect(preparationRequests).toEqual([image]); + expect(applicationRequests).toHaveLength(2); + expect(applicationRequests[0]).toMatchObject({ + name: "scheduler-app", + scheduling_policy: SchedulingPolicy.DEFAULT, + durable_objects: { namespace_id: "1" }, + }); + expect(applicationRequests[1]).toEqual( + expectedDurableObjectApplicationRequest("managed-app", namespaceId) + ); + expect(spawn).not.toHaveBeenCalled(); + }); it("should be able to deploy a new container from a dockerfile", async ({ expect, }) => { @@ -1217,9 +1820,27 @@ describe("wrangler deploy with containers", () => { fs.writeFileSync("./Dockerfile", "FROM scratch"); - mockGetVersion("Galaxy-Class"); mockGetApplications([]); mockCreateApplication(expect); + mockGetVersion( + "test-name:version:0", + [defaultDOBinding], + [{ name: "my-container", class_name: "ExampleDurableObject" }] + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "EXAMPLE_DO_BINDING", + type: "durable_object_namespace", + }, + ], + expectedBindingsInherit: "strict", + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], + useOldUploadApi: true, + }); fs.writeFileSync( "index.js", @@ -2430,6 +3051,78 @@ describe("wrangler deploy with containers", () => { `); }); + it("should find a durable object namespace after the first page", async ({ + expect, + }) => { + writeWranglerConfig({ + migrations: [ + { tag: "v1", new_sqlite_classes: ["ExampleDurableObject"] }, + ], + containers: [DEFAULT_CONTAINER_FROM_REGISTRY], + }); + + const requestedPages: string[] = []; + msw.use( + http.get( + "*/accounts/:accountId/workers/durable_objects/namespaces", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("per_page")).toBe("1000"); + const page = url.searchParams.get("page") ?? "1"; + requestedPages.push(page); + + const durableObjects = + page === "1" + ? Array.from({ length: 1000 }, (_, index) => ({ + id: `other-id-${index}`, + name: `other-name-${index}`, + script: "other-script", + class: "OtherDurableObject", + useSqlite: true, + })) + : [ + { + id: "target-id", + name: "target-name", + script: "test-name", + class: "ExampleDurableObject", + useSqlite: true, + }, + ]; + + return HttpResponse.json( + createFetchResult(durableObjects, true, [], [], { + page: Number(page), + per_page: 1000, + count: durableObjects.length, + total_count: 1001, + }) + ); + } + ) + ); + mockGetApplications([]); + mockUploadWorkerRequest({ + expectedBindings: [], + useOldUploadApi: true, + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], + }); + mockCreateApplication(expect, { + name: "my-container", + max_instances: 10, + scheduling_policy: SchedulingPolicy.DEFAULT, + durable_objects: { + namespace_id: "target-id", + }, + }); + + await runWrangler("deploy index.js"); + + expect(requestedPages).toEqual(["1", "2"]); + }); + it("should be able to deploy a container referenced from a declarative durable object export", async ({ expect, }) => { @@ -2818,12 +3511,24 @@ describe("wrangler deploy with containers and dispatch namespace", () => { { once: false } ) ); - mockSubDomainRequest(); + mockContainersAccount(); + fs.writeFileSync( + "index.js", + `export class ExampleDurableObject {}; export default{};` + ); + vi.stubEnv("WRANGLER_DOCKER_BIN", "/usr/bin/docker"); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("should deploy containers when using --dispatch-namespace", async ({ + expect, + }) => { mockServiceScriptData({ script: { id: "test-name", migration_tag: "v1" }, dispatchNamespace: "test-namespace", }); - mockContainersAccount(); mockUploadWorkerRequest({ expectedBindings: [ { @@ -2838,19 +3543,6 @@ describe("wrangler deploy with containers and dispatch namespace", () => { { name: "my-container", class_name: "ExampleDurableObject" }, ], }); - fs.writeFileSync( - "index.js", - `export class ExampleDurableObject {}; export default{};` - ); - vi.stubEnv("WRANGLER_DOCKER_BIN", "/usr/bin/docker"); - }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("should deploy containers when using --dispatch-namespace", async ({ - expect, - }) => { mockGetVersion("Galaxy-Class"); writeWranglerConfig({ ...DEFAULT_DURABLE_OBJECTS, @@ -2875,6 +3567,124 @@ describe("wrangler deploy with containers and dispatch namespace", () => { expect(cliStd.stdout).toContain("my-container"); expect(std.err).toMatchInlineSnapshot(`""`); }); + + it("should inherit Durable Object-managed images for an existing dispatch script when rollout is disabled", async ({ + expect, + }) => { + const deployedImage = + "registry.cloudflare.com/some-account-id/app@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + mockServiceScriptData({ + script: { id: "test-name", migration_tag: "v1" }, + dispatchNamespace: "test-namespace", + }); + msw.use( + http.get( + "*/accounts/:accountId/workers/dispatch/namespaces/:dispatchNamespace/scripts/:scriptName/bindings", + () => + HttpResponse.json( + createFetchResult([ + { + name: CONTAINER_IMAGES_BINDING, + type: "json", + json: JSON.stringify({ + ExampleDurableObject: { app: deployedImage }, + }), + }, + ]) + ) + ) + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "EXAMPLE_DO_BINDING", + type: "durable_object_namespace", + }, + { + name: CONTAINER_IMAGES_BINDING, + type: "inherit", + }, + ], + expectedBindingsInherit: "strict", + expectedContainers: [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + images: { app: deployedImage }, + }, + ], + useOldUploadApi: true, + expectedDispatchNamespace: "test-namespace", + }); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + class_name: "ExampleDurableObject", + scheduling_policy: "durable_object", + images: { + app: { + image: + "registry.cloudflare.com/some-account-id/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + }, + ], + }); + + await runWrangler( + "deploy index.js --dispatch-namespace test-namespace --containers-rollout=none" + ); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it("should omit Durable Object-managed images for a new dispatch script when rollout is disabled", async ({ + expect, + }) => { + mockServiceScriptData({ + dispatchNamespace: "test-namespace", + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { + class_name: "ExampleDurableObject", + name: "EXAMPLE_DO_BINDING", + type: "durable_object_namespace", + }, + ], + expectedContainers: [ + { + name: "test-name-exampledurableobject", + class_name: "ExampleDurableObject", + }, + ], + useOldUploadApi: true, + expectedDispatchNamespace: "test-namespace", + }); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + class_name: "ExampleDurableObject", + scheduling_policy: "durable_object", + images: { + app: { + image: + "registry.cloudflare.com/some-account-id/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + }, + ], + }); + + await runWrangler( + "deploy index.js --dispatch-namespace test-namespace --containers-rollout=none" + ); + + expect(spawn).not.toHaveBeenCalled(); + }); }); describe("containers.unsafe configuration", () => { @@ -3140,11 +3950,32 @@ const defaultDOBinding = { namespace_id: "1", class_name: "ExampleDurableObject", }; -function mockGetVersion(versionId: string, bindings = [defaultDOBinding]) { + +function expectedDurableObjectApplicationRequest( + name: string, + namespaceId: string +): CreateDurableObjectApplicationRequest { + return { + name, + scheduling_policy: SchedulingPolicy.DURABLE_OBJECT, + durable_objects: { namespace_id: namespaceId }, + }; +} + +function mockGetVersion( + versionId: string, + bindings: Record[] = [defaultDOBinding], + containers?: { + name?: string; + class_name?: string; + images?: Record; + }[] +) { msw.use( http.get( - `*/accounts/:accountId/workers/scripts/:scriptName/versions/${versionId}`, - async () => { + "*/accounts/:accountId/workers/scripts/:scriptName/versions/:versionId", + async ({ params }) => { + assert(params.versionId === versionId); return HttpResponse.json( createFetchResult({ id: versionId, @@ -3152,6 +3983,9 @@ function mockGetVersion(versionId: string, bindings = [defaultDOBinding]) { number: 2, resources: { bindings, + script_runtime: { + containers, + }, }, }) ); diff --git a/packages/wrangler/src/__tests__/containers/schema.test.ts b/packages/wrangler/src/__tests__/containers/schema.test.ts index ab34868d7bc..74519bf189e 100644 --- a/packages/wrangler/src/__tests__/containers/schema.test.ts +++ b/packages/wrangler/src/__tests__/containers/schema.test.ts @@ -48,7 +48,7 @@ describe("config schema", () => { expect(schema.definitions.ContainerApp.properties).toHaveProperty( "class_name" ); - expect(schema.definitions.ContainerApp.required).not.toContain( + expect(schema.definitions.ContainerApp.required ?? []).not.toContain( "class_name" ); }); diff --git a/packages/wrangler/src/__tests__/deploy/assets.test.ts b/packages/wrangler/src/__tests__/deploy/assets.test.ts index 791a0fa647f..e9c4975d8d5 100644 --- a/packages/wrangler/src/__tests__/deploy/assets.test.ts +++ b/packages/wrangler/src/__tests__/deploy/assets.test.ts @@ -27,6 +27,7 @@ import { mockDeploymentsListRequest, mockLastDeploymentRequest, mockPatchScriptSettings, + mockServiceScriptData, writeAssets, } from "./helpers"; import type { AssetManifest } from "../../assets"; @@ -1536,6 +1537,10 @@ describe("deploy", () => { expectedMainModule: undefined, expectedDispatchNamespace: "my-namespace", }); + mockServiceScriptData({ + script: { id: "test-name" }, + dispatchNamespace: "my-namespace", + }); await runWrangler("deploy --dispatch-namespace my-namespace"); }); diff --git a/packages/wrangler/src/__tests__/deploy/environments.test.ts b/packages/wrangler/src/__tests__/deploy/environments.test.ts index 7b863ad3d1d..31a2f42c59c 100644 --- a/packages/wrangler/src/__tests__/deploy/environments.test.ts +++ b/packages/wrangler/src/__tests__/deploy/environments.test.ts @@ -32,6 +32,7 @@ import { mockGetScriptWithTags, mockLastDeploymentRequest, mockPatchScriptSettings, + mockServiceScriptData, } from "./helpers"; vi.mock("command-exists"); @@ -244,6 +245,10 @@ describe("deploy", () => { expectedMainModule: "index.js", expectedDispatchNamespace: "test-dispatch-namespace", }); + mockServiceScriptData({ + script: { id: "test-name" }, + dispatchNamespace: "test-dispatch-namespace", + }); await runWrangler( "deploy --dispatch-namespace test-dispatch-namespace index.js" @@ -1120,6 +1125,10 @@ describe("deploy", () => { }, expectedDispatchNamespace: "test-dispatch-namespace", }); + mockServiceScriptData({ + script: { id: "test-name" }, + dispatchNamespace: "test-dispatch-namespace", + }); await runWrangler( 'deploy ./index --dispatch-namespace test-dispatch-namespace --tag v2.0.0 --message "legacy deploy msg"' diff --git a/packages/wrangler/src/__tests__/deploy/helpers.ts b/packages/wrangler/src/__tests__/deploy/helpers.ts index 0d3179e4ec9..84951797e9d 100644 --- a/packages/wrangler/src/__tests__/deploy/helpers.ts +++ b/packages/wrangler/src/__tests__/deploy/helpers.ts @@ -364,12 +364,13 @@ export function mockServiceScriptData(options: { messages: [], result: null, }); - }, - { once: true } + } ) ); return; } + // Dispatch script data may be fetched by both pre-upload checks and the + // Durable Object migrations flow during one deploy. msw.use( http.get( "*/accounts/:accountId/workers/dispatch/namespaces/:dispatchNamespace/scripts/:scriptName", @@ -383,8 +384,7 @@ export function mockServiceScriptData(options: { messages: [], result: { script }, }); - }, - { once: true } + } ) ); } else { diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 27cdbef74be..6535776170a 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -27,6 +27,8 @@ import type { StartRemoteProxySessionOptions } from "@cloudflare/remote-bindings import type { RawConfig } from "@cloudflare/workers-utils"; import type { RemoteProxyConnectionString, V4WorkerOptions } from "miniflare"; +const devReadyTimeout = process.platform === "win32" ? 30_000 : 5_000; + // Mock the startDev function to capture the devEnv so we can stop it later // The `stopWrangler` function will be assigned in the startDev mock implementation where it has access to the `devEnv.teardown()` method. let stopWrangler: () => Promise = async () => { @@ -537,7 +539,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { const wranglerStopped = runWrangler("dev --port=0 --inspector-port=0"); await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { - timeout: 5_000, + timeout: devReadyTimeout, }); expect(proxyWorkerBindings).toEqual(expectedProxyWorkerBindings); expect(workerOptions).toEqual(expectedWorkerOptions); @@ -570,7 +572,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { const match = await vi.waitUntil( () => std.out.match(/Ready on (?http:\/\/[^:]+:\d{4}.+)/), - { timeout: 5_000 } + { timeout: devReadyTimeout } ); // Check that there is initially no remote bindings proxy setup @@ -604,7 +606,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { expect(proxyWorkerBindings).toEqual(expectedProxyWorkerBindings); expect(workerOptions).toEqual(expectedWorkerOptions); }, - { timeout: 5_000 } + { timeout: devReadyTimeout } ); await stopWrangler(); @@ -675,7 +677,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); const wranglerStopped = runWrangler("dev --port=0 --inspector-port=0"); await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { - timeout: 5_000, + timeout: devReadyTimeout, }); expect(proxyWorkerBindings).toEqual({ KV_REMOTE_BINDING: { @@ -727,7 +729,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); const wranglerStopped = runWrangler("dev --local"); await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { - timeout: 5_000, + timeout: devReadyTimeout, }); const bindingsPrintStart = std.out.indexOf( "Your Worker has access to the following bindings:" @@ -780,7 +782,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); const wranglerStopped = runWrangler("dev --port=0 --inspector-port=0"); await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { - timeout: 5_000, + timeout: devReadyTimeout, }); expect(sessionOptions).toBeDefined(); assert(sessionOptions); @@ -823,7 +825,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { "dev --x-provision=false --port=0 --inspector-port=0" ); await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { - timeout: 5_000, + timeout: devReadyTimeout, }); expect(sessionOptions).toBeDefined(); diff --git a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts index 17cc6859431..caa1a342a99 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts @@ -66,9 +66,14 @@ export function mockUploadWorkerRequest( useOldUploadApi?: boolean; expectedObservability?: CfWorkerInit["observability"]; expectedSettingsPatch?: Partial; - expectedContainers?: { name?: string; class_name?: string }[]; + expectedContainers?: { + name?: string; + class_name?: string; + images?: Record; + }[]; expectedAnnotations?: Record; expectedDeploymentMessage?: string; + expectedBindingsInherit?: "strict"; } = {} ) { const handleUpload: HttpResponseResolver = async ({ params, request }) => { @@ -88,6 +93,11 @@ export function mockUploadWorkerRequest( if (useOldUploadApi) { expect(url.searchParams.get("excludeScript")).toEqual("true"); } + if (options.expectedBindingsInherit !== undefined) { + expect(url.searchParams.get("bindings_inherit")).toEqual( + options.expectedBindingsInherit + ); + } if (expectedDispatchNamespace) { expect(params.dispatchNamespace).toEqual(expectedDispatchNamespace); } diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 11582c9114e..a4cee6aa6d8 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -1897,6 +1897,58 @@ describe("generate types - CLI", () => { `); }); + it("should generate one class-scoped binding for Durable Object-managed images", async ({ + expect, + }) => { + fs.writeFileSync( + "./wrangler.jsonc", + JSON.stringify({ + name: "test-name", + durable_objects: { + bindings: [ + { + name: "SANDBOX", + class_name: "Sandbox", + }, + ], + }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: ["Sandbox"], + }, + ], + containers: [ + { + class_name: "Sandbox", + scheduling_policy: "durable_object", + images: { + sandbox: { + dockerfile: "./Dockerfile", + }, + tools: { + dockerfile: "./Dockerfile.tools", + }, + }, + }, + ], + }), + "utf-8" + ); + + await runWrangler("types --include-runtime=false"); + + const generated = fs.readFileSync("worker-configuration.d.ts", "utf-8"); + expect(generated).not.toContain("SANDBOX_IMAGE"); + expect(generated).not.toContain("TOOLS_IMAGE"); + expect(generated).toContain( + "EXPERIMENTAL_CLOUDFLARE_CONTAINER_IMAGES: Readonly>>>;" + ); + expect(generated).toContain( + "SANDBOX: DurableObjectNamespace /* Sandbox */;" + ); + }); + it("should override vars with secrets", async ({ expect }) => { fs.writeFileSync( "./wrangler.jsonc", diff --git a/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts b/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts index f50a361d09a..231ccc39e10 100644 --- a/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts @@ -1,4 +1,5 @@ import { INCONSISTENT_EXPORTS_ACROSS_VERSIONS_CODE } from "@cloudflare/deploy-helpers"; +import { CONTAINER_IMAGES_BINDING } from "@cloudflare/workers-utils"; import { runInTempDir, writeWranglerConfig, @@ -35,6 +36,7 @@ import { import { mswListNewDeploymentsLatestFiftyFifty } from "../helpers/msw/handlers/versions"; import { runWrangler } from "../helpers/run-wrangler"; import { writeWorkerSource } from "../helpers/write-worker-source"; +import type { ApiVersion } from "../../versions/types"; // MSW handler that returns the full annotations for version 30000000-... when // fetched individually (GET /versions/:id). The generic mswGetVersion() mock @@ -77,6 +79,68 @@ const mswGetVersion30000000 = http.get( ) ); +function containerVersion( + id: string, + applications: Array<{ + className: string; + name: string; + namespaceId?: string; + }> +): ApiVersion { + return { + id, + number: 1, + annotations: { + "workers/triggered_by": "upload", + }, + metadata: { + author_id: "Picard-Gamma-6-0-7-3", + author_email: "Jean-Luc-Picard@federation.org", + source: "wrangler", + created_on: "2021-01-01T00:00:00.000000Z", + modified_on: "2021-01-01T00:00:00.000000Z", + }, + resources: { + bindings: [ + { + type: "json", + name: CONTAINER_IMAGES_BINDING, + json: Object.fromEntries( + applications.map(({ className }) => [className, {}]) + ), + }, + ...applications.flatMap(({ className, namespaceId }) => + namespaceId === undefined + ? [] + : [ + { + type: "durable_object_namespace" as const, + name: className, + class_name: className, + namespace_id: namespaceId, + }, + ] + ), + ], + script: { + etag: "aaabbbccc", + handlers: ["fetch"], + last_deployed_from: "api", + }, + script_runtime: { + compatibility_date: "2026-09-03", + compatibility_flags: [], + usage_model: "standard", + limits: { cpu_ms: 50 }, + containers: applications.map(({ className, name }) => ({ + class_name: className, + name, + })), + }, + }, + }; +} + // MSW handler for the deployable-versions endpoint (GET /versions?deployable=true) // returning versions that carry `workers/tag` annotations, used to test // `versions deploy --version-tag `. @@ -287,6 +351,297 @@ describe("versions deploy", () => { }); }); + test("creates Container applications from the selected version instead of local config", async ({ + expect, + }) => { + const versionId = "10000000-0000-0000-0000-000000000000"; + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + const applicationRequests: unknown[] = []; + msw.use( + mswGetVersion( + containerVersion(versionId, [ + { className: "UploadedDurableObject", name: "uploaded-app" }, + ]) + ), + http.get("*/accounts/:accountId/workers/durable_objects/namespaces", () => + HttpResponse.json( + createFetchResult([ + { + id: namespaceId, + name: "uploaded-app", + script: "test-name", + class: "UploadedDurableObject", + useSqlite: true, + }, + ]) + ) + ), + http.post("*/applications", async ({ request }) => { + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + writeWranglerConfig( + { + name: "test-name", + main: "./index.js", + durable_objects: { + bindings: [ + { + name: "LOCAL", + class_name: "LocalDurableObject", + }, + ], + }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: ["LocalDurableObject"], + }, + ], + containers: [ + { + name: "local-app", + class_name: "LocalDurableObject", + scheduling_policy: "durable_object", + }, + ], + }, + "./wrangler.json" + ); + + await runWrangler( + `versions deploy ${versionId}@100% --yes --config ./wrangler.json` + ); + + expect(applicationRequests).toEqual([ + { + name: "uploaded-app", + scheduling_policy: "durable_object", + durable_objects: { namespace_id: namespaceId }, + }, + ]); + }); + + test("creates a consistent multi-version Container application once", async ({ + expect, + }) => { + const firstVersionId = "10000000-0000-0000-0000-000000000000"; + const secondVersionId = "20000000-0000-0000-0000-000000000000"; + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + const applicationRequests: unknown[] = []; + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:workerName/versions/${firstVersionId}`, + () => + HttpResponse.json( + createFetchResult( + containerVersion(firstVersionId, [ + { + className: "UploadedDurableObject", + name: "uploaded-app", + namespaceId, + }, + ]) + ) + ) + ), + http.get( + `*/accounts/:accountId/workers/scripts/:workerName/versions/${secondVersionId}`, + () => + HttpResponse.json( + createFetchResult( + containerVersion(secondVersionId, [ + { + className: "UploadedDurableObject", + name: "uploaded-app", + namespaceId, + }, + ]) + ) + ) + ), + http.post("*/applications", async ({ request }) => { + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + writeWranglerConfig(); + + await runWrangler( + `versions deploy ${firstVersionId}@50% ${secondVersionId}@50% --yes` + ); + + expect(applicationRequests).toEqual([ + { + name: "uploaded-app", + scheduling_policy: "durable_object", + durable_objects: { namespace_id: namespaceId }, + }, + ]); + }); + + test("creates a Container application after a selected version provisions its export", async ({ + expect, + }) => { + const versionId = "10000000-0000-0000-0000-000000000000"; + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + let deploymentCreated = false; + let namespaceRequests = 0; + const applicationRequests: unknown[] = []; + msw.use( + mswGetVersion( + containerVersion(versionId, [ + { className: "UploadedDurableObject", name: "uploaded-app" }, + ]) + ), + http.get( + "*/accounts/:accountId/workers/durable_objects/namespaces", + () => { + namespaceRequests++; + return HttpResponse.json( + createFetchResult( + deploymentCreated + ? [ + { + id: namespaceId, + name: "uploaded-app", + script: "test-name", + class: "UploadedDurableObject", + useSqlite: true, + }, + ] + : [] + ) + ); + } + ), + http.post( + "*/accounts/:accountId/workers/scripts/:workerName/deployments", + () => { + deploymentCreated = true; + return HttpResponse.json( + createFetchResult({ id: "mock-new-deployment-id" }) + ); + } + ), + http.post("*/applications", async ({ request }) => { + expect(deploymentCreated).toBe(true); + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + writeWranglerConfig(); + + await runWrangler(`versions deploy ${versionId}@100% --yes`); + + expect(namespaceRequests).toBe(2); + expect(applicationRequests).toEqual([ + { + name: "uploaded-app", + scheduling_policy: "durable_object", + durable_objects: { namespace_id: namespaceId }, + }, + ]); + }); + + test("rejects inconsistent multi-version Container applications before deploying", async ({ + expect, + }) => { + const firstVersionId = "10000000-0000-0000-0000-000000000000"; + const secondVersionId = "20000000-0000-0000-0000-000000000000"; + let deploymentRequests = 0; + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:workerName/versions/${firstVersionId}`, + () => + HttpResponse.json( + createFetchResult( + containerVersion(firstVersionId, [ + { + className: "UploadedDurableObject", + name: "first-app", + }, + ]) + ) + ) + ), + http.get( + `*/accounts/:accountId/workers/scripts/:workerName/versions/${secondVersionId}`, + () => + HttpResponse.json( + createFetchResult( + containerVersion(secondVersionId, [ + { + className: "UploadedDurableObject", + name: "second-app", + }, + ]) + ) + ) + ), + http.post( + "*/accounts/:accountId/workers/scripts/:workerName/deployments", + () => { + deploymentRequests++; + return HttpResponse.json( + createFetchResult({ id: "unexpected-deployment" }) + ); + } + ) + ); + writeWranglerConfig(); + + await expect( + runWrangler( + `versions deploy ${firstVersionId}@50% ${secondVersionId}@50% --yes` + ) + ).rejects.toThrow( + "All Worker Versions in a multi-version deployment must declare identical Durable Object-managed Container applications." + ); + expect(deploymentRequests).toBe(0); + }); + + test("validates an existing Container application before deploying traffic", async ({ + expect, + }) => { + const versionId = "10000000-0000-0000-0000-000000000000"; + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + let deploymentRequests = 0; + msw.use( + mswGetVersion( + containerVersion(versionId, [ + { + className: "UploadedDurableObject", + name: "uploaded-app", + namespaceId, + }, + ]) + ), + http.post("*/applications", () => + HttpResponse.json(createFetchResult(null, false), { status: 409 }) + ), + http.post( + "*/accounts/:accountId/workers/scripts/:workerName/deployments", + () => { + deploymentRequests++; + return HttpResponse.json( + createFetchResult({ id: "unexpected-deployment" }) + ); + } + ) + ); + writeWranglerConfig(); + + await expect( + runWrangler(`versions deploy ${versionId}@100% --yes`) + ).rejects.toThrow(); + expect(deploymentRequests).toBe(0); + }); + describe("with wrangler.toml", () => { beforeEach(() => writeWranglerConfig()); diff --git a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts index d68fe947107..c59addb437a 100644 --- a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts @@ -2,12 +2,16 @@ import assert from "node:assert"; import * as fs from "node:fs"; import * as path from "node:path"; +import { ContainerImagePreparationStatus } from "@cloudflare/containers-shared"; import { ACTOR_BINDING_DEPENDS_ON_EXPORT_CODE, generatePreviewAlias, } from "@cloudflare/deploy-helpers"; import { TEMPORARY_TERMS_NOTICE } from "@cloudflare/workers-auth"; -import { DEFAULT_COMPAT_DATE } from "@cloudflare/workers-utils"; +import { + CONTAINER_IMAGES_BINDING, + DEFAULT_COMPAT_DATE, +} from "@cloudflare/workers-utils"; import { runInTempDir, writeRedirectedWranglerConfig, @@ -21,6 +25,7 @@ import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, it, test, vi } from "vitest"; import * as metrics from "../../metrics"; import { dedent } from "../../utils/dedent"; +import { mockAccountV4 as mockContainersAccount } from "../cloudchamber/utils"; import { makeApiRequestAsserter } from "../helpers/assert-request"; import { captureRequestsFrom } from "../helpers/capture-requests-from"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; @@ -2338,6 +2343,125 @@ describe("versions upload", () => { expect(metadata.migrations?.new_tag).toEqual("v1"); }); + test("should include Durable Object-managed images alongside migrations", async ({ + expect, + }) => { + const namespaceId = "14758f1afd44c09b7992073ccf00b43d"; + const image = + "registry.cloudflare.com/some-account-id/tools@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + mockGetScript( + { + default_environment: { + script: { + last_deployed_from: "wrangler", + migration_tag: "", + }, + }, + }, + { once: false } + ); + mockContainersAccount(); + const requests = mockUploadVersion(false, 0); + const applicationRequests: unknown[] = []; + msw.use( + http.post("*/image-preparations", async ({ request }) => { + const body = (await request.json()) as { image: string }; + expect(body).toEqual({ image }); + return HttpResponse.json( + createFetchResult({ + image, + status: ContainerImagePreparationStatus.READY, + }) + ); + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/versions/:versionId", + ({ params }) => { + expect(params.versionId).toBe( + "51e4886e-2db7-4900-8d38-fbfecfeab993" + ); + return HttpResponse.json( + createFetchResult({ + id: params.versionId, + metadata: {}, + number: 1, + resources: { + bindings: [ + { + type: "durable_object_namespace", + namespace_id: namespaceId, + class_name: "MyDurableObject", + }, + ], + }, + }) + ); + } + ), + http.post("*/applications", async ({ request }) => { + const body = await request.json(); + applicationRequests.push(body); + return HttpResponse.json(createFetchResult(body)); + }) + ); + + writeWranglerConfig({ + name: "test-name", + main: "./index.js", + durable_objects: { + bindings: [{ name: "MY_DO", class_name: "MyDurableObject" }], + }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: ["MyDurableObject"], + }, + ], + containers: [ + { + class_name: "MyDurableObject", + scheduling_policy: "durable_object", + images: { + tools: { image }, + }, + }, + ], + }); + writeWorkerSource({ durableObjects: ["MyDurableObject"] }); + + await runWrangler("versions upload"); + + const metadata = await getMetadata(requests[requests.length - 1]); + expect(metadata.migrations?.new_tag).toEqual("v1"); + expect(metadata.exports).toBeUndefined(); + expect(metadata.containers).toEqual([ + { + name: "test-name-mydurableobject", + class_name: "MyDurableObject", + images: { tools: image }, + }, + ]); + expect(metadata.bindings).toContainEqual({ + json: { + MyDurableObject: { + tools: image, + }, + }, + name: CONTAINER_IMAGES_BINDING, + type: "json", + }); + expect(applicationRequests).toEqual([ + { + name: "test-name-mydurableobject", + scheduling_policy: "durable_object", + durable_objects: { namespace_id: namespaceId }, + }, + ]); + expect(std.warn).not.toContain( + "Container configuration changes (such as image, max_instances, etc.) will not be gradually rolled out with versions" + ); + }); + test("should skip migrations in dry-run", async ({ expect }) => { writeWranglerConfig({ name: "test-name", @@ -2402,6 +2526,70 @@ describe("versions upload", () => { expect(metadata.migrations).toBeUndefined(); }); + test("defers exports-managed Container application creation until versions deploy", async ({ + expect, + }) => { + mockGetScript(); + const requests = mockUploadVersion(false, 0); + let namespaceListRequests = 0; + let applicationRequests = 0; + msw.use( + http.get( + "*/accounts/:accountId/workers/durable_objects/namespaces", + () => { + namespaceListRequests++; + return HttpResponse.json(createFetchResult([])); + } + ), + http.post("*/applications", () => { + applicationRequests++; + return HttpResponse.json(createFetchResult({})); + }) + ); + + writeWranglerConfig( + { + name: "test-name", + main: "./index.js", + exports: { + MyDurableObject: { + type: "durable-object", + storage: "sqlite", + container: "managed-app", + }, + }, + containers: [ + { + name: "managed-app", + class_name: "MyDurableObject", + scheduling_policy: "durable_object", + }, + ], + }, + "./wrangler.json" + ); + writeWorkerSource({ durableObjects: ["MyDurableObject"] }); + + await runWrangler("versions upload --config ./wrangler.json"); + + const metadata = await getMetadata(requests[requests.length - 1]); + expect(metadata.exports).toEqual({ + MyDurableObject: { + type: "durable-object", + storage: "sqlite", + container: "managed-app", + }, + }); + expect(metadata.containers).toEqual([ + { + name: "managed-app", + class_name: "MyDurableObject", + }, + ]); + expect(namespaceListRequests).toBe(0); + expect(applicationRequests).toBe(0); + }); + test("surfaces a friendly error when EWC rejects a binding to a not-yet-provisioned `exports` class (code 100406)", async ({ expect, }) => { diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 786868f430a..9cc54c9c0dd 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -16,7 +16,7 @@ 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 { getNormalizedContainerOptionsForDev } from "../../containers/config"; import { getEntry } from "../../deployment-bundle/entry"; import { validateNodeCompatMode } from "../../deployment-bundle/node-compat"; import { getBindings, getHostAndRoutes, getInferredHost } from "../../dev"; @@ -447,7 +447,7 @@ async function resolveConfig( tsconfig: input.build?.tsconfig ?? config.tsconfig, exports: entry.exports, }, - containers: await getNormalizedContainerOptions(config, {}), + containers: await getNormalizedContainerOptionsForDev(config), dev: await resolveDevConfig(config, input), legacy: { site: legacySite, diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index deee1db8897..7add713307b 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { cleanupContainers, - getDevContainerImageName, prepareContainerImagesForDev, runDockerCmdWithOutput, } from "@cloudflare/containers-shared"; @@ -15,6 +14,12 @@ import { Miniflare, Mutex, } from "miniflare"; +import { + addDurableObjectContainerImagesBinding, + getContainerDevImageName, + getContainerDevOptions, + getDurableObjectContainerDefaultImageNames, +} from "../../containers/dev"; import * as MF from "../../dev/miniflare"; import { logger } from "../../logger"; import { RuntimeController } from "./BaseController"; @@ -115,6 +120,13 @@ export async function convertToConfigBundle( event: BundleCompleteEvent ): Promise { const bindings: Record = { ...event.config.bindings }; + if (event.config.dev.enableContainers ?? true) { + addDurableObjectContainerImagesBinding( + bindings, + event.config.containers, + event.config.dev.containerBuildId + ); + } const crons = []; const routes = []; @@ -217,6 +229,10 @@ export async function convertToConfigBundle( containerDOClassNames: new Set( event.config.containers?.map((c) => c.class_name) ), + containerDefaultImageNames: getDurableObjectContainerDefaultImageNames( + event.config.containers, + event.config.dev.containerBuildId + ), containerBuildId: event.config.dev?.containerBuildId, containerEngine: event.config.dev.containerEngine, enableContainers: event.config.dev.enableContainers ?? true, @@ -364,8 +380,8 @@ export class LocalRuntimeController extends RuntimeController { if (this.#currentContainerBuildId !== undefined) { runDockerCmdWithOutput(this.dockerPath, [ "rmi", - getDevContainerImageName( - container.class_name, + getContainerDevImageName( + container, this.#currentContainerBuildId ), ]); @@ -583,39 +599,3 @@ export class LocalRuntimeController extends RuntimeController { this.bus.dispatch(data); } } - -/** - * @returns Container options suitable for building or pulling images, - * with image tag set to well-known dev format. - * Undefined if containers are not enabled or not configured. - */ -export async function getContainerDevOptions( - containersConfig: NonNullable, - containerBuildId: string -) { - const containers: ContainerDevOptions[] = []; - for (const container of containersConfig) { - if ("image_uri" in container) { - containers.push({ - image_uri: container.image_uri, - class_name: container.class_name, - image_tag: getDevContainerImageName( - container.class_name, - containerBuildId - ), - }); - } else { - containers.push({ - dockerfile: container.dockerfile, - image_build_context: container.image_build_context, - image_vars: container.image_vars, - class_name: container.class_name, - image_tag: getDevContainerImageName( - container.class_name, - containerBuildId - ), - }); - } - } - return containers; -} diff --git a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts index d88dc15a5dd..2d6710c2f13 100644 --- a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts @@ -4,12 +4,12 @@ import { prepareContainerImagesForDev } from "@cloudflare/containers-shared"; import { getDockerPath } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { convertV4MiniflareOptions, Miniflare, Mutex } from "miniflare"; +import { getContainerDevOptions } from "../../containers/dev"; import * as MF from "../../dev/miniflare"; import { logger } from "../../logger"; import { castErrorCause } from "./events"; import { convertToConfigBundle, - getContainerDevOptions, getUserWorkerInnerUrlOverrides, LocalRuntimeController, } from "./LocalRuntimeController"; diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index 0f6af34c1d0..e6949152a7c 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -1,7 +1,7 @@ import type { EsbuildBundle } from "../../dev/use-esbuild"; import type { ConfigController } from "./ConfigController"; import type { DevEnv } from "./DevEnv"; -import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; +import type { ContainerDevConfig } from "@cloudflare/containers-shared"; import type { AsyncHook, AssetsOptions, @@ -85,7 +85,7 @@ export type StartDevWorkerOptions = Omit< }; entrypoint: string; assets?: AssetsOptions; - containers?: ContainerNormalizedConfig[]; + containers?: ContainerDevConfig[]; name: string; complianceRegion: Config["compliance_region"]; }; diff --git a/packages/wrangler/src/containers/config.ts b/packages/wrangler/src/containers/config.ts index 06b5344fcc4..497af93ce41 100644 --- a/packages/wrangler/src/containers/config.ts +++ b/packages/wrangler/src/containers/config.ts @@ -6,7 +6,9 @@ import { SchedulingPolicy, } from "@cloudflare/containers-shared"; import { + getDurableObjectContainerApps, isDockerfile, + isDurableObjectContainerApp, resolveContainerClassName, UserError, } from "@cloudflare/workers-utils"; @@ -15,6 +17,7 @@ import { getOrSelectAccountId } from "../user"; import type { ApplicationAffinities, ApplicationAffinityColocation, + ContainerDevConfig, ContainerNormalizedConfig, InstanceTypeOrLimits, SharedContainerConfig, @@ -22,6 +25,49 @@ import type { import type { ApplicationAffinityHardwareGeneration } from "@cloudflare/containers-shared/src/client/models/ApplicationAffinityHardwareGeneration"; import type { Config, ContainerApp } from "@cloudflare/workers-utils"; +/** + * Validate that every Durable Object-managed container belongs to this Worker. + */ +export function validateDurableObjectContainerApplications( + config: Config +): void { + const allDOs = getDurableObjectClassNameToUseSQLiteMap( + config.migrations, + config.exports + ); + + for (const container of getDurableObjectContainerApps(config.containers)) { + const maybeBoundDO = config.durable_objects.bindings.find( + (durableObject) => durableObject.class_name === container.class_name + ); + const useSQLite = allDOs.get(container.class_name); + if (useSQLite === undefined && maybeBoundDO === undefined) { + throw new UserError( + `The container class_name ${container.class_name} does not match any durable object class_name defined in your Wrangler config file. Note that the durable object must be defined in the same script as the container.`, + { telemetryMessage: "no DO defined that matches container class_name" } + ); + } + if (maybeBoundDO?.script_name !== undefined) { + throw new UserError( + `The container ${container.name} is referencing the durable object ${container.class_name}, which appears to be defined on the ${maybeBoundDO.script_name} Worker instead (via the 'script_name' field). You cannot configure a container on a Durable Object that is defined in another Worker.`, + { + telemetryMessage: + "container class_name refers to an external durable object", + } + ); + } + if (useSQLite === false) { + throw new UserError( + `The container ${container.name} references Durable Object class ${container.class_name}, which uses the legacy KV storage backend. Durable Object-managed Containers require SQLite-backed Durable Objects.`, + { + telemetryMessage: + "durable object container class uses legacy storage", + } + ); + } + } +} + /** * Perform type conversion of affinities so that they can be fed to the API. */ @@ -61,13 +107,23 @@ export const getNormalizedContainerOptions = async ( return []; } + validateDurableObjectContainerApplications(config); + const normalizedContainers: ContainerNormalizedConfig[] = []; + const allDOs = getDurableObjectClassNameToUseSQLiteMap( + config.migrations, + config.exports + ); for (const container of config.containers) { + if (isDurableObjectContainerApp(container)) { + continue; + } + assert(container.name, "container name should have been set by validation"); - const allDOs = getDurableObjectClassNameToUseSQLiteMap( - config.migrations, - config.exports + assert( + container.image, + "container image should have been set by validation" ); // A container is linked to its Durable Object either by its own @@ -238,3 +294,60 @@ export const getNormalizedContainerOptions = async ( return normalizedContainers; }; + +/** + * Normalize all images needed by local development. + * + * Deployment handles Durable Object-managed Container images separately. For + * local development we flatten their named image map so every image can be + * built or pulled and addressed by a deterministic local Docker tag. + */ +export const getNormalizedContainerOptionsForDev = async ( + config: Config +): Promise => { + const normalized: ContainerDevConfig[] = await getNormalizedContainerOptions( + config, + {} + ); + const baseDir = config.configPath + ? path.dirname(config.configPath) + : process.cwd(); + + for (const container of getDurableObjectContainerApps(config.containers)) { + const images = Object.entries(container.images ?? {}); + if (images.length === 0) { + normalized.push({ + class_name: container.class_name, + scheduling_policy: "durable_object", + }); + continue; + } + + for (const [imageName, image] of images) { + const shared = { + class_name: container.class_name, + scheduling_policy: "durable_object" as const, + image_name: imageName, + }; + if (image.dockerfile !== undefined) { + const dockerfile = path.resolve(baseDir, image.dockerfile); + normalized.push({ + ...shared, + dockerfile, + image_build_context: dirname(dockerfile), + }); + } else { + normalized.push({ + ...shared, + image_uri: resolveImageName( + await getOrSelectAccountId(config), + image.image, + config + ), + }); + } + } + } + + return normalized; +}; diff --git a/packages/wrangler/src/containers/deploy.ts b/packages/wrangler/src/containers/deploy.ts index edc7c890f6f..489f1c64087 100644 --- a/packages/wrangler/src/containers/deploy.ts +++ b/packages/wrangler/src/containers/deploy.ts @@ -2,7 +2,6 @@ * Note! Much of this is copied and modified from cloudchamber/apply.ts * However this code is only used for containers interactions, not cloudchamber ones! */ -import assert from "node:assert"; import { setTimeout } from "node:timers/promises"; import { endSection, @@ -33,7 +32,7 @@ import { getDockerPath, UserError, } from "@cloudflare/workers-utils"; -import { fetchResult } from "../cfetch"; +import { fetchPagedListResult } from "../cfetch"; import { fillOpenAPIConfiguration, promiseSpinner, @@ -73,38 +72,18 @@ type DeployContainersArgs = { scriptName: string; }; -export async function deployContainers( +export function createDurableObjectNamespaceResolver( config: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], { versionId, accountId, scriptName }: DeployContainersArgs -) { - await fillOpenAPIConfiguration(config, containersScope); - - const pathToDocker = getDockerPath(); +): (className: string) => Promise { const boundDOs = new Set( - config.durable_objects.bindings.map((b) => b.class_name) + config.durable_objects.bindings.map((binding) => binding.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 }; - } - - // Only bound DOs are returned in version info. For unbound DOs, we need to list all DO namespaces. - if (boundDOs.has(container.class_name)) { + return async (className: string) => { + if (boundDOs.has(className)) { maybeVersionInfo ??= await fetchUploadedVersion( config, accountId, @@ -118,53 +97,80 @@ export async function deployContainers( const targetDurableObject = maybeVersionInfo.resources.bindings.find( (binding): binding is DurableObjectBinding => binding.type === "durable_object_namespace" && - binding.class_name === container.class_name && - // DO cannot be defined in a different script to the container + binding.class_name === className && (binding.script_name === undefined || binding.script_name === scriptName) && binding.namespace_id !== undefined ); - if (!targetDurableObject) { + if (!targetDurableObject?.namespace_id) { throw new UserError( - "Could not deploy container application as durable object was not found in list of bindings", + "Could not deploy container configuration as durable object was not found in list of bindings", { telemetryMessage: "containers deploy durable object binding missing", } ); } - assert( - targetDurableObject && targetDurableObject.namespace_id !== undefined - ); + return targetDurableObject.namespace_id; + } - await apply( + maybeAllDurableObjects ??= await listDurableObjects(config, accountId); + const targetDurableObject = maybeAllDurableObjects.find( + (durableObject) => + durableObject.class === className && durableObject.script === scriptName + ); + if (!targetDurableObject) { + throw new UserError( + "Could not deploy container configuration as durable object was not found in the account namespace list", { - imageRef, - durable_object_namespace_id: targetDurableObject.namespace_id, - }, - container, - config - ); - } else { - // The DO is unbound, so we need to list all DO namespaces to find the right one - // TODO: use the list API with filters when it exists - maybeAllDurableObjects ??= await listDurableObjects(config, accountId); - const targetDurableObject = maybeAllDurableObjects.find( - (durableObject) => - durableObject.class === container.class_name && - durableObject.script === scriptName + telemetryMessage: + "containers deploy durable object namespace missing", + } ); + } + return targetDurableObject.id; + }; +} - assert(targetDurableObject, "Durable Object not returned from list API"); - await apply( - { - imageRef, - durable_object_namespace_id: targetDurableObject.id, - }, +export async function deployContainers( + config: Config, + normalisedContainerConfig: ContainerNormalizedConfig[], + { versionId, accountId, scriptName }: DeployContainersArgs +) { + await fillOpenAPIConfiguration(config, containersScope); + + const pathToDocker = getDockerPath(); + const resolveNamespaceId = createDurableObjectNamespaceResolver(config, { + versionId, + accountId, + scriptName, + }); + + let imageRef: ImageRef; + + 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 }; } + + const namespaceId = await resolveNamespaceId(container.class_name); + await apply( + { + imageRef, + durable_object_namespace_id: namespaceId, + }, + container, + config + ); } } @@ -208,7 +214,7 @@ export async function listDurableObjects( complianceConfig: ComplianceConfig, accountId: string ): Promise { - return await fetchResult( + return await fetchPagedListResult( complianceConfig, `/accounts/${accountId}/workers/durable_objects/namespaces`, {}, diff --git a/packages/wrangler/src/containers/dev.ts b/packages/wrangler/src/containers/dev.ts new file mode 100644 index 00000000000..550d1956c93 --- /dev/null +++ b/packages/wrangler/src/containers/dev.ts @@ -0,0 +1,146 @@ +import assert from "node:assert"; +import { createHash } from "node:crypto"; +import { getDevContainerImageName } from "@cloudflare/containers-shared"; +import { CONTAINER_IMAGES_BINDING, UserError } from "@cloudflare/workers-utils"; +import type { + ContainerDevConfig, + ContainerDevOptions, +} from "@cloudflare/containers-shared"; +import type { Binding } from "@cloudflare/workers-utils"; + +// Keep the full repository name comfortably below Docker's 255-character +// limit after adding the `cloudflare-dev/` prefix and uniqueness suffix. +const NAMED_IMAGE_SLUG_MAX_LENGTH = 200; + +/** Returns the deterministic Docker tag used for one local Container image. */ +export function getContainerDevImageName( + container: Pick & { image_name?: string }, + containerBuildId: string +): string { + if (container.image_name === undefined) { + return getDevContainerImageName(container.class_name, containerBuildId); + } + + const sourceName = `${container.class_name}\0${container.image_name}`; + const suffix = createHash("sha256") + .update(sourceName) + .digest("hex") + .slice(0, 12); + const slug = `${container.class_name}-${container.image_name}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, NAMED_IMAGE_SLUG_MAX_LENGTH) + .replace(/-+$/g, ""); + const name = `${slug || "image"}-${suffix}`; + return getDevContainerImageName(name, containerBuildId); +} + +/** Converts normalized Container config into build/pull options for Docker. */ +export function getContainerDevOptions( + containersConfig: ContainerDevConfig[], + containerBuildId: string +): ContainerDevOptions[] { + const containers: ContainerDevOptions[] = []; + for (const container of containersConfig) { + if (!("image_uri" in container) && !("dockerfile" in container)) { + continue; + } + const imageTag = getContainerDevImageName(container, containerBuildId); + const imageName = + "image_name" in container ? container.image_name : undefined; + if ("image_uri" in container) { + containers.push({ + image_uri: container.image_uri, + class_name: container.class_name, + image_name: imageName, + image_tag: imageTag, + }); + } else { + containers.push({ + dockerfile: container.dockerfile, + image_build_context: container.image_build_context, + image_vars: container.image_vars, + class_name: container.class_name, + image_name: imageName, + image_tag: imageTag, + }); + } + } + return containers; +} + +/** + * Selects a real local fallback image for each Durable Object-managed + * Container. Workerd uses this attachment when restoring a container snapshot + * without an explicit image, while ordinary starts can still override it with + * any named image from the injected image map. + */ +export function getDurableObjectContainerDefaultImageNames( + containersConfig: ContainerDevConfig[] | undefined, + containerBuildId: string | undefined +): Map { + const defaults = new Map(); + if (!containersConfig || !containerBuildId) { + return defaults; + } + + for (const container of containersConfig) { + if ( + container.scheduling_policy !== "durable_object" || + defaults.has(container.class_name) || + (!("image_uri" in container) && !("dockerfile" in container)) + ) { + continue; + } + defaults.set( + container.class_name, + getContainerDevImageName(container, containerBuildId) + ); + } + + return defaults; +} + +/** + * Exposes local Docker tags through the same reserved binding used in + * production, allowing `ctx.container.images` and the environment binding to + * select named images without special application code. + */ +export function addDurableObjectContainerImagesBinding( + bindings: Record, + containers: ContainerDevConfig[] | undefined, + containerBuildId: string | undefined +): void { + const dynamicContainers = containers?.filter( + (container) => container.scheduling_policy === "durable_object" + ); + if (!dynamicContainers?.length) { + return; + } + if (bindings[CONTAINER_IMAGES_BINDING] !== undefined) { + throw new UserError( + `The binding name "${CONTAINER_IMAGES_BINDING}" is reserved for Durable Object-managed Container images.`, + { + telemetryMessage: + "durable object container images binding name conflict", + } + ); + } + assert( + containerBuildId, + "Build ID should be set if containers are defined and enabled" + ); + + const images: Record> = {}; + for (const container of dynamicContainers) { + const classImages = (images[container.class_name] ??= {}); + if ("image_name" in container) { + classImages[container.image_name] = getContainerDevImageName( + container, + containerBuildId + ); + } + } + bindings[CONTAINER_IMAGES_BINDING] = { type: "json", value: images }; +} diff --git a/packages/wrangler/src/containers/durable-object-applications.ts b/packages/wrangler/src/containers/durable-object-applications.ts new file mode 100644 index 00000000000..6fc3261c8e7 --- /dev/null +++ b/packages/wrangler/src/containers/durable-object-applications.ts @@ -0,0 +1,456 @@ +import path from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { updateStatus } from "@cloudflare/cli-shared-helpers"; +import { + ApplicationsService, + ContainerImagePreparationsService, + ContainerImagePreparationStatus, + resolveImageName, + SchedulingPolicy, +} from "@cloudflare/containers-shared"; +import { + CONTAINER_IMAGES_BINDING, + getDurableObjectContainerApps, + getDockerPath, + isNonInteractiveOrCI, + UserError, +} from "@cloudflare/workers-utils"; +import { buildAndMaybePush } from "../cloudchamber/build"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { logger } from "../logger"; +import { getOrSelectAccountId } from "../user"; +import { validateDurableObjectContainerApplications } from "./config"; +import { + createDurableObjectNamespaceResolver, + listDurableObjects, +} from "./deploy"; +import { containersScope } from "."; +import type { ApiVersion } from "../versions/types"; +import type { CreateDurableObjectApplicationRequest } from "@cloudflare/containers-shared"; +import type { + Config, + DurableObjectContainerApp, + DurableObjectContainerImage, +} from "@cloudflare/workers-utils"; + +const IMAGE_PREPARATION_POLL_INTERVAL_MS = 2_000; +const IMAGE_PREPARATION_TIMEOUT_MS = 15 * 60_000; + +type DeployDurableObjectContainerApplicationsArgs = { + versionId: string; + accountId: string; + scriptName: string; +}; + +type PrepareDurableObjectContainerApplicationsArgs = { + dryRun: boolean; + scriptName: string; +}; + +type DurableObjectContainerApplication = Pick< + DurableObjectContainerApp, + "class_name" | "name" +>; + +export type VersionedDurableObjectContainerApplication = + DurableObjectContainerApplication & { + namespaceId?: string; + }; + +export type PreparedContainerImages = Record>; + +function isRegistryImage( + image: DurableObjectContainerImage +): image is Extract { + return typeof image.image === "string"; +} + +function toCreateApplicationRequest( + { name }: DurableObjectContainerApplication, + namespaceId: string +): CreateDurableObjectApplicationRequest { + return { + name, + scheduling_policy: SchedulingPolicy.DURABLE_OBJECT, + durable_objects: { namespace_id: namespaceId }, + }; +} + +function getContainerImageClasses( + version: ApiVersion +): Set | undefined { + // Container upload metadata does not include scheduling_policy. This + // versioned generated binding identifies exactly the Durable Object-managed + // classes, including classes whose image maps are empty. + const binding = version.resources.bindings.find( + (candidate) => candidate.name === CONTAINER_IMAGES_BINDING + ); + if (binding === undefined) { + return undefined; + } + + if ( + binding.type !== "json" || + typeof binding.json !== "object" || + binding.json === null || + Array.isArray(binding.json) + ) { + throw new UserError( + `Worker Version ${version.id} has invalid ${CONTAINER_IMAGES_BINDING} metadata.`, + { + telemetryMessage: + "versions deploy invalid durable object container image binding", + } + ); + } + + return new Set(Object.keys(binding.json)); +} + +function getNamespaceId( + version: ApiVersion, + scriptName: string, + className: string +): string | undefined { + const binding = version.resources.bindings.find( + (candidate) => + candidate.type === "durable_object_namespace" && + candidate.class_name === className && + (candidate.script_name === undefined || + candidate.script_name === scriptName) + ); + return binding?.type === "durable_object_namespace" + ? binding.namespace_id + : undefined; +} + +function getDurableObjectContainerApplicationsFromVersion( + version: ApiVersion, + scriptName: string +): VersionedDurableObjectContainerApplication[] { + const containerClasses = getContainerImageClasses(version); + if (containerClasses === undefined) { + return []; + } + + const containers = version.resources.script_runtime.containers ?? []; + return [...containerClasses].sort().map((className) => { + const matchingContainers = containers.filter( + (container) => container.class_name === className + ); + const [container] = matchingContainers; + if (matchingContainers.length !== 1 || container?.name === undefined) { + throw new UserError( + `Worker Version ${version.id} has invalid Durable Object-managed Container metadata for class "${className}".`, + { + telemetryMessage: + "versions deploy invalid durable object container metadata", + } + ); + } + + return { + class_name: className, + name: container.name, + namespaceId: getNamespaceId(version, scriptName, className), + }; + }); +} + +export function getVersionedDurableObjectContainerApplications( + versions: ApiVersion[], + scriptName: string +): VersionedDurableObjectContainerApplication[] { + const applicationsByVersion = versions.map((version) => + getDurableObjectContainerApplicationsFromVersion(version, scriptName) + ); + const [firstApplications = []] = applicationsByVersion; + const expectedDefinition = JSON.stringify( + firstApplications.map(({ class_name, name }) => ({ class_name, name })) + ); + + for (let index = 1; index < applicationsByVersion.length; index++) { + const applications = applicationsByVersion[index] ?? []; + const definition = JSON.stringify( + applications.map(({ class_name, name }) => ({ class_name, name })) + ); + if (definition !== expectedDefinition) { + throw new UserError( + "All Worker Versions in a multi-version deployment must declare identical Durable Object-managed Container applications.", + { + telemetryMessage: + "versions deploy inconsistent durable object container applications", + } + ); + } + } + + return firstApplications.map((application, applicationIndex) => { + const namespaceIds = new Set( + applicationsByVersion + .map((applications) => applications[applicationIndex]?.namespaceId) + .filter((namespaceId): namespaceId is string => Boolean(namespaceId)) + ); + if (namespaceIds.size > 1) { + throw new UserError( + "All Worker Versions in a multi-version deployment must reference the same Durable Object namespaces for their Container applications.", + { + telemetryMessage: + "versions deploy inconsistent durable object container namespaces", + } + ); + } + + return { + ...application, + namespaceId: namespaceIds.values().next().value, + }; + }); +} + +async function createApplication( + application: DurableObjectContainerApplication, + namespaceId: string +): Promise { + await ApplicationsService.createApplication( + toCreateApplicationRequest(application, namespaceId) + ); +} + +export async function deployVersionedDurableObjectContainerApplications( + config: Config, + { + applications, + accountId, + scriptName, + allowMissingNamespaces = false, + }: { + applications: VersionedDurableObjectContainerApplication[]; + accountId: string; + scriptName: string; + allowMissingNamespaces?: boolean; + } +): Promise { + if (applications.length === 0) { + return []; + } + + await fillOpenAPIConfiguration(config, containersScope); + const namespaces = applications.some( + (application) => application.namespaceId === undefined + ) + ? await listDurableObjects(config, accountId) + : []; + const pendingApplications: VersionedDurableObjectContainerApplication[] = []; + for (const application of applications) { + const namespaceId = + application.namespaceId ?? + namespaces.find( + (namespace) => + namespace.class === application.class_name && + namespace.script === scriptName + )?.id; + if (namespaceId === undefined) { + if (allowMissingNamespaces) { + pendingApplications.push(application); + continue; + } + throw new UserError( + `Could not deploy Durable Object-managed Container application "${application.name}" because class "${application.class_name}" has no namespace after the Worker Version was deployed.`, + { + telemetryMessage: + "versions deploy durable object container namespace missing", + } + ); + } + await createApplication(application, namespaceId); + } + return pendingApplications; +} + +function buildTag( + scriptName: string, + className: string, + imageName: string +): string { + const repository = `${scriptName}-${className}-${imageName}` + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${repository}:wrangler-${Date.now().toString(36)}`; +} + +async function buildOrResolveImage( + config: Config, + container: DurableObjectContainerApp, + imageName: string, + imageConfig: DurableObjectContainerImage, + scriptName: string, + dryRun: boolean +): Promise { + if (isRegistryImage(imageConfig)) { + if (dryRun) { + return imageConfig.image; + } + return resolveImageName( + await getOrSelectAccountId(config), + imageConfig.image, + config + ); + } + + const baseDir = config.configPath + ? path.dirname(config.configPath) + : process.cwd(); + const dockerfile = path.resolve(baseDir, imageConfig.dockerfile); + const tag = buildTag(scriptName, container.class_name, imageName); + logger.log("Building image", tag); + const imageRef = await buildAndMaybePush( + { + tag, + pathToDockerfile: dockerfile, + buildContext: path.dirname(dockerfile), + platform: "linux/amd64", + }, + getDockerPath(), + !dryRun, + undefined, + true, + config + ); + + return "remoteDigest" in imageRef ? imageRef.remoteDigest : imageRef.newTag; +} + +async function waitForImagePreparation(image: string): Promise { + const deadline = Date.now() + IMAGE_PREPARATION_TIMEOUT_MS; + + while (Date.now() < deadline) { + const preparation = + await ContainerImagePreparationsService.prepareContainerImage({ image }); + switch (preparation.status) { + case ContainerImagePreparationStatus.READY: + return; + case ContainerImagePreparationStatus.ERROR: + throw new UserError( + preparation.reason ?? "Container image preparation failed", + { + telemetryMessage: + "durable object container image preparation failed", + } + ); + case ContainerImagePreparationStatus.PENDING: + await setTimeout(IMAGE_PREPARATION_POLL_INTERVAL_MS); + break; + default: + throw new UserError( + `Container image preparation returned an unsupported status: ${String(preparation.status)}`, + { + telemetryMessage: + "durable object container image preparation unsupported status", + } + ); + } + } + + throw new UserError( + "Timed out while preparing the container image on Cloudflare's network.", + { + telemetryMessage: "durable object container image preparation timed out", + } + ); +} + +export async function prepareDurableObjectContainerApplications( + config: Config, + { dryRun, scriptName }: PrepareDurableObjectContainerApplicationsArgs +): Promise { + validateDurableObjectContainerApplications(config); + + const containers = getDurableObjectContainerApps(config.containers).filter( + (container) => Object.keys(container.images ?? {}).length > 0 + ); + if (containers.length === 0) { + return {}; + } + + if (!dryRun) { + await fillOpenAPIConfiguration(config, containersScope); + } + + const imagesByClass: [string, Record][] = []; + const preparedImages = new Map(); + for (const container of containers) { + const classImages: [string, string][] = []; + for (const [imageName, imageConfig] of Object.entries( + container.images ?? {} + )) { + const source = isRegistryImage(imageConfig) + ? `image:${imageConfig.image}` + : `dockerfile:${path.resolve( + config.configPath ? path.dirname(config.configPath) : process.cwd(), + imageConfig.dockerfile + )}`; + let image = preparedImages.get(source); + if (image === undefined) { + image = await buildOrResolveImage( + config, + container, + imageName, + imageConfig, + scriptName, + dryRun + ); + + if (!dryRun) { + const imageLine = ` ${image}`; + const message = `Preparing ${imageName} for Cloudflare Containers\n${imageLine}`; + if (isNonInteractiveOrCI()) { + updateStatus(message); + await waitForImagePreparation(image); + } else { + await promiseSpinner(waitForImagePreparation(image), { + message, + }); + } + updateStatus(`${imageName} is ready to run\n${imageLine}`); + } + preparedImages.set(source, image); + } + + classImages.push([imageName, image]); + } + imagesByClass.push([container.class_name, Object.fromEntries(classImages)]); + } + + return Object.fromEntries(imagesByClass); +} + +export async function deployDurableObjectContainerApplications( + config: Config, + { + versionId, + accountId, + scriptName, + }: DeployDurableObjectContainerApplicationsArgs +): Promise { + const containers = getDurableObjectContainerApps(config.containers); + if (containers.length === 0) { + return; + } + + await fillOpenAPIConfiguration(config, containersScope); + const resolveNamespaceId = createDurableObjectNamespaceResolver(config, { + versionId, + accountId, + scriptName, + }); + + for (const container of containers) { + const namespaceId = await resolveNamespaceId(container.class_name); + await createApplication(container, namespaceId); + } +} diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index bd01e55d38e..d1d5f5298be 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -7,6 +7,10 @@ import { analyseBundle } from "../check/commands"; import { buildContainer } from "../containers/build"; import { getNormalizedContainerOptions } from "../containers/config"; import { deployContainers } from "../containers/deploy"; +import { + deployDurableObjectContainerApplications, + prepareDurableObjectContainerApplications, +} from "../containers/durable-object-applications"; import { createCommand } from "../core/create-command"; import { sharedDeployVersionsArgs, @@ -203,6 +207,8 @@ export async function runDeployCommandHandler( getNormalizedContainerOptions, buildContainer, deployContainers, + prepareDurableObjectContainerApplications, + deployDurableObjectContainerApplications, analyseBundle, } ); diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 06ee812da47..8972de0f225 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -100,6 +100,7 @@ export interface ConfigBundle { streamingTails: Config["streaming_tail_consumers"] | undefined; testScheduled: boolean; containerDOClassNames: Set | undefined; + containerDefaultImageNames?: Map; containerBuildId: string | undefined; containerEngine: ContainerEngine | undefined; enableContainers: boolean; @@ -484,6 +485,7 @@ type MiniflareBindingsConfig = Pick< | "streamingTails" | "complianceRegion" | "containerDOClassNames" + | "containerDefaultImageNames" | "containerBuildId" | "enableContainers" > & @@ -765,6 +767,7 @@ export function buildMiniflareBindingOptions( ? getImageNameFromDOClassName({ doClassName: className, containerDOClassNames: config.containerDOClassNames, + containerDefaultImageNames: config.containerDefaultImageNames, containerBuildId: config.containerBuildId, }) : undefined, @@ -1049,6 +1052,8 @@ export function buildMiniflareBindingOptions( ? getImageNameFromDOClassName({ doClassName: className, containerDOClassNames: config.containerDOClassNames, + containerDefaultImageNames: + config.containerDefaultImageNames, containerBuildId: config.containerBuildId, }) : undefined, @@ -1221,6 +1226,7 @@ export async function buildMiniflareOptions( export function getImageNameFromDOClassName(options: { doClassName: string; containerDOClassNames: Set; + containerDefaultImageNames?: Map; containerBuildId: string | undefined; }): DOContainerOptions | undefined { assert( @@ -1230,10 +1236,9 @@ export function getImageNameFromDOClassName(options: { if (options.containerDOClassNames.has(options.doClassName)) { return { - imageName: getDevContainerImageName( - options.doClassName, - options.containerBuildId - ), + imageName: + options.containerDefaultImageNames?.get(options.doClassName) ?? + getDevContainerImageName(options.doClassName, options.containerBuildId), }; } } diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 8f95738cbfd..fb03968a29b 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -3,10 +3,12 @@ import * as fs from "node:fs"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; import { RUNTIME_TYPES_MARKER } from "@cloudflare/runtime-types"; import { + CONTAINER_IMAGES_BINDING, CommandLineArgsError, configFileName, experimental_readRawConfig, FatalError, + getDurableObjectContainerApps, parseJSONC, UserError, } from "@cloudflare/workers-utils"; @@ -40,6 +42,9 @@ import type { RawEnvironment, } from "@cloudflare/workers-utils"; +const CONTAINER_IMAGES_BINDING_TYPE = + "Readonly>>>"; + export interface GenerateTypesOptions { /** * Path to the Wrangler config file to use. Can be an array for multi-config type resolution. @@ -2566,6 +2571,15 @@ function collectCoreBindings( if (env.assets?.binding) { addBinding(env.assets.binding, "Fetcher", "assets", envName); } + + if (getDurableObjectContainerApps(env.containers).length > 0) { + addBinding( + CONTAINER_IMAGES_BINDING, + CONTAINER_IMAGES_BINDING_TYPE, + "container_images", + envName + ); + } } const { rawConfig } = experimental_readRawConfig(args); @@ -3574,6 +3588,14 @@ function collectCoreBindingsPerEnvironment( }); } + if (getDurableObjectContainerApps(env.containers).length > 0) { + bindings.push({ + bindingCategory: "container_images", + name: CONTAINER_IMAGES_BINDING, + type: CONTAINER_IMAGES_BINDING_TYPE, + }); + } + for (const [index, aiSearchNamespace] of ( env.ai_search_namespaces ?? [] ).entries()) { diff --git a/packages/wrangler/src/versions/deploy.ts b/packages/wrangler/src/versions/deploy.ts index ea38782ff3e..7325d6651d8 100644 --- a/packages/wrangler/src/versions/deploy.ts +++ b/packages/wrangler/src/versions/deploy.ts @@ -15,6 +15,10 @@ import { } from "@cloudflare/deploy-helpers"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { fetchResult } from "../cfetch"; +import { + deployVersionedDurableObjectContainerApplications, + getVersionedDurableObjectContainerApplications, +} from "../containers/durable-object-applications"; import { createCommand } from "../core/create-command"; import { experimentalNewConfigArg } from "../experimental-config/cli-flag"; import * as metrics from "../metrics"; @@ -26,6 +30,7 @@ import { fetchDeployableVersions, fetchDeploymentVersions, fetchLatestDeployment, + fetchVersion, fetchVersions, patchNonVersionedScriptSettings, } from "./api"; @@ -198,11 +203,30 @@ export const versionsDeployCommand = createCommand({ helpText: "(optional)", }); + const selectedVersions = await Promise.all( + confirmedVersionsToDeploy.map((versionId) => + fetchVersion(config, accountId, workerName, versionId) + ) + ); + const containerApplications = + getVersionedDurableObjectContainerApplications( + selectedVersions, + workerName + ); + if (args.dryRun) { cli.cancel("--dry-run: exiting"); return; } + const pendingContainerApplications = + await deployVersionedDurableObjectContainerApplications(config, { + applications: containerApplications, + accountId, + scriptName: workerName, + allowMissingNamespaces: true, + }); + const start = Date.now(); let deploymentId: string; @@ -247,6 +271,26 @@ export const versionsDeployCommand = createCommand({ throw e; } + // createDeployment reconciles declarative exports before returning. + // Only applications whose namespaces did not exist during preflight + // need to be created after traffic changes. + try { + await deployVersionedDurableObjectContainerApplications(config, { + applications: pendingContainerApplications, + accountId, + scriptName: workerName, + }); + } catch (error) { + throw new UserError( + "The Worker Versions were deployed successfully, but Wrangler could not finish creating their Durable Object-managed Container applications. Re-run the same `wrangler versions deploy` command to retry the idempotent application creation.", + { + telemetryMessage: + "versions deploy durable object container application creation failed after deployment", + cause: error, + } + ); + } + await maybePatchSettings(config, accountId, workerName); const elapsedMilliseconds = Date.now() - start; diff --git a/packages/wrangler/src/versions/types.ts b/packages/wrangler/src/versions/types.ts index 2ad2dce21f6..fa1d803b1c8 100644 --- a/packages/wrangler/src/versions/types.ts +++ b/packages/wrangler/src/versions/types.ts @@ -1,5 +1,6 @@ import type { CfUserLimits, + CfWorkerInit, WorkerMetadataBinding, } from "@cloudflare/workers-utils"; @@ -47,6 +48,7 @@ export type ApiVersion = { compatibility_flags?: string[]; usage_model: "bundled" | "unbound" | "standard"; limits: CfUserLimits; + containers?: CfWorkerInit["containers"]; }; }; startup_time_ms?: number; diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 56bc3dd437d..548399ae9f3 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -3,6 +3,10 @@ import { type AssetUploadStats, } from "@cloudflare/deploy-helpers"; import { analyseBundle } from "../check/commands"; +import { + deployDurableObjectContainerApplications, + prepareDurableObjectContainerApplications, +} from "../containers/durable-object-applications"; import { createCommand } from "../core/create-command"; import { sharedDeployVersionsArgs, @@ -69,6 +73,8 @@ export const versionsUploadCommand = createCommand({ buildResult, { analyseBundle: analyseBundle, + prepareDurableObjectContainerApplications, + deployDurableObjectContainerApplications, } ); assetUploadStats = uploadStats;