From be0f7ea380fba55c7603e26f7a3890bf04c4c5eb Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 4 Aug 2026 21:55:41 +0100 Subject: [PATCH 1/3] [wrangler] Allow containers to be attached to Durable Objects via `exports` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container can now be linked to its Durable Object from the export side, using a new `container` field that names an entry in the `containers` array: "containers": [{ "name": "my-container", "image": "./Dockerfile" }], "exports": { "MyContainerDO": { "type": "durable-object", "storage": "sqlite", "container": "my-container" } } As a result `containers[].class_name` is now optional — a container that is referenced this way only needs a `name`. This decouples container configuration from the Durable Object class, which is a prerequisite for configuring containers as standalone resources. The link is resolved by a shared helper in `@cloudflare/workers-utils`, used by wrangler's container normalization, the Vite plugin's dev/preview container builds, and `unstable_getMiniflareWorkerOptions`. Upload metadata sends both directions as configured (`containers[].name` and `exports[].container`) so that the API can do its own resolution; wrangler only pre-resolves for local dev and Cloudchamber app creation. Cross-field validation now reports dangling `container` references, two exports claiming the same container, the two directions disagreeing, a container linked to no Durable Object, `class_name` with no live export, and duplicate container names. --- .changeset/config-export-container-field.md | 24 + .changeset/containers-attached-via-exports.md | 30 ++ fixtures/container-app/wrangler.jsonc | 15 +- packages/config/src/__tests__/convert.test.ts | 48 ++ packages/config/src/__tests__/schema.test.ts | 52 ++ packages/config/src/convert.ts | 2 + packages/config/src/exports.ts | 13 + packages/config/src/schema.ts | 2 + .../helpers/create-worker-upload-form.ts | 8 +- .../src/__tests__/containers.spec.ts | 87 +++ .../vite-plugin-cloudflare/src/containers.ts | 62 ++- .../src/miniflare-options.ts | 2 + .../workers-utils/src/config/containers.ts | 76 +++ .../workers-utils/src/config/environment.ts | 35 +- packages/workers-utils/src/config/exports.ts | 13 + packages/workers-utils/src/config/index.ts | 5 + .../workers-utils/src/config/validation.ts | 236 +++++++- packages/workers-utils/src/index.ts | 5 + packages/workers-utils/src/types.ts | 4 +- packages/workers-utils/src/worker.ts | 6 +- .../tests/config/containers.test.ts | 129 +++++ .../normalize-and-validate-config.test.ts | 503 +++++++++++++++++- .../e2e/durable-objects-exports.test.ts | 87 +++ .../src/__tests__/containers/config.test.ts | 65 +++ .../src/__tests__/containers/deploy.test.ts | 91 +++- .../src/__tests__/containers/schema.test.ts | 33 ++ .../metadata.test.ts | 36 ++ packages/wrangler/src/__tests__/dev.test.ts | 32 ++ .../__tests__/helpers/mock-upload-worker.ts | 2 +- .../src/api/integrations/platform/index.ts | 11 +- packages/wrangler/src/containers/config.ts | 29 +- 31 files changed, 1660 insertions(+), 83 deletions(-) create mode 100644 .changeset/config-export-container-field.md create mode 100644 .changeset/containers-attached-via-exports.md create mode 100644 packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts create mode 100644 packages/workers-utils/src/config/containers.ts create mode 100644 packages/workers-utils/tests/config/containers.test.ts diff --git a/.changeset/config-export-container-field.md b/.changeset/config-export-container-field.md new file mode 100644 index 00000000000..4f16959cb9f --- /dev/null +++ b/.changeset/config-export-container-field.md @@ -0,0 +1,24 @@ +--- +"@cloudflare/config": minor +--- + +Add a `container` option to `exports.durableObject()` + +Live Durable Object exports can now attach a container by name, matching the new `container` field in the Wrangler configuration format: + +```typescript +import { defineWorker, exports } from "@cloudflare/config"; + +export default defineWorker({ + name: "my-worker", + compatibilityDate: "2026-07-01", + exports: { + MyContainerDO: exports.durableObject({ + storage: "sqlite", + container: "my-container", + }), + }, +}); +``` + +This is an experimental feature: containers themselves are not yet configurable from `cloudflare.config.ts`, so the field is only useful once they are. diff --git a/.changeset/containers-attached-via-exports.md b/.changeset/containers-attached-via-exports.md new file mode 100644 index 00000000000..f9d1b09d220 --- /dev/null +++ b/.changeset/containers-attached-via-exports.md @@ -0,0 +1,30 @@ +--- +"wrangler": minor +"@cloudflare/vite-plugin": minor +--- + +Allow containers to be attached to a Durable Object from its `exports` entry + +A container can now be linked to its Durable Object from the export side, using a new `container` field that names an entry in the `containers` array. As a result `containers[].class_name` is now optional — a container that is referenced this way only needs a `name`: + +```jsonc +{ + "name": "my-worker", + "main": "worker.js", + "compatibility_date": "2026-07-01", + "containers": [ + { "name": "my-container", "image": "./Dockerfile", "max_instances": 1 }, + ], + "exports": { + "MyContainerDO": { + "type": "durable-object", + "storage": "sqlite", + "container": "my-container", + }, + }, +} +``` + +This decouples container configuration from the Durable Object class, which is a prerequisite for configuring containers as standalone resources. The existing `containers[].class_name` direction keeps working, and either direction may be used, but a Durable Object and its container must reference each other consistently when both are set. + +`container` is only valid on live `durable-object` exports (`created` and `expecting-transfer`) and requires `storage: "sqlite"`. Wrangler now also reports an error when a `container` reference names a container that does not exist, when two Durable Object exports claim the same container, when a container ends up linked to no Durable Object at all, and when two containers share a name. diff --git a/fixtures/container-app/wrangler.jsonc b/fixtures/container-app/wrangler.jsonc index 37e82de5b48..cdef708db52 100644 --- a/fixtures/container-app/wrangler.jsonc +++ b/fixtures/container-app/wrangler.jsonc @@ -6,15 +6,18 @@ "containers": [ { "image": "./Dockerfile", - "class_name": "FixtureTestContainer", "name": "container", "max_instances": 2, }, ], - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["FixtureTestContainer"], + // The container has no `class_name`; the Durable Object attaches it by name + // instead. See `wrangler.registry.jsonc` for the `class_name` + `migrations` + // equivalent. + "exports": { + "FixtureTestContainer": { + "type": "durable-object", + "storage": "sqlite", + "container": "container", }, - ], + }, } diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 2af0071a514..2b37c46b344 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -677,6 +677,54 @@ describe("convertToWranglerConfig", () => { }); }); + it("converts an attached container on a live durable-object export", ({ + expect, + }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + }); + expect((result as { exports?: unknown }).exports).toEqual({ + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }); + }); + + it("converts an attached container on an expecting-transfer export", ({ + expect, + }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + exports: { + Incoming: { + type: "durable-object", + state: "expecting-transfer", + storage: "sqlite", + transferFrom: "source-worker", + container: "my-container", + }, + }, + }); + expect((result as { exports?: unknown }).exports).toEqual({ + Incoming: { + type: "durable-object", + state: "expecting-transfer", + storage: "sqlite", + transfer_from: "source-worker", + container: "my-container", + }, + }); + }); + it('treats an explicit `state: "created"` like the default and omits it on the wire', ({ expect, }) => { diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index a1c9798d90c..b06e5965cce 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -724,3 +724,55 @@ describe("ConfigExportsSchema", () => { expect(result.success).toBe(true); }); }); + +describe("ExportSchema", () => { + function parseExports(exports: unknown) { + return InputWorkerSchema.safeParse({ ...baseConfig, exports }); + } + + it("accepts `container` on a live durable-object export", ({ expect }) => { + const result = parseExports({ + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }); + + expect(result.success).toBe(true); + }); + + it("accepts `container` on an expecting-transfer export", ({ expect }) => { + const result = parseExports({ + Incoming: { + type: "durable-object", + state: "expecting-transfer", + storage: "sqlite", + transferFrom: "source-worker", + container: "my-container", + }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects `container` on a tombstone", ({ expect }) => { + const result = parseExports({ + OldDO: { + type: "durable-object", + state: "deleted", + container: "my-container", + }, + }); + + expect(result.success).toBe(false); + }); + + it("rejects a non-string `container`", ({ expect }) => { + const result = parseExports({ + MyDO: { type: "durable-object", storage: "sqlite", container: 1 }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index 6e44eead877..b6c25923be3 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -711,6 +711,7 @@ function convertExports( converted[exportName] = { type: "durable-object", storage: value.storage, + ...(value.container !== undefined && { container: value.container }), }; break; } @@ -743,6 +744,7 @@ function convertExports( state: "expecting-transfer", storage: value.storage, transfer_from: value.transferFrom, + ...(value.container !== undefined && { container: value.container }), }; break; } diff --git a/packages/config/src/exports.ts b/packages/config/src/exports.ts index c01f4393fbe..5b358809947 100644 --- a/packages/config/src/exports.ts +++ b/packages/config/src/exports.ts @@ -30,6 +30,12 @@ export interface DurableObjectCreatedExportOptions { * - `"legacy-kv"`: selects the legacy key-value storage engine. */ storage: "sqlite" | "legacy-kv"; + /** + * Attach a container to this Durable Object, by container name. + * + * Requires `storage: "sqlite"`. + */ + container?: string; } /** @@ -81,6 +87,12 @@ export interface DurableObjectExpectingTransferExportOptions { * The source Worker for the two-phase cross-Worker transfer. */ transferFrom: string; + /** + * Attach a container to this Durable Object, by container name. + * + * Requires `storage: "sqlite"`. + */ + container?: string; } export interface DurableObjectCreatedExport extends DurableObjectCreatedExportOptions { @@ -223,6 +235,7 @@ function worker( * export default defineWorker({ * exports: { * MyDurableObject: exports.durableObject({ storage: "sqlite" }), + * MyContainerDO: exports.durableObject({ storage: "sqlite", container: "my-container" }), * OldClass: exports.durableObject({ state: "deleted" }), * OldName: exports.durableObject({ state: "renamed", renamedTo: "NewName" }), * Outgoing: exports.durableObject({ state: "transferred", transferredTo: "target-worker" }), diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 700b8135850..713297283b8 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -296,6 +296,7 @@ const ExportSchema = z.union([ type: z.literal("durable-object"), state: z.literal("created").optional(), storage: z.enum(["sqlite", "legacy-kv"]), + container: z.string().optional(), }), z.strictObject({ type: z.literal("durable-object"), @@ -316,6 +317,7 @@ const ExportSchema = z.union([ state: z.literal("expecting-transfer"), storage: z.enum(["sqlite", "legacy-kv"]), transferFrom: z.string(), + container: z.string().optional(), }), z.strictObject({ type: z.literal("worker"), 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 5c1943c2780..d56138ff65b 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 @@ -869,10 +869,16 @@ export function createWorkerUploadForm( ? { main_module: main.name } : { body_part: main.name }), bindings: metadataBindings, + // Both directions of the container/Durable Object link are sent as + // configured: the API resolves a container's Durable Object from either this + // `class_name` or an `exports` entry naming the container by `name`. containers: worker.containers === undefined ? undefined - : worker.containers.map((c) => ({ class_name: c.class_name })), + : worker.containers.map((c) => ({ + ...(c.name !== undefined && { name: c.name }), + ...(c.class_name !== undefined && { class_name: c.class_name }), + })), ...(compatibility_date && { compatibility_date }), ...(compatibility_flags && { diff --git a/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts new file mode 100644 index 00000000000..494a8333206 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts @@ -0,0 +1,87 @@ +import { describe, test } from "vitest"; +import { getContainerOptions } from "../containers"; +import type { ResolvedWorkerConfig } from "../plugin-config"; + +type Containers = ResolvedWorkerConfig["containers"]; +type Exports = ResolvedWorkerConfig["exports"]; + +describe("getContainerOptions", () => { + test("returns undefined when no containers are configured", ({ expect }) => { + expect( + getContainerOptions({ + containersConfig: undefined, + exports: {}, + containerBuildId: "build-id", + }) + ).toBeUndefined(); + }); + + test("uses the container's own class_name when set", ({ expect }) => { + const containersConfig: Containers = [ + { + name: "my-container", + class_name: "MyDO", + image: "registry.cloudflare.com/hello:world", + }, + ]; + + expect( + getContainerOptions({ + containersConfig, + exports: {}, + containerBuildId: "build-id", + }) + ).toEqual([ + { + image_uri: "registry.cloudflare.com/hello:world", + class_name: "MyDO", + image_tag: "cloudflare-dev/mydo:build-id", + }, + ]); + }); + + test("resolves class_name from a durable object export that references the container", ({ + expect, + }) => { + const containersConfig: Containers = [ + { name: "my-container", image: "registry.cloudflare.com/hello:world" }, + ]; + const exports: Exports = { + MyContainerDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }; + + expect( + getContainerOptions({ + containersConfig, + exports, + containerBuildId: "build-id", + }) + ).toEqual([ + { + image_uri: "registry.cloudflare.com/hello:world", + class_name: "MyContainerDO", + image_tag: "cloudflare-dev/mycontainerdo:build-id", + }, + ]); + }); + + test("skips containers that are not linked to a durable object", ({ + expect, + }) => { + const containersConfig: Containers = [ + { name: "my-container", image: "registry.cloudflare.com/hello:world" }, + ]; + + expect( + getContainerOptions({ + containersConfig, + exports: {}, + containerBuildId: "build-id", + }) + ).toEqual([]); + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/containers.ts b/packages/vite-plugin-cloudflare/src/containers.ts index db447e0bfc7..659d23fe012 100644 --- a/packages/vite-plugin-cloudflare/src/containers.ts +++ b/packages/vite-plugin-cloudflare/src/containers.ts @@ -1,6 +1,9 @@ import path from "node:path"; import { getDevContainerImageName } from "@cloudflare/containers-shared/src/knobs"; -import { isDockerfile } from "@cloudflare/workers-utils"; +import { + isDockerfile, + resolveContainerClassName, +} from "@cloudflare/workers-utils"; import type { ResolvedWorkerConfig } from "./plugin-config"; /** @@ -22,39 +25,46 @@ export function getDockerPath(): string { */ export function getContainerOptions(options: { containersConfig: ResolvedWorkerConfig["containers"]; + exports: ResolvedWorkerConfig["exports"]; containerBuildId: string; configPath?: string; }) { - const { containersConfig, containerBuildId, configPath } = options; + const { containersConfig, exports, containerBuildId, configPath } = options; if (!containersConfig?.length) { return undefined; } - return containersConfig.map((container) => { - if (isDockerfile(container.image, configPath)) { - return { - dockerfile: container.image, - image_build_context: - container.image_build_context ?? path.dirname(container.image), - image_vars: container.image_vars, - class_name: container.class_name, - image_tag: getDevContainerImageName( - container.class_name, - containerBuildId - ), - }; - } else { - return { - image_uri: container.image, - class_name: container.class_name, - image_tag: getDevContainerImageName( - container.class_name, - containerBuildId - ), - }; - } - }); + return containersConfig + .map((container) => { + // 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. + const className = resolveContainerClassName(container, exports); + if (className === undefined) { + return undefined; + } + + const image_tag = getDevContainerImageName(className, containerBuildId); + + if (isDockerfile(container.image, configPath)) { + return { + dockerfile: container.image, + image_build_context: + container.image_build_context ?? path.dirname(container.image), + image_vars: container.image_vars, + class_name: className, + image_tag, + }; + } else { + return { + image_uri: container.image, + class_name: className, + image_tag, + }; + } + }) + .filter((container) => container !== undefined); } export type ContainerTagToOptionsMap = Map< diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 46ceb863a6f..70f54565a89 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -387,6 +387,7 @@ export async function getDevMiniflareOptions( const options = getContainerOptions({ containersConfig: worker.config.containers, + exports: worker.config.exports, containerBuildId, configPath: worker.config.configPath, }); @@ -804,6 +805,7 @@ export async function getPreviewMiniflareOptions( const options = getContainerOptions({ containersConfig: workerConfig.containers, + exports: workerConfig.exports, containerBuildId, configPath: workerConfig.configPath, }); diff --git a/packages/workers-utils/src/config/containers.ts b/packages/workers-utils/src/config/containers.ts new file mode 100644 index 00000000000..e89589af1ca --- /dev/null +++ b/packages/workers-utils/src/config/containers.ts @@ -0,0 +1,76 @@ +import { getDurableObjectExports } from "./durable-object-exports"; +import type { ContainerApp, Exports } from "./environment"; + +/** + * A container can be linked to a Durable Object from either direction: + * + * - the container names the class via `containers[].class_name`, or + * - the Durable Object names the container via `exports[Class].container`. + * + * This returns the second direction as a lookup of container name to Durable + * Object class name. Only live `durable-object` exports can attach a container, + * so tombstones are ignored. + * + * When two exports name the same container the first wins. That is a config + * error caught during validation, so the choice only affects which class a + * rejected config reports. + */ +export function getContainerNameToClassNameMap( + exports: Exports | undefined +): Map { + const containerNameToClassName = new Map(); + + for (const [className, entry] of Object.entries( + getDurableObjectExports(exports) + )) { + if ( + "container" in entry && + typeof entry.container === "string" && + !containerNameToClassName.has(entry.container) + ) { + containerNameToClassName.set(entry.container, className); + } + } + + return containerNameToClassName; +} + +/** + * The Durable Object class a container backs, resolved from either direction of + * the container/Durable Object link. + * + * Returns `undefined` when the container is not linked to a Durable Object at + * all, which validation rejects. + */ +export function resolveContainerClassName( + container: Pick, + exports: Exports | undefined +): string | undefined { + if (container.class_name !== undefined) { + return container.class_name; + } + if (container.name === undefined) { + return undefined; + } + return getContainerNameToClassNameMap(exports).get(container.name); +} + +/** + * The set of Durable Object class names that have a container attached, resolved + * from either direction of the container/Durable Object link. + */ +export function getContainerDurableObjectClassNames( + containers: ContainerApp[] | undefined, + exports: Exports | undefined +): Set { + const classNames = new Set(); + + for (const container of containers ?? []) { + const className = resolveContainerClassName(container, exports); + if (className !== undefined) { + classNames.add(className); + } + } + + return classNames; +} diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 34b7fe79f08..72fa854e239 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -104,8 +104,14 @@ export type ContainerApp = { // TODO: fill out the entire type /** - * Name of the application - * @optional Defaults to `worker_name-class_name` if not specified. + * Name of the application. + * + * This is also the identifier used to reference the container from a Durable + * Object's `exports` entry via its `container` field. + * + * @optional Defaults to `worker_name-class_name` if not specified. A name is + * required when `class_name` is not set, since there is no class name to + * derive the default from. */ name?: string; @@ -143,8 +149,12 @@ export type ContainerApp = { /** * The class name of the Durable Object the container is connected to. + * + * @optional Instead of naming the Durable Object here, you can reference this + * container from the Durable Object's `exports` entry via its `container` + * field. Exactly one of the two directions must be configured. */ - class_name: string; + class_name?: string; /** * The scheduling policy of the application @@ -382,12 +392,23 @@ export type DurableObjectExportStorage = "sqlite" | "legacy-kv"; * script via `transfer_from`. * - `expecting-transfer` (live): receiving side of a two-phase transfer; * `storage` and `transfer_from` are both required. + * + * The live states may additionally attach a container via `container`, which + * names an entry in the top-level `containers` array. Tombstones cannot. */ export type DurableObjectExport = | { type: "durable-object"; state?: "created"; storage: DurableObjectExportStorage; + /** + * Attach a container to this Durable Object. Must match the `name` of an + * entry in the top-level `containers` array, and requires + * `storage: "sqlite"`. + * + * @optional + */ + container?: string; } | { type: "durable-object"; state: "deleted" } | { type: "durable-object"; state: "renamed"; renamed_to: string } @@ -401,6 +422,14 @@ export type DurableObjectExport = state: "expecting-transfer"; storage: DurableObjectExportStorage; transfer_from: string; + /** + * Attach a container to this Durable Object. Must match the `name` of an + * entry in the top-level `containers` array, and requires + * `storage: "sqlite"`. + * + * @optional + */ + container?: string; }; export interface WorkerEntrypointExport { diff --git a/packages/workers-utils/src/config/exports.ts b/packages/workers-utils/src/config/exports.ts index 88514db9b45..d5b185d05c4 100644 --- a/packages/workers-utils/src/config/exports.ts +++ b/packages/workers-utils/src/config/exports.ts @@ -11,6 +11,16 @@ export interface PartitionedExports { worker: Record; } +/** + * Entries with an unknown `type`, and entries that are not objects at all, are + * reported by config validation. This lets us skip them rather than crash, so + * that callers can run against a config that has failed validation. + */ +function hasKnownExportType(entry: unknown): entry is Exports[string] { + const type = (entry as Exports[string] | null | undefined)?.type; + return type === "durable-object" || type === "worker"; +} + export function partitionExports( exports: Exports | undefined ): PartitionedExports { @@ -24,6 +34,9 @@ export function partitionExports( } for (const [name, entry] of Object.entries(exports)) { + if (!hasKnownExportType(entry)) { + continue; + } partitioned[entry.type][name] = entry; } diff --git a/packages/workers-utils/src/config/index.ts b/packages/workers-utils/src/config/index.ts index 21f8278d434..f67fe837a38 100644 --- a/packages/workers-utils/src/config/index.ts +++ b/packages/workers-utils/src/config/index.ts @@ -40,6 +40,11 @@ export type { } from "./environment"; export { partitionExports } from "./exports"; export type { ExportType, PartitionedExports } from "./exports"; +export { + getContainerDurableObjectClassNames, + getContainerNameToClassNameMap, + resolveContainerClassName, +} 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 0f17322d254..642cad24148 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -7,7 +7,9 @@ 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 { Diagnostics } from "./diagnostics"; +import { getDurableObjectExports } from "./durable-object-exports"; import { ARTIFACTS_EVENT_TYPES } from "./environment"; import { all, @@ -2193,6 +2195,13 @@ function normalizeAndValidateEnvironment( environment.exports ); + validateContainerExportLinks( + diagnostics, + environment.containers, + environment.exports, + rawConfig?.containers !== undefined + ); + // top level 'rawEnv' includes inheritable keys and is validated elsewhere if (envName !== "top level") { validateAdditionalProperties( @@ -3384,14 +3393,7 @@ function validateContainerApp( } for (const containerAppOptional of value) { - // validate that either a name is set and is a string - if (!isOptionalProperty(value, "name", "string")) { - diagnostics.errors.push( - `Field "name", when present, should be a string, but got ${JSON.stringify(value)}` - ); - } - - validateRequiredProperty( + validateOptionalProperty( diagnostics, field, "class_name", @@ -3407,23 +3409,27 @@ function validateContainerApp( ); // try and add a default name if (!containerAppOptional.name) { - // we need topLevelName and a containers.class_name if containers.name is not defined - if ( - !topLevelName || - !isOptionalProperty(containerAppOptional, "class_name", "string") - ) { + // 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. + if (containerAppOptional.class_name === undefined) { + diagnostics.errors.push( + `"containers.name" is required when "containers.class_name" is not defined, because there is no class name to derive a default name from. Either name this container and reference it from a Durable Object's \`exports\` entry, or set "containers.class_name".` + ); + } else if (!topLevelName) { diagnostics.errors.push( `Must have either a top level "name" and "containers.class_name" field defined, or have field "containers.name" defined.` ); + } else { + // if there is worker name defined but no name for this container app default to: + // worker_name-class_name[-envName]. + let name = `${topLevelName}-${containerAppOptional.class_name}`; + // config is undefined when we are at the top level instead of in a named env + // If we are in a named env, append it to the generated name + // so that users can re-use container definitions between different envs without issue. + name += config === undefined ? "" : `-${envName}`; + containerAppOptional.name = name.toLowerCase().replace(/ /g, "-"); } - // if there is worker name defined but no name for this container app default to: - // worker_name-class_name[-envName]. - let name = `${topLevelName}-${containerAppOptional.class_name}`; - // config is undefined when we are at the top level instead of in a named env - // If we are in a named env, append it to the generated name - // so that users can re-use container definitions between different envs without issue. - name += config === undefined ? "" : `-${envName}`; - containerAppOptional.name = name.toLowerCase().replace(/ /g, "-"); } if ( !containerAppOptional.configuration?.image && @@ -6099,6 +6105,44 @@ function validateDurableObjectExportProperties( return valid; } +/** + * Validate the `container` field of a live Durable Object export. The reference + * itself is cross-checked against the `containers` array by + * {@link validateContainerExportLinks}; here we only check the shape and the + * storage backend, since containers require SQLite-backed Durable Objects. + */ +function validateDurableObjectExportContainer( + diagnostics: Diagnostics, + className: string, + durableObjectExport: { + container?: unknown; + storage?: unknown; + } +): boolean { + if (durableObjectExport.container === undefined) { + return true; + } + + if ( + typeof durableObjectExport.container !== "string" || + durableObjectExport.container === "" + ) { + diagnostics.errors.push( + `"exports.${className}.container" must be a non-empty string naming a container in the "containers" array, but got ${JSON.stringify(durableObjectExport.container)}.` + ); + return false; + } + + if (durableObjectExport.storage === "legacy-kv") { + diagnostics.errors.push( + `"exports.${className}.container" requires "storage" to be "sqlite". Containers are not supported on Durable Objects using the "legacy-kv" storage backend.` + ); + return false; + } + + return true; +} + /** * Validate a Durable Object `exports` configuration. * @@ -6133,12 +6177,18 @@ function validateDurableObjectExport( ); valid = false; } + valid = + validateDurableObjectExportContainer( + diagnostics, + className, + durableObjectExport + ) && valid; valid = validateDurableObjectExportProperties( diagnostics, className, durableObjectExport, - ["type", "state", "storage"] + ["type", "state", "storage", "container"] ) && valid; break; } @@ -6222,12 +6272,18 @@ function validateDurableObjectExport( ); valid = false; } + valid = + validateDurableObjectExportContainer( + diagnostics, + className, + durableObjectExport + ) && valid; valid = validateDurableObjectExportProperties( diagnostics, className, durableObjectExport, - ["type", "state", "storage", "transfer_from"] + ["type", "state", "storage", "transfer_from", "container"] ) && valid; break; } @@ -6662,6 +6718,140 @@ function errorIfMigrationsAndExportsBothSet( } } +/** + * A container is linked to a Durable Object from exactly one direction: either + * the container names the class via `containers[].class_name`, or the Durable + * Object names the container via `exports[Class].container`. Validate that the + * two arrays agree. + * + * Note that several containers may share a `class_name` — a Durable Object can + * be backed by more than one container — but a container backs at most one + * Durable Object. + */ +function validateContainerExportLinks( + diagnostics: Diagnostics, + containers: Config["containers"], + exports: Config["exports"], + topLevelDeclaresContainers: boolean +) { + if (containers !== undefined && !Array.isArray(containers)) { + // `validateContainerApp` has already reported the non-array `containers`. + return; + } + + if (containers === undefined && topLevelDeclaresContainers) { + // `containers` is not inherited by named environments and `notInheritable` + // has already warned that this one is missing it. Cross-checking inherited + // `exports` against an empty container list would only add noise. + return; + } + + const containersByName = new Map(); + const duplicateNames = new Set(); + for (const container of containers ?? []) { + // A non-string name has already been reported by `validateContainerApp`. + if (typeof container.name !== "string") { + continue; + } + if (containersByName.has(container.name)) { + duplicateNames.add(container.name); + } else { + containersByName.set(container.name, container); + } + } + for (const name of [...duplicateNames].sort()) { + diagnostics.errors.push( + `"containers" contains more than one container named "${name}". Container names must be unique.` + ); + } + + const durableObjectExports = getDurableObjectExports(exports); + const liveExportClassNames = new Set(); + const classNamesByContainerName = new Map(); + + for (const [className, entry] of Object.entries(durableObjectExports)) { + if ( + entry.state !== undefined && + entry.state !== "created" && + entry.state !== "expecting-transfer" + ) { + // `container` is forbidden on tombstones, which is reported separately. + continue; + } + liveExportClassNames.add(className); + + // A non-string container reference has already been reported by + // `validateDurableObjectExportContainer`. + if (typeof entry.container !== "string" || entry.container === "") { + continue; + } + if (!containersByName.has(entry.container)) { + diagnostics.errors.push( + `"exports.${className}.container" references a container named "${entry.container}", but no container with that name is defined in "containers".` + ); + continue; + } + classNamesByContainerName.set(entry.container, [ + ...(classNamesByContainerName.get(entry.container) ?? []), + className, + ]); + } + + for (const [containerName, classNames] of classNamesByContainerName) { + if (classNames.length > 1) { + diagnostics.errors.push( + `The container "${containerName}" is referenced by more than one Durable Object export (${classNames.join(", ")}). A container can only back a single Durable Object.` + ); + } + } + + const containerNameToClassName = getContainerNameToClassNameMap(exports); + const usesDurableObjectExports = Object.keys(durableObjectExports).length > 0; + + for (const container of containers ?? []) { + if (typeof container.name !== "string") { + continue; + } + + if (container.class_name === undefined) { + if (!containerNameToClassName.has(container.name)) { + diagnostics.errors.push( + `The container "${container.name}" is not linked to a Durable Object. Either set "containers.class_name", or reference this container from a Durable Object's \`exports\` entry via its "container" field.` + ); + } + continue; + } + + const exportEntry = durableObjectExports[container.class_name]; + const referencedContainerName = + exportEntry !== undefined && "container" in exportEntry + ? exportEntry.container + : undefined; + + if ( + typeof referencedContainerName === "string" && + referencedContainerName !== container.name + ) { + diagnostics.errors.push( + `The container "${container.name}" sets "class_name" to "${container.class_name}", but "exports.${container.class_name}.container" is "${referencedContainerName}". A Durable Object and its container must reference each other consistently.` + ); + continue; + } + + // Only enforced when the declarative `exports` flow is in use. The legacy + // `migrations` flow silently ignores containers whose class it does not know + // about, and we must not break those configs. + if ( + usesDurableObjectExports && + !liveExportClassNames.has(container.class_name) + ) { + diagnostics.errors.push( + `The container "${container.name}" sets "class_name" to "${container.class_name}", but "exports" has no live "durable-object" entry for "${container.class_name}".` + ); + } + } +} + const validatePythonModules: ValidatorFn = ( diagnostics, field, diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index abdaf480490..cd748b0b918 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -13,6 +13,11 @@ export { getDurableObjectExports, hasDurableObjectExports, } from "./config/durable-object-exports"; +export { + getContainerDurableObjectClassNames, + getContainerNameToClassNameMap, + resolveContainerClassName, +} from "./config/containers"; export { type RedirectedRawConfig, defaultWranglerConfig, diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index b8ecf7eee6e..d0b899dc8de 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -292,7 +292,9 @@ type WorkerMetadataPut = { config?: AssetConfigMetadata; }; observability?: Observability | undefined; - containers?: { class_name: string }[]; + // `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 }[]; package_dependencies?: Array<{ name: string; packageJsonVersion: string; diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 1d3a75c9bdf..0ced23b3bd3 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -479,7 +479,11 @@ export interface CfWorkerInit { */ sourceMaps: CfWorkerSourceMap[] | undefined; - containers: { class_name: string }[] | undefined; + /** + * 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; migrations: CfDurableObjectMigrations | undefined; /** diff --git a/packages/workers-utils/tests/config/containers.test.ts b/packages/workers-utils/tests/config/containers.test.ts new file mode 100644 index 00000000000..de701291373 --- /dev/null +++ b/packages/workers-utils/tests/config/containers.test.ts @@ -0,0 +1,129 @@ +import { describe, test } from "vitest"; +import { + getContainerDurableObjectClassNames, + getContainerNameToClassNameMap, + resolveContainerClassName, +} from "../../src/config/containers"; +import type { ContainerApp, Exports } from "../../src/config/environment"; + +function container(props: Partial): ContainerApp { + return { image: "./Dockerfile", ...props }; +} + +describe("getContainerNameToClassNameMap", () => { + test("returns an empty map when exports are undefined", ({ expect }) => { + expect(getContainerNameToClassNameMap(undefined)).toEqual(new Map()); + }); + + test("ignores exports that do not attach a container", ({ expect }) => { + const exports: Exports = { + Counter: { type: "durable-object", storage: "sqlite" }, + Admin: { type: "worker" }, + }; + + expect(getContainerNameToClassNameMap(exports)).toEqual(new Map()); + }); + + test("maps container names to their Durable Object class names", ({ + expect, + }) => { + const exports: Exports = { + Counter: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + Incoming: { + type: "durable-object", + state: "expecting-transfer", + storage: "sqlite", + transfer_from: "other-worker", + container: "incoming-container", + }, + }; + + expect(getContainerNameToClassNameMap(exports)).toEqual( + new Map([ + ["my-container", "Counter"], + ["incoming-container", "Incoming"], + ]) + ); + }); +}); + +describe("resolveContainerClassName", () => { + test("prefers the container's own class_name", ({ expect }) => { + expect( + resolveContainerClassName( + container({ name: "my-container", class_name: "Counter" }), + { + Other: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + } + ) + ).toBe("Counter"); + }); + + test("falls back to the export that references the container by name", ({ + expect, + }) => { + expect( + resolveContainerClassName(container({ name: "my-container" }), { + Counter: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }) + ).toBe("Counter"); + }); + + test("returns undefined when the container is not linked to a Durable Object", ({ + expect, + }) => { + expect( + resolveContainerClassName(container({ name: "my-container" }), { + Counter: { type: "durable-object", storage: "sqlite" }, + }) + ).toBeUndefined(); + }); + + test("returns undefined when the container has neither a name nor a class_name", ({ + expect, + }) => { + expect(resolveContainerClassName(container({}), {})).toBeUndefined(); + }); +}); + +describe("getContainerDurableObjectClassNames", () => { + test("returns an empty set when there are no containers", ({ expect }) => { + expect(getContainerDurableObjectClassNames(undefined, {})).toEqual( + new Set() + ); + }); + + test("resolves class names from both directions of the link", ({ + expect, + }) => { + const containers = [ + container({ name: "bound", class_name: "Bound" }), + container({ name: "referenced" }), + container({ name: "unlinked" }), + ]; + const exports: Exports = { + Bound: { type: "durable-object", storage: "sqlite" }, + Referenced: { + type: "durable-object", + storage: "sqlite", + container: "referenced", + }, + }; + + expect(getContainerDurableObjectClassNames(containers, exports)).toEqual( + new Set(["Bound", "Referenced"]) + ); + }); +}); 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 fdc773a571a..f221aded5a8 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 @@ -2408,7 +2408,7 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "exports.MyDO.transfer_from" is forbidden on state "created". - - Allowed properties are: type, state, and storage." + - Allowed properties are: type, state, storage, and container." `); }); @@ -2568,6 +2568,438 @@ describe("normalizeAndValidateConfig()", () => { expect(rendered).toContain('"storage": "sqlite"'); expect(rendered).not.toContain("new_sqlite_classes"); }); + + it("accepts `container` on a live `created` entry", ({ expect }) => { + const { config, diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + expect(config.exports.MyDO).toEqual({ + type: "durable-object", + storage: "sqlite", + container: "my-container", + }); + }); + + it("accepts `container` on a live `expecting-transfer` entry", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + state: "expecting-transfer", + storage: "sqlite", + transfer_from: "other-worker", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("errors when `container` is not a non-empty string", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "exports.MyDO.container" must be a non-empty string naming a container in the "containers" array, but got ""." + `); + }); + + it("errors when `container` is combined with `legacy-kv` storage", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "legacy-kv", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "exports.MyDO.container" requires "storage" to be "sqlite". Containers are not supported on Durable Objects using the "legacy-kv" storage backend." + `); + }); + + for (const state of ["deleted", "renamed", "transferred"] as const) { + it(`errors when \`container\` is set on a ${state} tombstone`, ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + state, + renamed_to: "NewDO", + transferred_to: "other-worker", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toContain( + `"exports.MyDO.container" is forbidden on state "${state}".` + ); + }); + } + }); + + describe("[containers] linked via `exports`", () => { + it("errors when `container` names a container that does not exist", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "missing-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "exports.MyDO.container" references a container named "missing-container", but no container with that name is defined in "containers". + - The container "my-container" is not linked to a Durable Object. Either set "containers.class_name", or reference this container from a Durable Object's \`exports\` entry via its "container" field." + `); + }); + + it("errors when two Durable Object exports reference the same container", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + OtherDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The container "my-container" is referenced by more than one Durable Object export (MyDO, OtherDO). A container can only back a single Durable Object." + `); + }); + + it("errors when the container and export reference each other inconsistently", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "container-a", + image: "registry.cloudflare.com/something:hello", + class_name: "MyDO", + }, + { + name: "container-b", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "container-b", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The container "container-a" sets "class_name" to "MyDO", but "exports.MyDO.container" is "container-b". A Durable Object and its container must reference each other consistently." + `); + }); + + it("allows a consistent round trip between a container and its export", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + class_name: "MyDO", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("allows several containers to share a class_name", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "container-a", + image: "registry.cloudflare.com/something:hello", + class_name: "MyDO", + }, + { + name: "container-b", + image: "registry.cloudflare.com/something:hello", + class_name: "MyDO", + }, + ], + exports: { + MyDO: { type: "durable-object", storage: "sqlite" }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("errors when a class_name has no live Durable Object export", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + class_name: "Gone", + }, + ], + exports: { + Gone: { type: "durable-object", state: "deleted" }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The container "my-container" sets "class_name" to "Gone", but "exports" has no live "durable-object" entry for "Gone"." + `); + }); + + it("does not check class_name against `exports` when using `migrations`", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + class_name: "MyDO", + }, + ], + migrations: [{ tag: "v1", new_sqlite_classes: ["MyDO"] }], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("errors when two containers share a name", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + class_name: "MyDO", + }, + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + class_name: "OtherDO", + }, + ], + exports: { + MyDO: { type: "durable-object", storage: "sqlite" }, + OtherDO: { type: "durable-object", storage: "sqlite" }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "containers" contains more than one container named "my-container". Container names must be unique." + `); + }); + + it("does not report dangling `container` references in an environment that does not redeclare `containers`", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + name: "my-worker", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + env: { staging: {} }, + } as unknown as RawConfig, + undefined, + undefined, + { env: "staging" } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.renderWarnings()).toContain( + `"containers" exists at the top level, but not on "env.staging"` + ); + }); }); describe("[assets]", () => { @@ -3664,7 +4096,29 @@ describe("normalizeAndValidateConfig()", () => { `); }); - it("should error if no containers name and no worker name are provided", ({ + it("should error if neither a container name nor a class_name is provided", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + containers: [ + { + image: "registry.cloudflare.com/something:hello", + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "containers.name" is required when "containers.class_name" is not defined, because there is no class name to derive a default name from. Either name this container and reference it from a Durable Object's \`exports\` entry, or set "containers.class_name"." + `); + }); + + it("should error if a class_name is provided but there is no container name and no worker name", ({ expect, }) => { const { diagnostics } = normalizeAndValidateConfig( @@ -3672,6 +4126,7 @@ describe("normalizeAndValidateConfig()", () => { containers: [ { image: "registry.cloudflare.com/something:hello", + class_name: "test-class", }, ], } as unknown as RawConfig, @@ -3682,11 +4137,53 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasWarnings()).toBe(false); expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - - "containers.class_name" is a required field. - Must have either a top level "name" and "containers.class_name" field defined, or have field "containers.name" defined." `); }); + it("should accept a container with no class_name when a Durable Object export references it by name", ({ + expect, + }) => { + const { diagnostics, config } = normalizeAndValidateConfig( + { + name: "test-worker-name", + containers: [ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + }, + ], + exports: { + MyContainerDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + expect(config.containers).toEqual([ + { + name: "my-container", + image: "registry.cloudflare.com/something:hello", + image_build_context: undefined, + }, + ]); + expect(config.exports).toEqual({ + MyContainerDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }); + }); + it("should provide a name if no container name is provided and worker name exists", ({ expect, }) => { diff --git a/packages/wrangler/e2e/durable-objects-exports.test.ts b/packages/wrangler/e2e/durable-objects-exports.test.ts index 6c429a74a18..c2cd419fcde 100644 --- a/packages/wrangler/e2e/durable-objects-exports.test.ts +++ b/packages/wrangler/e2e/durable-objects-exports.test.ts @@ -529,5 +529,92 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( expect(output.stdout).toContain("SUCCESS"); }); }); + + describe("containers attached via `exports`", () => { + const workerName = generateResourceName(); + const helper = new WranglerE2ETestHelper(); + + it("accepts a container that is referenced from a Durable Object export", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "${workerName}", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "compatibility_flags": ["enable_ctx_exports"], + "containers": [ + { + "name": "${workerName}-container", + "image": "registry.cloudflare.com/hello:world", + "max_instances": 1, + }, + ], + "exports": { + "MyContainerDO": { + "type": "durable-object", + "storage": "sqlite", + "container": "${workerName}-container", + }, + }, + } + `, + "src/index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + export class MyContainerDO extends DurableObject {} + export default { + fetch() { return new Response("hello"); }, + }; + `, + "package.json": dedent` + { + "name": "${workerName}", + "version": "0.0.0", + "private": true + } + `, + }); + + const output = await helper.run(`wrangler deploy --dry-run`); + + expect(output.stdout).toContain( + "The following containers are available:" + ); + expect(output.stdout).toContain(`${workerName}-container`); + expect(output.stderr).toBe(""); + }); + + it("rejects a container that is not linked to a Durable Object", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "${workerName}", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + "name": "${workerName}-container", + "image": "registry.cloudflare.com/hello:world", + "max_instances": 1, + }, + ], + "exports": { + "MyContainerDO": { "type": "durable-object", "storage": "sqlite" }, + }, + } + `, + }); + + const output = await helper.run(`wrangler deploy --dry-run`); + + expect(output.status).not.toBe(0); + expect(output.stderr).toContain( + `The container "${workerName}-container" is not linked to a Durable Object` + ); + }); + }); } ); diff --git a/packages/wrangler/src/__tests__/containers/config.test.ts b/packages/wrangler/src/__tests__/containers/config.test.ts index d39617bccf6..bcb23224537 100644 --- a/packages/wrangler/src/__tests__/containers/config.test.ts +++ b/packages/wrangler/src/__tests__/containers/config.test.ts @@ -81,6 +81,71 @@ describe("getNormalizedContainerOptions", () => { ); }); + it("should resolve class_name from a durable object export that references the container by name", async ({ + expect, + }) => { + const config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + image: "registry.cloudflare.com/hello:world", + name: "my-container", + }, + ], + exports: { + MyContainerDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + durable_objects: { + bindings: [], + }, + } as Partial as Config; + + const result = await getNormalizedContainerOptions(config, { + dryRun: true, + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "my-container", + class_name: "MyContainerDO", + }); + }); + + it("should throw error when a container is not linked to any durable object", async ({ + expect, + }) => { + const config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + image: "registry.cloudflare.com/hello:world", + name: "my-container", + }, + ], + exports: { + MyContainerDO: { type: "durable-object", storage: "sqlite" }, + }, + durable_objects: { + bindings: [], + }, + } as Partial as Config; + + await expect( + getNormalizedContainerOptions(config, { dryRun: true }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The container "my-container" is not linked to a Durable Object. Either set "containers.class_name", or reference this container from a Durable Object's \`exports\` entry via its "container" field.]` + ); + }); + it("should throw error when durable object has script_name defined", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/containers/deploy.test.ts b/packages/wrangler/src/__tests__/containers/deploy.test.ts index 87d0115c5d8..0a9d4e44194 100644 --- a/packages/wrangler/src/__tests__/containers/deploy.test.ts +++ b/packages/wrangler/src/__tests__/containers/deploy.test.ts @@ -805,8 +805,8 @@ describe("wrangler deploy with containers", () => { ], useOldUploadApi: true, expectedContainers: [ - { class_name: "ExampleDurableObject" }, - { class_name: "DurableObjectClass2" }, + { name: "my-container", class_name: "ExampleDurableObject" }, + { name: "my-container-app-2", class_name: "DurableObjectClass2" }, ], }); writeWranglerConfig({ @@ -2365,7 +2365,9 @@ describe("wrangler deploy with containers", () => { mockUploadWorkerRequest({ expectedBindings: [], useOldUploadApi: true, - expectedContainers: [{ class_name: "ExampleDurableObject" }], + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], }); mockCreateApplication(expect, { name: "my-container", @@ -2428,6 +2430,73 @@ describe("wrangler deploy with containers", () => { `); }); + it("should be able to deploy a container referenced from a declarative durable object export", async ({ + expect, + }) => { + writeWranglerConfig({ + // The container carries no `class_name`; the Durable Object's `exports` + // entry references it by name instead. + containers: [ + { + name: "my-container", + max_instances: 10, + image: "registry.cloudflare.com/hello:world", + rollout_active_grace_period: 600, + }, + ], + exports: { + ExampleDurableObject: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + }); + + mockGetApplications([]); + mockListDurableObjects([ + { + id: "some-id", + name: "name", + script: "test-name", + class: "ExampleDurableObject", + }, + ]); + mockUploadWorkerRequest({ + expectedBindings: [], + useOldUploadApi: true, + // Both sides of the link are sent as configured: the container has no + // `class_name`, and the `exports` entry names the container. + expectedContainers: [{ name: "my-container" }], + expectedExports: { + ExampleDurableObject: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + expectedMigrations: undefined, + }); + mockCreateApplication(expect, { + name: "my-container", + max_instances: 10, + scheduling_policy: SchedulingPolicy.DEFAULT, + rollout_active_grace_period: 600, + durable_objects: { + namespace_id: "some-id", + }, + }); + + await runWrangler("deploy index.js"); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(cliStd.stdout).toContain("NEW my-container"); + expect(cliStd.stdout).toContain( + "SUCCESS Created application my-container" + ); + }); + it("should error if a container name has been used before but attached to a different DO", async ({ expect, }) => { @@ -2483,7 +2552,9 @@ describe("wrangler deploy with containers", () => { mockUploadWorkerRequest({ expectedBindings: [], useOldUploadApi: true, - expectedContainers: [{ class_name: "ExampleDurableObject" }], + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], }); await expect( @@ -2510,7 +2581,9 @@ describe("wrangler deploy with containers", () => { mockUploadWorkerRequest({ expectedBindings: [], useOldUploadApi: true, - expectedContainers: [{ class_name: "ExampleDurableObject" }], + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], }); mockListDurableObjects([ { @@ -2761,7 +2834,9 @@ describe("wrangler deploy with containers and dispatch namespace", () => { ], useOldUploadApi: true, expectedDispatchNamespace: "test-namespace", - expectedContainers: [{ class_name: "ExampleDurableObject" }], + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], }); fs.writeFileSync( "index.js", @@ -3029,7 +3104,9 @@ function setupCommonMocks() { }, ], useOldUploadApi: true, - expectedContainers: [{ class_name: "ExampleDurableObject" }], + expectedContainers: [ + { name: "my-container", class_name: "ExampleDurableObject" }, + ], }); } diff --git a/packages/wrangler/src/__tests__/containers/schema.test.ts b/packages/wrangler/src/__tests__/containers/schema.test.ts index 9a496653cde..ab34868d7bc 100644 --- a/packages/wrangler/src/__tests__/containers/schema.test.ts +++ b/packages/wrangler/src/__tests__/containers/schema.test.ts @@ -6,6 +6,13 @@ type WranglerSchema = { definitions: { ContainerApp: { properties: Record; + required?: string[]; + }; + DurableObjectExport: { + anyOf: { + properties: Record; + required?: string[]; + }[]; }; RawConfig: { properties: { @@ -33,6 +40,32 @@ describe("config schema", () => { ); }); + it("does not require class_name, since a container may be referenced from `exports`", ({ + expect, + }) => { + const schema = readSchema(); + + expect(schema.definitions.ContainerApp.properties).toHaveProperty( + "class_name" + ); + expect(schema.definitions.ContainerApp.required).not.toContain( + "class_name" + ); + }); + + it("allows `container` on live durable object exports only", ({ expect }) => { + const schema = readSchema(); + const branchesWithContainer = schema.definitions.DurableObjectExport.anyOf + .filter((branch) => "container" in branch.properties) + .map((branch) => branch.required); + + // The two live states: `created` (the default) and `expecting-transfer`. + expect(branchesWithContainer).toEqual([ + ["type", "storage"], + ["type", "state", "storage", "transfer_from"], + ]); + }); + it("emits markdownDescription for rich editor hovers", ({ expect }) => { const schema = readSchema(); const build = schema.definitions.RawConfig.properties.build; diff --git a/packages/wrangler/src/__tests__/create-worker-upload-form/metadata.test.ts b/packages/wrangler/src/__tests__/create-worker-upload-form/metadata.test.ts index ce85d6a509a..587a8aae599 100644 --- a/packages/wrangler/src/__tests__/create-worker-upload-form/metadata.test.ts +++ b/packages/wrangler/src/__tests__/create-worker-upload-form/metadata.test.ts @@ -227,6 +227,42 @@ describe("createWorkerUploadForm — optional metadata fields", () => { Admin: { type: "worker", cache: { enabled: true } }, }, }, + { + label: "durable object exports with an attached container", + overrides: { + exports: { + Counter: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + }, + key: "exports", + expected: { + Counter: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + }, + { + label: "containers linked by class_name", + overrides: { + containers: [{ name: "my-container", class_name: "Counter" }], + }, + key: "containers", + expected: [{ name: "my-container", class_name: "Counter" }], + }, + { + label: "containers linked from the exports side", + overrides: { + containers: [{ name: "my-container" }], + }, + key: "containers", + expected: [{ name: "my-container" }], + }, { label: "annotations", overrides: { diff --git a/packages/wrangler/src/__tests__/dev.test.ts b/packages/wrangler/src/__tests__/dev.test.ts index b256a4a4d82..e7bea9d2600 100644 --- a/packages/wrangler/src/__tests__/dev.test.ts +++ b/packages/wrangler/src/__tests__/dev.test.ts @@ -1842,6 +1842,38 @@ describe.sequential("wrangler dev", () => { const config = await runWranglerUntilConfig("dev"); expect(config.name).toBe("test-do-exports-dev"); }); + + it("resolves a container referenced from a durable object export", async ({ + expect, + }) => { + writeWranglerConfig({ + name: "test-container-exports-dev", + main: "index.js", + containers: [ + { + name: "my-container", + max_instances: 1, + image: "registry.cloudflare.com/hello:world", + }, + ], + exports: { + MyContainerDO: { + type: "durable-object", + storage: "sqlite", + container: "my-container", + }, + }, + }); + fs.writeFileSync("index.js", `export default {};`); + + const config = await runWranglerUntilConfig("dev"); + expect(config.containers).toEqual([ + expect.objectContaining({ + name: "my-container", + class_name: "MyContainerDO", + }), + ]); + }); }); }); diff --git a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts index d16f43c0f79..17cc6859431 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts @@ -66,7 +66,7 @@ export function mockUploadWorkerRequest( useOldUploadApi?: boolean; expectedObservability?: CfWorkerInit["observability"]; expectedSettingsPatch?: Partial; - expectedContainers?: { class_name: string }[]; + expectedContainers?: { name?: string; class_name?: string }[]; expectedAnnotations?: Record; expectedDeploymentMessage?: string; } = {} diff --git a/packages/wrangler/src/api/integrations/platform/index.ts b/packages/wrangler/src/api/integrations/platform/index.ts index 522193935c9..7fd1ea2f709 100644 --- a/packages/wrangler/src/api/integrations/platform/index.ts +++ b/packages/wrangler/src/api/integrations/platform/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; import { + getContainerDurableObjectClassNames, getRegistryPath, getTodaysCompatDate, } from "@cloudflare/workers-utils"; @@ -291,8 +292,9 @@ async function getMiniflareOptionsFromConfig(args: { exports: config.exports, tails: [], streamingTails: [], - containerDOClassNames: new Set( - config.containers?.map((c) => c.class_name) + containerDOClassNames: getContainerDurableObjectClassNames( + config.containers, + config.exports ), containerBuildId: undefined, enableContainers: config.dev.enable_containers, @@ -443,8 +445,9 @@ export function unstable_getMiniflareWorkerOptions( fallthrough: rule.fallthrough, })); - const containerDOClassNames = new Set( - config.containers?.map((c) => c.class_name) + const containerDOClassNames = getContainerDurableObjectClassNames( + config.containers, + config.exports ); const bindings = getBindings( config, diff --git a/packages/wrangler/src/containers/config.ts b/packages/wrangler/src/containers/config.ts index 7715b90fe3b..23558662c7b 100644 --- a/packages/wrangler/src/containers/config.ts +++ b/packages/wrangler/src/containers/config.ts @@ -5,7 +5,11 @@ import { resolveImageName, SchedulingPolicy, } from "@cloudflare/containers-shared"; -import { isDockerfile, UserError } from "@cloudflare/workers-utils"; +import { + isDockerfile, + resolveContainerClassName, + UserError, +} from "@cloudflare/workers-utils"; import { getDurableObjectClassNameToUseSQLiteMap } from "../dev/class-names-sqlite"; import { getOrSelectAccountId } from "../user"; import type { @@ -66,23 +70,34 @@ export const getNormalizedContainerOptions = async ( config.exports ); + // 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`. + const className = resolveContainerClassName(container, config.exports); + if (className === undefined) { + throw new UserError( + `The container "${container.name}" is not linked to a Durable Object. Either set "containers.class_name", or reference this container from a Durable Object's \`exports\` entry via its "container" field.`, + { telemetryMessage: "container not linked to a durable object" } + ); + } + if ( - !allDOs.has(container.class_name) && + !allDOs.has(className) && config.durable_objects.bindings.find( - (doBinding) => doBinding.class_name === container.class_name + (doBinding) => doBinding.class_name === className ) === 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.`, + `The container class_name ${className} 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" } ); } const maybeBoundDO = config.durable_objects.bindings.find( - (durableObject) => durableObject.class_name === container.class_name + (durableObject) => durableObject.class_name === className ); if (maybeBoundDO && 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.`, + `The container ${container.name} is referencing the durable object ${className}, 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: "contaienr class_name refers to an external durable object", @@ -112,7 +127,7 @@ export const getNormalizedContainerOptions = async ( const shared: Omit = { name: container.name, - class_name: container.class_name, + class_name: className, max_instances: container.max_instances ?? 20, scheduling_policy: (container.scheduling_policy ?? SchedulingPolicy.DEFAULT) as SchedulingPolicy, From 69c5d215ba576dcacb9fc3800677c9d730c37199 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 4 Aug 2026 22:05:43 +0100 Subject: [PATCH 2/3] [wrangler] Add live deploy e2e coverage for containers attached via `exports` Deploys a Worker whose container is linked only from the Durable Object's `exports` entry, then asserts `ctx.container` is present on the deployed Durable Object. That is only true when the API resolved the link from the export side, so it covers the server half of the feature end to end. A second case moves the same link back to `containers[].class_name` and re-deploys, verifying the container stays attached and that the two directions really are interchangeable. The image is pushed once up front under a known tag so that teardown can delete it deterministically, and so neither deploy has to build it. The block is skipped when Docker is unavailable. --- .../e2e/durable-objects-exports.test.ts | 184 +++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/packages/wrangler/e2e/durable-objects-exports.test.ts b/packages/wrangler/e2e/durable-objects-exports.test.ts index c2cd419fcde..08a2609f0e4 100644 --- a/packages/wrangler/e2e/durable-objects-exports.test.ts +++ b/packages/wrangler/e2e/durable-objects-exports.test.ts @@ -1,10 +1,17 @@ +import assert from "node:assert"; +import { setTimeout } from "node:timers/promises"; +import { getCloudflareContainerRegistry } from "@cloudflare/containers-shared"; import dedent from "ts-dedent"; -import { afterAll, describe, it } from "vitest"; +import { afterAll, beforeAll, describe, it } from "vitest"; import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id"; import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test"; import { generateResourceName } from "./helpers/generate-resource-name"; +import { waitForWorkersDev } from "./helpers/wait-for-workers-dev"; const TIMEOUT = 60_000; +// Deploys that build and push a container image are much slower than a plain +// `wrangler deploy`. +const CONTAINER_DEPLOY_TIMEOUT = 240_000; describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( "durable-objects-exports", @@ -616,5 +623,180 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( ); }); }); + + // Pushing the container image needs Docker. Unlike the local dev tests we + // never *run* the container, so this is not restricted to Linux. + describe.skipIf(process.env.LOCAL_TESTS_WITHOUT_DOCKER)( + "containers attached via `exports`: deploy", + { timeout: CONTAINER_DEPLOY_TIMEOUT }, + () => { + const workerName = generateResourceName(); + const containerName = `${workerName}-container`; + // Push the image up front under a known tag so that it can be deleted + // again deterministically, and so that neither deploy has to build it. + const imageTag = `${workerName}:tmp-e2e`; + const imageUri = `${getCloudflareContainerRegistry()}/${CLOUDFLARE_ACCOUNT_ID}/${imageTag}`; + const helper = new WranglerE2ETestHelper(); + + beforeAll(async () => { + await helper.seed({ + // A container-free config, so that `containers build` runs before + // the image it is about to push is referenced by anything. + "wrangler.jsonc": dedent` + { + "name": "${workerName}", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + } + `, + Dockerfile: dedent` + FROM alpine:latest + EXPOSE 8080 + CMD ["sleep", "infinity"] + `, + "src/index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + + export class MyContainerDO extends DurableObject { + async fetch() { + // \`ctx.container\` is only present when the deployed Worker has a + // container attached to this class, so this asserts that the API + // resolved the link between the two. + return Response.json({ hasContainer: this.ctx.container !== undefined }); + } + } + + export default { + async fetch(request, env, ctx) { + const id = ctx.exports.MyContainerDO.idFromName("container"); + return ctx.exports.MyContainerDO.get(id).fetch(request); + }, + }; + `, + "package.json": dedent` + { + "name": "${workerName}", + "version": "0.0.0", + "private": true + } + `, + }); + + await helper.run(`wrangler containers build . -t ${imageTag} -p`); + // Give the registry a moment to make the pushed image available. + await setTimeout(5_000); + }, CONTAINER_DEPLOY_TIMEOUT); + + afterAll(async () => { + await helper.bestEffortRun(`wrangler delete`); + await helper.bestEffortRun( + `wrangler containers images delete ${imageTag}` + ); + }); + + it("attaches the container to the Durable Object it is referenced from", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "${workerName}", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "compatibility_flags": ["enable_ctx_exports"], + "containers": [ + { + "name": "${containerName}", + "image": "${imageUri}", + "max_instances": 1, + }, + ], + "exports": { + "MyContainerDO": { + "type": "durable-object", + "storage": "sqlite", + "container": "${containerName}", + }, + }, + } + `, + }); + + const output = await helper.run(`wrangler deploy`); + + expect(output.stdout).toContain("Created: MyContainerDO"); + expect(output.stdout).toContain( + "The following containers are available:" + ); + expect(output.stdout).toContain(containerName); + + // Wait only for the Durable Object to respond at all, then assert on + // the payload, so that a missing container fails immediately with a + // useful diff rather than timing out. + const response = await waitForWorkersDev( + getDeployedUrl(output), + (candidate) => + candidate.headers + .get("content-type") + ?.includes("application/json") === true + ); + + expect(await response.json()).toEqual({ hasContainer: true }); + }); + + it("keeps the container attached when the link moves to `class_name`", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "${workerName}", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "compatibility_flags": ["enable_ctx_exports"], + "containers": [ + { + "name": "${containerName}", + "class_name": "MyContainerDO", + "image": "${imageUri}", + "max_instances": 1, + }, + ], + "exports": { + "MyContainerDO": { "type": "durable-object", "storage": "sqlite" }, + }, + } + `, + }); + + const output = await helper.run(`wrangler deploy`); + + expect(output.stdout).toContain( + "The following containers are available:" + ); + + // Wait only for the Durable Object to respond at all, then assert on + // the payload, so that a missing container fails immediately with a + // useful diff rather than timing out. + const response = await waitForWorkersDev( + getDeployedUrl(output), + (candidate) => + candidate.headers + .get("content-type") + ?.includes("application/json") === true + ); + + expect(await response.json()).toEqual({ hasContainer: true }); + }); + } + ); } ); + +function getDeployedUrl(output: { stdout: string }) { + const match = output.stdout.match( + /(?https:\/\/tmp-e2e-.+?\..+?\.workers\.dev)/ + ); + assert(match?.groups); + return match.groups.url; +} From d1a0821c8637245637703b73a928f5a4fd0d46cc Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 5 Aug 2026 09:09:22 +0100 Subject: [PATCH 3/3] [wrangler] Update versions upload container metadata assertion `versions upload` sends `containers[].name` alongside `class_name` now, so this assertion needs the derived `-` name. The container in this test has no explicit `name`, which is exactly the case where validation fills one in. --- .../src/__tests__/versions/versions.upload.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts index c63a6309035..c6a7be349bd 100644 --- a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts @@ -1224,7 +1224,12 @@ describe("versions upload", () => { await toString(formBody.get("metadata")) ) as WorkerMetadata; - expect(metadata.containers).toEqual([{ class_name: "MyDurableObject" }]); + // The container has no explicit `name`, so validation derives + // `-`. Both directions of the container/Durable Object link + // are sent so that the API can resolve it from either side. + expect(metadata.containers).toEqual([ + { name: "test-name-mydurableobject", class_name: "MyDurableObject" }, + ]); expect(std.warn).toContain( "Container configuration changes (such as image, max_instances, etc.) will not be gradually rolled out with versions"