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..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", @@ -529,5 +536,267 @@ 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` + ); + }); + }); + + // 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; +} 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,