Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/fuzzy-pandas-launch.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/tidy-containers-develop.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/containers-shared/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */

export enum ContainerImagePreparationStatus {
PENDING = "pending",
READY = "ready",
ERROR = "error",
}
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */

export type PrepareContainerImageRequestBody = {
image: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -40,7 +41,9 @@ export class ApplicationsService {
* @throws ApiError
*/
public static createApplication(
requestBody: CreateApplicationRequest
requestBody:
| CreateApplicationRequest
| CreateDurableObjectApplicationRequest
): CancelablePromise<Application> {
return __request(OpenAPI, {
method: "POST",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ContainerImagePreparation> {
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`,
},
});
}
}
30 changes: 30 additions & 0 deletions packages/containers-shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
64 changes: 61 additions & 3 deletions packages/deploy-helpers/src/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatTime,
getBindings,
getDockerPath,
getDurableObjectContainerApps,
hasDurableObjectExports,
parseNonHyphenedUuid,
printBindings,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -133,6 +140,18 @@ export type DeployCallbacks = {
args: { versionId: string; accountId: string; scriptName: string }
) => Promise<void>)
| undefined;
prepareDurableObjectContainerApplications:
| ((
config: Config,
args: { dryRun: boolean; scriptName: string }
) => Promise<Record<string, Record<string, string>>>)
| undefined;
deployDurableObjectContainerApplications:
| ((
config: Config,
args: { versionId: string; accountId: string; scriptName: string }
) => Promise<void>)
| undefined;
analyseBundle:
| ((workerBundle: string | FormData) => Promise<Record<string, unknown>>)
| undefined;
Expand Down Expand Up @@ -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 };
}
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -333,7 +379,7 @@ async function deployWorker(
migrations,
exports,
modules,
containers: config.containers,
containers: containerMetadata,
sourceMaps,
compatibility_date: compatibilityDate,
compatibility_flags: compatibilityFlags,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -39,20 +42,22 @@ export async function confirmLatestDeploymentOverwrite(
versionCache
);

return inputPrompt<boolean>({
const confirmed = await inputPrompt<boolean>({
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(
Expand Down
Loading
Loading