diff --git a/.changeset/chatty-doors-shake.md b/.changeset/chatty-doors-shake.md new file mode 100644 index 000000000000..00e6bf2016f6 --- /dev/null +++ b/.changeset/chatty-doors-shake.md @@ -0,0 +1,36 @@ +--- +"wrangler": minor +--- + +Enable using `ctx.exports` with containers + +You can now use containers with Durable Objects that are accessed via [`ctx.exports`](https://developers.cloudflare.com/workers/runtime-apis/context/#exports). + +Now your config file can look something like this: + +``` +{ + "name": "container-app", + "main": "src/index.ts", + "compatibility_date": "2025-12-01", + "compatibility_flags": ["enable_ctx_exports"], // compat flag needed for now. + "containers": [ + { + "image": "./Dockerfile", + "class_name": "MyDOClassname", + "name": "my-container" + }, + ], + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MyDOClassname"], + }, + ], + // no need to declare your durable object binding here +} +``` + +Note that when using `ctx.exports`, where you previously accessed a Durable Object via something like `env.DO`, you should now access with `ctx.exports.MyDOClassname`. + +Refer to [the docs for more information on using `ctx.exports`](https://developers.cloudflare.com/workers/runtime-apis/context/#exports). diff --git a/fixtures/container-app/src/index.ts b/fixtures/container-app/src/index.ts index 9b42281ea0c1..ca55dd91a3e7 100644 --- a/fixtures/container-app/src/index.ts +++ b/fixtures/container-app/src/index.ts @@ -51,17 +51,18 @@ export class FixtureTestContainer extends DurableObject { } export default { - async fetch(request, env): Promise { + async fetch(request, env, ctx: ExecutionContext): Promise { const url = new URL(request.url); if (url.pathname === "/second") { // This is a second Durable Object that can be used to test multiple DOs - const id = env.CONTAINER.idFromName("second-container"); - const stub = env.CONTAINER.get(id); + const id = + ctx.exports.FixtureTestContainer.idFromName("second-container"); + const stub = ctx.exports.FixtureTestContainer.get(id); const query = url.searchParams.get("req"); return stub.fetch("http://example.com/" + query); } - const id = env.CONTAINER.idFromName("container"); - const stub = env.CONTAINER.get(id); + const id = ctx.exports.FixtureTestContainer.idFromName("container"); + const stub = ctx.exports.FixtureTestContainer.get(id); return stub.fetch(request); }, } satisfies ExportedHandler; diff --git a/fixtures/container-app/wrangler.jsonc b/fixtures/container-app/wrangler.jsonc index 3a2d79bca21d..37e82de5b48a 100644 --- a/fixtures/container-app/wrangler.jsonc +++ b/fixtures/container-app/wrangler.jsonc @@ -2,6 +2,7 @@ "name": "container-app", "main": "src/index.ts", "compatibility_date": "2025-04-03", + "compatibility_flags": ["enable_ctx_exports"], "containers": [ { "image": "./Dockerfile", @@ -10,14 +11,6 @@ "max_instances": 2, }, ], - "durable_objects": { - "bindings": [ - { - "class_name": "FixtureTestContainer", - "name": "CONTAINER", - }, - ], - }, "migrations": [ { "tag": "v1", diff --git a/packages/wrangler/e2e/dev-registry.test.ts b/packages/wrangler/e2e/dev-registry.test.ts index b5c40559c341..648cbd63e2e1 100644 --- a/packages/wrangler/e2e/dev-registry.test.ts +++ b/packages/wrangler/e2e/dev-registry.test.ts @@ -306,7 +306,7 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { ); expect(normalizeOutput(workerA.currentOutput)).toContain( - "connect to other wrangler or vite dev processes running locally" + "connect to other Wrangler or Vite dev processes running locally" ); }); @@ -595,7 +595,7 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { ); expect(normalizeOutput(workerA.currentOutput)).toContain( - "connect to other wrangler or vite dev processes running locally" + "connect to other Wrangler or Vite dev processes running locally" ); }); diff --git a/packages/wrangler/e2e/pages-dev.test.ts b/packages/wrangler/e2e/pages-dev.test.ts index bfaea2b66ba3..c19e0d0e40c4 100644 --- a/packages/wrangler/e2e/pages-dev.test.ts +++ b/packages/wrangler/e2e/pages-dev.test.ts @@ -448,7 +448,7 @@ describe.sequential("wrangler pages dev", () => { env.VAR1 ("(hidden)") Environment Variable local env.VAR2 ("VAR_2_TOML") Environment Variable local env.VAR3 ("(hidden)") Environment Variable local - Service bindings, Durable Object bindings, and Tail consumers connect to other wrangler or vite dev processes running locally, with their connection status indicated by [connected] or [not connected]. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development + Service bindings, Durable Object bindings, and Tail consumers connect to other Wrangler or Vite dev processes running locally, with their connection status indicated by [connected] or [not connected]. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development " `); }); diff --git a/packages/wrangler/src/__tests__/containers/config.test.ts b/packages/wrangler/src/__tests__/containers/config.test.ts index 75a3404d94be..4f87607415a0 100644 --- a/packages/wrangler/src/__tests__/containers/config.test.ts +++ b/packages/wrangler/src/__tests__/containers/config.test.ts @@ -97,6 +97,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; await expect(getNormalizedContainerOptions(config, {})).rejects.toThrow( @@ -132,6 +133,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -176,6 +178,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -219,6 +222,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -260,6 +264,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -307,6 +312,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -351,6 +357,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -393,6 +400,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -448,6 +456,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -500,6 +509,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -552,6 +562,9 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [ + { tag: "v1", new_sqlite_classes: ["Container1", "Container2"] }, + ], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -583,6 +596,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -615,6 +629,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -644,6 +659,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); @@ -675,6 +691,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; await expect(getNormalizedContainerOptions(config, {})).rejects .toThrowErrorMatchingInlineSnapshot(` @@ -706,6 +723,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, {}); expect(result).toHaveLength(1); @@ -737,6 +755,7 @@ describe("getNormalizedContainerOptions", () => { }, ], }, + migrations: [{ tag: "v1", new_sqlite_classes: ["TestContainer"] }], } as Partial as Config; const result = await getNormalizedContainerOptions(config, { dryRun: true, diff --git a/packages/wrangler/src/__tests__/containers/deploy.test.ts b/packages/wrangler/src/__tests__/containers/deploy.test.ts index 18121520999d..4c16b1722f7a 100644 --- a/packages/wrangler/src/__tests__/containers/deploy.test.ts +++ b/packages/wrangler/src/__tests__/containers/deploy.test.ts @@ -1,5 +1,6 @@ import { execFileSync, spawn } from "node:child_process"; import * as fs from "node:fs"; +import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import { getCloudflareContainerRegistry, @@ -109,6 +110,10 @@ describe("wrangler deploy with containers", () => { getCloudflareContainerRegistry() + "/some-account-id/my-container:Galaxy", }, + durable_objects: { + // uses namespace_id when the DO is a binding + namespace_id: "1", + }, }); await runWrangler("deploy index.js"); @@ -123,6 +128,9 @@ describe("wrangler deploy with containers", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (/Dockerfile) + Uploaded test-name (TIMINGS) Building image my-container:Galaxy Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy @@ -198,6 +206,9 @@ describe("wrangler deploy with containers", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (registry.cloudflare.com/hello:world) + Uploaded test-name (TIMINGS) Deployed test-name triggers (TIMINGS) https://test-name.test-sub-domain.workers.dev @@ -284,6 +295,9 @@ describe("wrangler deploy with containers", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (registry.cloudflare.com/hello:world) + Uploaded test-name (TIMINGS) Deployed test-name triggers (TIMINGS) https://test-name.test-sub-domain.workers.dev @@ -382,6 +396,9 @@ describe("wrangler deploy with containers", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (registry.cloudflare.com/hello:world) + Uploaded test-name (TIMINGS) Deployed test-name triggers (TIMINGS) https://test-name.test-sub-domain.workers.dev @@ -472,23 +489,33 @@ describe("wrangler deploy with containers", () => { await runWrangler("deploy --cwd src"); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - Total Upload: xx KiB / gzip: xx KiB - Worker Startup Time: 100 ms - Your Worker has access to the following bindings: - Binding Resource - env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + // we filter stdout normally to replace cwd since that is temporary + // however in this case since we pass a cwd to wrangler, the cwd wrangler runs in + // is different from the cwd for the test so our normal matching doesn't work + const wranglerCWD = process.cwd().split(path.sep); + wranglerCWD.splice(-1, 1); + expect(std.out.replace(wranglerCWD.join("/"), "")) + .toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your Worker has access to the following bindings: + Binding Resource + env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + + The following containers are available: + - my-container (/Dockerfile) + + Uploaded test-name (TIMINGS) + Building image my-container:Galaxy + Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); - Uploaded test-name (TIMINGS) - Building image my-container:Galaxy - Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy - Deployed test-name triggers (TIMINGS) - https://test-name.test-sub-domain.workers.dev - Current Version ID: Galaxy-Class" - `); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.warn).toMatchInlineSnapshot(`""`); }); @@ -545,6 +572,9 @@ describe("wrangler deploy with containers", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (/Dockerfile) + Uploaded test-name (TIMINGS) Building image my-container:Galaxy Image does not exist remotely, pushing: registry.cloudflare.com/some-account-id/my-container:Galaxy @@ -692,6 +722,12 @@ describe("wrangler deploy with containers", () => { }, ], }, + migrations: [ + { + tag: "v1", + new_sqlite_classes: ["DurableObjectClass2", "ExampleDurableObject"], + }, + ], containers: [ DEFAULT_CONTAINER_FROM_DOCKERFILE, { @@ -1779,6 +1815,259 @@ describe("wrangler deploy with containers", () => { expect(std.err).toMatchInlineSnapshot(`""`); expect(std.warn).toMatchInlineSnapshot(`""`); }); + + describe("ctx.exports", async () => { + // note how mockGetVersion is NOT mocked in any of these, unlike the other tests. + // instead we mock the list durable objects endpoint, which the ctx.exports path uses instead + it("should be able to deploy a new container", async () => { + writeWranglerConfig({ + // no DO! + migrations: [ + { tag: "v1", new_sqlite_classes: ["ExampleDurableObject"] }, + ], + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + rollout_active_grace_period: 600, + }, + ], + }); + + mockGetApplications([]); + mockListDurableObjects([ + { + id: "some-id", + name: "name", + script: "test-name", + class: "ExampleDurableObject", + }, + ]); + mockUploadWorkerRequest({ + expectedBindings: [], + useOldUploadApi: true, + expectedContainers: [{ class_name: "ExampleDurableObject" }], + }); + mockCreateApplication({ + 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.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + The following containers are available: + - my-container (registry.cloudflare.com/hello:world) + + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(cliStd.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ NEW my-container + │ + │ [[containers]] + │ name = \\"my-container\\" + │ scheduling_policy = \\"default\\" + │ instances = 0 + │ max_instances = 10 + │ rollout_active_grace_period = 600 + │ + │ [containers.configuration] + │ image = \\"registry.cloudflare.com/some-account-id/hello:world\\" + │ instance_type = \\"lite\\" + │ + │ [containers.constraints] + │ tier = 1 + │ + │ [containers.durable_objects] + │ namespace_id = \\"some-id\\" + │ + │ + │ SUCCESS Created application my-container (Application ID: undefined) + │ + ╰ Applied changes + + " + `); + }); + + it("should error if a container name has been used before but attached to a different DO", async () => { + writeWranglerConfig({ + migrations: [ + { tag: "v1", new_sqlite_classes: ["ExampleDurableObject"] }, + ], + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + rollout_active_grace_period: 600, + }, + ], + }); + + mockGetApplications([ + { + id: "abc", + name: "my-container", + instances: 0, + max_instances: 10, + created_at: new Date().toString(), + version: 1, + account_id: "1", + scheduling_policy: SchedulingPolicy.DEFAULT, + rollout_active_grace_period: 0, + configuration: { + image: `${getCloudflareContainerRegistry()}/some-account-id/my-container:Galaxy`, + disk: { + size: "2GB", + size_mb: 2000, + }, + vcpu: 0.0625, + memory: "256MB", + memory_mib: 256, + }, + constraints: { + tier: 1, + }, + durable_objects: { + namespace_id: "something-else", + }, + }, + ]); + mockListDurableObjects([ + { + id: "something", + name: "name", + script: "test-name", + class: "ExampleDurableObject", + }, + ]); + mockUploadWorkerRequest({ + expectedBindings: [], + useOldUploadApi: true, + expectedContainers: [{ class_name: "ExampleDurableObject" }], + }); + + await expect( + runWrangler("deploy index.js") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: There is already an application with the name my-container deployed that is associated with a different durable object namespace (something-else). Either change the container name or delete the existing application first.]` + ); + }); + + it("should be able to redeploy an existing application", async () => { + writeWranglerConfig({ + migrations: [ + { tag: "v1", new_sqlite_classes: ["ExampleDurableObject"] }, + ], + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + rollout_active_grace_period: 600, + }, + ], + }); + mockUploadWorkerRequest({ + expectedBindings: [], + useOldUploadApi: true, + expectedContainers: [{ class_name: "ExampleDurableObject" }], + }); + mockListDurableObjects([ + { + id: "something", + name: "name", + script: "test-name", + class: "ExampleDurableObject", + }, + ]); + mockGetApplications([ + { + id: "abc", + name: "my-container", + instances: 0, + max_instances: 2, + created_at: new Date().toString(), + version: 1, + account_id: "1", + scheduling_policy: SchedulingPolicy.DEFAULT, + configuration: { + image: "registry.cloudflare.com/some-account-id/hello:world", + disk: { + size: "2GB", + size_mb: 2000, + }, + vcpu: 0.0625, + memory: "256MB", + memory_mib: 256, + }, + constraints: { + tier: 1, + }, + durable_objects: { + namespace_id: "something", + }, + rollout_active_grace_period: 500, + }, + ]); + fs.writeFileSync("./Dockerfile", "FROM scratch"); + mockGenerateImageRegistryCredentials(); + mockModifyApplication({ + configuration: { + image: "registry.cloudflare.com/some-account-id/hello:world", + }, + max_instances: 10, + rollout_active_grace_period: 600, + }); + mockCreateApplicationRollout({ + description: "Progressive update", + strategy: "rolling", + kind: "full_auto", + }); + await runWrangler("deploy index.js"); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(cliStd.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ EDIT my-container + │ + │ [[containers]] + │ - max_instances = 2 + │ + max_instances = 10 + │ name = \\"my-container\\" + │ - rollout_active_grace_period = 500 + │ + rollout_active_grace_period = 600 + │ scheduling_policy = \\"default\\" + │ [containers.configuration] + │ + │ + │ SUCCESS Modified application my-container (Application ID: abc) + │ + ╰ Applied changes + + " + `); + }); + }); }); // This is a separate describe block because we intentionally do not mock any @@ -1791,11 +2080,11 @@ describe("wrangler deploy with containers dry run", () => { beforeEach(() => { clearCachedAccount(); expect(process.env.CLOUDFLARE_API_TOKEN).toBeUndefined(); + vi.mocked(spawn).mockReset(); }); afterEach(() => { vi.unstubAllEnvs(); - vi.mocked(spawn).mockReset(); }); it("builds the image without pushing", async () => { @@ -1827,6 +2116,9 @@ describe("wrangler deploy with containers dry run", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (/Dockerfile) + --dry-run: exiting now." `); expect(cliStd.stdout).toMatchInlineSnapshot(`""`); @@ -1854,6 +2146,9 @@ describe("wrangler deploy with containers dry run", () => { Binding Resource env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + The following containers are available: + - my-container (registry.cloudflare.com/hello:world) + --dry-run: exiting now." `); expect(cliStd.stdout).toMatchInlineSnapshot(`""`); @@ -2212,6 +2507,24 @@ function mockGetApplications(applications: Application[]) { ); } +function mockListDurableObjects( + durableObjects: Array<{ + id: string; + name: string; + script: string; + class: string; + }> +) { + msw.use( + http.get( + "*/accounts/:accountId/workers/durable_objects/namespaces", + async () => { + return HttpResponse.json(createFetchResult(durableObjects)); + } + ) + ); +} + function mockDockerInfo() { return (cmd: string, args: readonly string[]) => { expect(cmd).toBe("/usr/bin/docker"); diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 9df687adde30..8bcd07fead30 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -211,6 +211,7 @@ async function resolveBindings( }, input.tailConsumers ?? config.tail_consumers, input.streamingTailConsumers ?? config.streaming_tail_consumers, + config.containers, { registry, local: !input.dev?.remote, diff --git a/packages/wrangler/src/cloudchamber/deploy.ts b/packages/wrangler/src/cloudchamber/deploy.ts deleted file mode 100644 index 6c369334b3bc..000000000000 --- a/packages/wrangler/src/cloudchamber/deploy.ts +++ /dev/null @@ -1,97 +0,0 @@ -import assert from "node:assert"; -import { getDockerPath, UserError } from "@cloudflare/workers-utils"; -import { containersScope } from "../containers"; -import { apply } from "../containers/deploy"; -import { logger } from "../logger"; -import { fetchVersion } from "../versions/api"; -import { buildAndMaybePush } from "./build"; -import { fillOpenAPIConfiguration } from "./common"; -import type { ImageRef } from "./build"; -import type { - ContainerNormalizedConfig, - ImageURIConfig, -} from "@cloudflare/containers-shared/src/types"; -import type { Config } from "@cloudflare/workers-utils"; - -export async function buildContainer( - containerConfig: Exclude, - /** just the tag component. will be prefixed with the container name */ - imageTag: string, - dryRun: boolean, - pathToDocker: string -): Promise { - const imageFullName = containerConfig.name + ":" + imageTag.split("-")[0]; - logger.log("Building image", imageFullName); - - return await buildAndMaybePush( - { - tag: imageFullName, - pathToDockerfile: containerConfig.dockerfile, - buildContext: containerConfig.image_build_context, - args: containerConfig.image_vars, - }, - pathToDocker, - !dryRun, - containerConfig - ); -} - -export type DeployContainersArgs = { - versionId: string; - accountId: string; - scriptName: string; - dryRun: boolean; - env?: string; -}; - -export async function deployContainers( - config: Config, - normalisedContainerConfig: ContainerNormalizedConfig[], - { versionId, accountId, scriptName }: DeployContainersArgs -) { - await fillOpenAPIConfiguration(config, containersScope); - - const pathToDocker = getDockerPath(); - const version = await fetchVersion(config, accountId, scriptName, versionId); - let imageRef: ImageRef; - for (const container of normalisedContainerConfig) { - if ("dockerfile" in container) { - imageRef = await buildContainer( - container, - versionId, - false, - pathToDocker - ); - } else { - imageRef = { newTag: container.image_uri }; - } - const targetDurableObject = version.resources.bindings.find( - (durableObject) => - durableObject.type === "durable_object_namespace" && - durableObject.class_name === container.class_name && - // DO cannot be defined in a different script to the container - durableObject.script_name === undefined && - durableObject.namespace_id !== undefined - ); - - if (!targetDurableObject) { - throw new UserError( - "Could not deploy container application as durable object was not found in list of bindings" - ); - } - - assert( - targetDurableObject.type === "durable_object_namespace" && - targetDurableObject.namespace_id !== undefined - ); - - await apply( - { - imageRef, - durable_object_namespace_id: targetDurableObject.namespace_id, - }, - container, - config - ); - } -} diff --git a/packages/wrangler/src/containers/build.ts b/packages/wrangler/src/containers/build.ts new file mode 100644 index 000000000000..bf62d9d1a96b --- /dev/null +++ b/packages/wrangler/src/containers/build.ts @@ -0,0 +1,30 @@ +import { buildAndMaybePush } from "../cloudchamber/build"; +import { logger } from "../logger"; +import type { ImageRef } from "../cloudchamber/build"; +import type { + ContainerNormalizedConfig, + ImageURIConfig, +} from "@cloudflare/containers-shared"; + +export async function buildContainer( + containerConfig: Exclude, + /** just the tag component. will be prefixed with the container name */ + imageTag: string, + dryRun: boolean, + pathToDocker: string +): Promise { + const imageFullName = containerConfig.name + ":" + imageTag.split("-")[0]; + logger.log("Building image", imageFullName); + + return await buildAndMaybePush( + { + tag: imageFullName, + pathToDockerfile: containerConfig.dockerfile, + buildContext: containerConfig.image_build_context, + args: containerConfig.image_vars, + }, + pathToDocker, + !dryRun, + containerConfig + ); +} diff --git a/packages/wrangler/src/containers/config.ts b/packages/wrangler/src/containers/config.ts index c5e31408e254..624a75e3025e 100644 --- a/packages/wrangler/src/containers/config.ts +++ b/packages/wrangler/src/containers/config.ts @@ -8,6 +8,7 @@ import { SchedulingPolicy, } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { getDurableObjectClassNameToUseSQLiteMap } from "../dev/class-names-sqlite"; import { getAccountId } from "../user"; import type { ApplicationAffinities, @@ -62,19 +63,20 @@ export const getNormalizedContainerOptions = async ( for (const container of config.containers) { assert(container.name, "container name should have been set by validation"); - const targetDurableObject = config.durable_objects.bindings.find( - (durableObject) => durableObject.class_name === container.class_name - ); - if (!targetDurableObject) { + const allDOs = getDurableObjectClassNameToUseSQLiteMap(config.migrations); + + if (!allDOs.has(container.class_name)) { throw new UserError( `The container class_name ${container.class_name} does not match any durable object class_name defined in your Wrangler config file. Note that the durable object must be defined in the same script as the container.`, { telemetryMessage: "no DO defined that matches container class_name" } ); } - - if (targetDurableObject.script_name !== undefined) { + const maybeBoundDO = config.durable_objects.bindings.find( + (durableObject) => durableObject.class_name === container.class_name + ); + 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 ${targetDurableObject.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 ${container.class_name}, which appears to be defined on the ${maybeBoundDO.script_name} Worker instead (via the 'script_name' field). You cannot configure a container on a Durable Object that is defined in another Worker.`, { telemetryMessage: "contaienr class_name refers to an external durable object", diff --git a/packages/wrangler/src/containers/deploy.ts b/packages/wrangler/src/containers/deploy.ts index e64fc959bb15..65d578e0c097 100644 --- a/packages/wrangler/src/containers/deploy.ts +++ b/packages/wrangler/src/containers/deploy.ts @@ -2,6 +2,7 @@ * Note! Much of this is copied and modified from cloudchamber/apply.ts * However this code is only used for containers interactions, not cloudchamber ones! */ +import assert from "node:assert"; import { endSection, log, @@ -22,17 +23,26 @@ import { import { FatalError, formatConfigSnippet, + getDockerPath, UserError, } from "@cloudflare/workers-utils"; -import { promiseSpinner } from "../cloudchamber/common"; +import { fetchResult } from "../cfetch"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; import { inferInstanceType } from "../cloudchamber/instance-type/instance-type"; +import { buildContainer } from "../containers/build"; import { getAccountId } from "../user"; import { Diff } from "../utils/diff"; import { sortObjectRecursive, stripUndefined, } from "../utils/sortObjectRecursive"; +import { fetchVersion } from "../versions/api"; +import { containersScope } from "."; import type { ImageRef } from "../cloudchamber/build"; +import type { ApiVersion } from "../versions/types"; import type { Application, ApplicationID, @@ -43,8 +53,126 @@ import type { Observability as ObservabilityConfiguration, RolloutStepRequest, } from "@cloudflare/containers-shared"; -import type { Config, ContainerApp } from "@cloudflare/workers-utils"; +import type { + ComplianceConfig, + Config, + ContainerApp, + WorkerMetadataBinding, +} from "@cloudflare/workers-utils"; + +type DeployContainersArgs = { + versionId: string; + accountId: string; + scriptName: string; +}; + +export async function deployContainers( + config: Config, + normalisedContainerConfig: ContainerNormalizedConfig[], + { versionId, accountId, scriptName }: DeployContainersArgs +) { + await fillOpenAPIConfiguration(config, containersScope); + + const pathToDocker = getDockerPath(); + const boundDOs = new Set( + config.durable_objects.bindings.map((b) => b.class_name) + ); + let imageRef: ImageRef; + let maybeVersionInfo: ApiVersion | undefined; + let maybeAllDurableObjects: DurableObjectNamespace[] | undefined; + + for (const container of normalisedContainerConfig) { + if ("dockerfile" in container) { + imageRef = await buildContainer( + container, + versionId, + false, // dry runs will have already exited by this point + pathToDocker + ); + } else { + imageRef = { newTag: container.image_uri }; + } + + // Only bound DOs are returned in version info. For unbound DOs, we need to list all DO namespaces. + if (boundDOs.has(container.class_name)) { + maybeVersionInfo ??= await fetchVersion( + config, + accountId, + scriptName, + versionId + ); + type DurableObjectBinding = Extract< + WorkerMetadataBinding, + { type: "durable_object_namespace" } + >; + const targetDurableObject = maybeVersionInfo.resources.bindings.find( + (binding): binding is DurableObjectBinding => + binding.type === "durable_object_namespace" && + binding.class_name === container.class_name && + // DO cannot be defined in a different script to the container + (binding.script_name === undefined || + binding.script_name === scriptName) && + binding.namespace_id !== undefined + ); + if (!targetDurableObject) { + throw new UserError( + "Could not deploy container application as durable object was not found in list of bindings" + ); + } + assert( + targetDurableObject && targetDurableObject.namespace_id !== undefined + ); + + await apply( + { + imageRef, + durable_object_namespace_id: targetDurableObject.namespace_id, + }, + container, + config + ); + } else { + // The DO is unbound, so we need to list all DO namespaces to find the right one + // TODO: use the list API with filters when it exists + maybeAllDurableObjects ??= await listDurableObjects(config, accountId); + const targetDurableObject = maybeAllDurableObjects.find( + (durableObject) => + durableObject.class === container.class_name && + durableObject.script === scriptName + ); + + assert(targetDurableObject, "Durable Object not returned from list API"); + await apply( + { + imageRef, + durable_object_namespace_id: targetDurableObject.id, + }, + container, + config + ); + } + } +} + +type DurableObjectNamespace = { + id: string; + class: string; + name: string; + script: string; + useSqlite: boolean; +}; +async function listDurableObjects( + complianceConfig: ComplianceConfig, + accountId: string +): Promise { + return await fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/durable_objects/namespaces`, + {}, + new URLSearchParams({ per_page: "1000" }) + ); +} /** * Source overwrites target */ @@ -263,8 +391,7 @@ export async function apply( prevApp.durable_objects.namespace_id !== args.durable_object_namespace_id ) { throw new UserError( - `Application "${prevApp.name}" is assigned to durable object ${prevApp.durable_objects.namespace_id}, but a new DO namespace is being assigned to the application, - you should delete the container application and deploy again`, + `There is already an application with the name ${containerConfig.name} deployed that is associated with a different durable object namespace (${prevApp.durable_objects.namespace_id}). Either change the container name or delete the existing application first.`, { telemetryMessage: "trying to redeploy container to different durable object", diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 77960d5ef897..d6c74b0b3f09 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -19,8 +19,9 @@ import PQueue from "p-queue"; import { Response } from "undici"; import { syncAssets } from "../assets"; import { fetchListResult, fetchResult } from "../cfetch"; -import { buildContainer, deployContainers } from "../cloudchamber/deploy"; +import { buildContainer } from "../containers/build"; import { getNormalizedContainerOptions } from "../containers/config"; +import { deployContainers } from "../containers/deploy"; import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; import { bundleWorker } from "../deployment-bundle/bundle"; import { printBundleSize } from "../deployment-bundle/bundle-reporter"; @@ -910,6 +911,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m { ...withoutStaticAssets, vars: maskedVars }, config.tail_consumers, config.streaming_tail_consumers, + config.containers, { warnIfNoBindings: true } ); } else { @@ -1049,7 +1051,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m printBindings( { ...withoutStaticAssets, vars: maskedVars }, config.tail_consumers, - config.streaming_tail_consumers + config.streaming_tail_consumers, + config.containers ); versionId = parseNonHyphenedUuid(result.deployment_id); @@ -1079,7 +1082,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m printBindings( { ...withoutStaticAssets, vars: maskedVars }, config.tail_consumers, - config.streaming_tail_consumers + config.streaming_tail_consumers, + config.containers ); } const message = await helpIfErrorIsSizeOrScriptStartup( @@ -1178,7 +1182,6 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m versionId, accountId, scriptName, - dryRun: props.dryRun ?? false, }); } diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index ab429c9909fe..d8fe032b0f8d 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -492,6 +492,7 @@ export async function provisionBindings( printable, config.tail_consumers, config.streaming_tail_consumers, + config.containers, { provisioning: true } ); logger.log(); diff --git a/packages/wrangler/src/dev/class-names-sqlite.ts b/packages/wrangler/src/dev/class-names-sqlite.ts index 408fae4985a2..b2d10f1479d8 100644 --- a/packages/wrangler/src/dev/class-names-sqlite.ts +++ b/packages/wrangler/src/dev/class-names-sqlite.ts @@ -1,6 +1,12 @@ import { UserError } from "@cloudflare/workers-utils"; import type { Config } from "@cloudflare/workers-utils"; +/** + * Based on the migrations, infer what the current Durable Object class names are. + * This includes unbound (ctx.exports) and bound DOs. + * Returns class name mapped to whether it uses SQLite storage. + * This is imperfect because you can delete a migration after it has been applied. + */ export function getDurableObjectClassNameToUseSQLiteMap( migrations: Config["migrations"] | undefined ) { diff --git a/packages/wrangler/src/utils/print-bindings.ts b/packages/wrangler/src/utils/print-bindings.ts index 9ac671982748..2d564fe765c0 100644 --- a/packages/wrangler/src/utils/print-bindings.ts +++ b/packages/wrangler/src/utils/print-bindings.ts @@ -4,7 +4,11 @@ import chalk from "chalk"; import stripAnsi from "strip-ansi"; import { getFlag } from "../experimental-flags"; import { logger } from "../logger"; -import type { CfTailConsumer, CfWorkerInit } from "@cloudflare/workers-utils"; +import type { + CfTailConsumer, + CfWorkerInit, + ContainerApp, +} from "@cloudflare/workers-utils"; import type { WorkerRegistry } from "miniflare"; /** @@ -14,6 +18,7 @@ export function printBindings( bindings: Partial, tailConsumers: CfTailConsumer[] = [], streamingTailConsumers: CfTailConsumer[] = [], + containers: ContainerApp[] = [], context: { registry?: WorkerRegistry | null; local?: boolean; @@ -739,10 +744,26 @@ export function printBindings( ); } + if (containers.length > 0 && !context.provisioning) { + let containersTitle = "The following containers are available:"; + if (context.name && getFlag("MULTIWORKER")) { + containersTitle = `The following containers are available from ${chalk.blue(context.name)}:`; + } + + logger.log( + `${containersTitle}\n${containers + .map((c) => { + return `- ${c.name} (${c.image})`; + }) + .join("\n")}` + ); + logger.log(); + } + if (hasConnectionStatus) { logger.once.info( dim( - `\nService bindings, Durable Object bindings, and Tail consumers connect to other wrangler or vite dev processes running locally, with their connection status indicated by ${chalk.green("[connected]")} or ${chalk.red("[not connected]")}. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development\n` + `\nService bindings, Durable Object bindings, and Tail consumers connect to other Wrangler or Vite dev processes running locally, with their connection status indicated by ${chalk.green("[connected]")} or ${chalk.red("[not connected]")}. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development\n` ) ); }