From aafe866263e522bce05370ecf252e610653cfdaa Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Fri, 1 May 2026 16:33:24 -0700 Subject: [PATCH] feat(wrangler): add container support to worker previews Worker previews now support containers via a new `previews.containers` configuration block. Container configuration is non-inheritable: declare containers explicitly in the `previews` block to enable them for previews, mirroring how `previews.durable_objects` works today. Preview container application names are auto-generated by wrangler in the form `{worker_name}_{preview_slug}_{class_name}` and are not user configurable -- the config validator rejects entries that set a `name` field. Container applications bound to Durable Object classes implemented by another Worker (via `script_name`) are intentionally skipped, since the implementing Worker owns its own container application. Container applications are created on `wrangler preview` and removed on `wrangler preview delete`. Cleanup matches applications by their auto-generated name prefix (`{worker_name}_{preview_slug}_`), so deletions are scoped to the specific preview being torn down. Failures on individual application deletes are logged as warnings but do not block the preview deletion itself. --- .changeset/cyan-planets-ring.md | 9 + .../workers-utils/src/config/validation.ts | 53 +- .../normalize-and-validate-config.test.ts | 75 ++ .../wrangler/src/__tests__/preview.test.ts | 789 ++++++++++++++++++ packages/wrangler/src/preview/api.ts | 1 + packages/wrangler/src/preview/containers.ts | 222 +++++ packages/wrangler/src/preview/preview.ts | 67 ++ 7 files changed, 1214 insertions(+), 2 deletions(-) create mode 100644 .changeset/cyan-planets-ring.md create mode 100644 packages/wrangler/src/preview/containers.ts diff --git a/.changeset/cyan-planets-ring.md b/.changeset/cyan-planets-ring.md new file mode 100644 index 0000000000..cc2bd6fb87 --- /dev/null +++ b/.changeset/cyan-planets-ring.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Add container support to worker previews + +Worker previews now support containers via a new `previews.containers` configuration block. Container configuration is non-inheritable: declare containers explicitly in the `previews` block to enable them for previews, mirroring how `previews.durable_objects` works today. Preview container application names are auto-generated by wrangler in the form `{worker_name}_{preview_slug}_{class_name}` and are not user-configurable — the config validator rejects entries that set a `name` field. Container applications bound to Durable Object classes implemented by another Worker (via `script_name`) are intentionally skipped, since the implementing Worker owns its own container application. + +Container applications are created on `wrangler preview` and removed on `wrangler preview delete`. Cleanup matches applications by their auto-generated name prefix (`{worker_name}_{preview_slug}_`), so deletions are scoped to the specific preview being torn down. Failures on individual application deletes are logged as warnings but do not block the preview deletion itself. diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 5b1108362b..2c50188970 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -2081,7 +2081,7 @@ function normalizeAndValidateEnvironment( topLevelEnv, rawEnv, "previews", - validatePreviewsConfig(envName), + validatePreviewsConfig(envName, rawEnv.name, configPath), undefined ), }; @@ -3159,6 +3159,40 @@ const validateBindingArray = return isValid; }; +/** + * Validate `previews.containers`. Mirrors `validateContainerApp` but rejects + * any user-provided `name` field — preview container application names are + * auto-generated by wrangler from the worker name, preview slug, and + * class name so they can be reliably created and cleaned up alongside the + * preview itself. + */ +function validatePreviewsContainers( + envName: string, + topLevelName: string | undefined, + configPath: string | undefined +): ValidatorFn { + const innerValidator = validateContainerApp( + envName, + topLevelName, + configPath + ); + return (diagnostics, field, value, config) => { + if ( + Array.isArray(value) && + value.some( + (entry) => + entry && typeof entry === "object" && entry.name !== undefined + ) + ) { + diagnostics.errors.push( + `"name" is not allowed on "${field}" entries; preview container application names are auto-generated by wrangler from the worker name, preview slug, and class name.` + ); + return false; + } + return innerValidator(diagnostics, field, value, config); + }; +} + function validateContainerApp( envName: string, topLevelName: string | undefined, @@ -5183,7 +5217,11 @@ function normalizeAndValidateLimits( } const validatePreviewsConfig = - (envName: string): ValidatorFn => + ( + envName: string, + topLevelName: string | undefined, + configPath: string | undefined + ): ValidatorFn => (diagnostics, field, value) => { if (value === undefined) { return true; @@ -5235,6 +5273,7 @@ const validatePreviewsConfig = "ratelimits", "vpc_services", "version_metadata", + "containers", "logpush", "observability", "limits", @@ -5514,6 +5553,16 @@ const validatePreviewsConfig = ) && isValid; } + if (previews.containers !== undefined) { + isValid = + validatePreviewsContainers(envName, topLevelName, configPath)( + diagnostics, + `${field}.containers`, + previews.containers, + undefined + ) && isValid; + } + isValid = isBoolean(diagnostics, `${field}.logpush`, previews.logpush, undefined) && isValid; 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 e904cdc2b7..8a9836da9b 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 @@ -9973,6 +9973,81 @@ describe("normalizeAndValidateConfig()", () => { 'The field "previews.browser" should be an object' ); }); + + it("should accept previews.containers without a name", ({ expect }) => { + const rawConfig = { + name: "test-worker", + previews: { + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/test:latest", + }, + ], + }, + } as unknown as RawConfig; + + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("should reject previews.containers entries that set a name", ({ + expect, + }) => { + const rawConfig = { + name: "test-worker", + previews: { + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/test:latest", + name: "custom-name", + }, + ], + }, + } as unknown as RawConfig; + + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + '"name" is not allowed on "previews.containers" entries' + ); + }); + + it("should reject previews.containers entries missing image", ({ + expect, + }) => { + const rawConfig = { + name: "test-worker", + previews: { + containers: [{ class_name: "MyContainer" }], + }, + } as unknown as RawConfig; + + const { diagnostics } = normalizeAndValidateConfig( + rawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toContain( + '"containers.image" field must be defined' + ); + }); }); }); }); diff --git a/packages/wrangler/src/__tests__/preview.test.ts b/packages/wrangler/src/__tests__/preview.test.ts index a925bae9c2..2bf532b6bb 100644 --- a/packages/wrangler/src/__tests__/preview.test.ts +++ b/packages/wrangler/src/__tests__/preview.test.ts @@ -7,6 +7,7 @@ import { http, HttpResponse } from "msw"; import { afterAll, afterEach, beforeEach, describe, test, vi } from "vitest"; import { clearOutputFilePath } from "../output"; import { extractConfigBindings, getBranchName } from "../preview/shared"; +import * as user from "../user"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { msw } from "./helpers/msw"; @@ -1772,6 +1773,616 @@ describe("wrangler preview", () => { expect(deploymentRequestBody?.env).not.toHaveProperty("ASSETS"); }); + test("should include containers in deployment metadata and create a preview-scoped container application", async ({ + expect, + }) => { + writeFileSync( + "src/index.ts", + "export class MyContainer { fetch() { return new Response('ok'); } } export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + previews: { + durable_objects: { + bindings: [{ name: "MY_CONTAINER", class_name: "MyContainer" }], + }, + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/some-account-id/test:latest", + }, + ], + }, + }) + ); + vi.spyOn(user, "getScopes").mockReturnValue(["containers:write"]); + let deploymentRequestBody: Record | undefined; + let createdApplication: Record | undefined; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "preview-id-containers", + name: "feature/my-branch", + slug: "feature-my-branch", + urls: ["https://test-preview.test-worker.cloudflare.app"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + async ({ request }) => { + deploymentRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json( + { + success: true, + result: { + id: "deployment-id-containers", + preview_id: "preview-id-containers", + preview_name: "feature/my-branch", + urls: ["https://containers.test-worker.cloudflare.app"], + compatibility_date: "2025-01-01", + env: { + MY_CONTAINER: { + type: "durable_object_namespace", + class_name: "MyContainer", + namespace_id: "preview-do-ns-id", + }, + }, + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ); + } + ), + http.get("*/me", () => + HttpResponse.json({ + success: true, + result: { + external_account_id: "some-account-id", + limits: { disk_mb_per_deployment: 2000 }, + }, + }) + ), + http.get("*/applications", () => + HttpResponse.json({ success: true, result: [] }) + ), + http.post("*/applications", async ({ request }) => { + createdApplication = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json({ + success: true, + result: { id: "app-id", ...createdApplication }, + }); + }) + ); + await runWrangler("preview --name feature/my-branch"); + expect(deploymentRequestBody?.containers).toEqual([ + { class_name: "MyContainer" }, + ]); + expect(createdApplication).toMatchObject({ + name: "test-worker_feature-my-branch_MyContainer", + durable_objects: { namespace_id: "preview-do-ns-id" }, + }); + }); + + test("should not propagate top-level containers into the preview deployment", async ({ + expect, + }) => { + writeFileSync( + "src/index.ts", + "export class MyContainer { fetch() { return new Response('ok'); } } export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + durable_objects: { + bindings: [{ name: "MY_CONTAINER", class_name: "MyContainer" }], + }, + migrations: [{ tag: "v1", new_sqlite_classes: ["MyContainer"] }], + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/some-account-id/test:latest", + }, + ], + previews: { + durable_objects: { + bindings: [{ name: "MY_CONTAINER", class_name: "MyContainer" }], + }, + }, + }) + ); + let deploymentRequestBody: Record | undefined; + let listApplicationsCalls = 0; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "preview-id-no-inherit", + name: "test-preview", + slug: "test-preview", + urls: ["https://test-preview.test-worker.cloudflare.app"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + async ({ request }) => { + deploymentRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json( + { + success: true, + result: { + id: "deployment-id-no-inherit", + preview_id: "preview-id-no-inherit", + preview_name: "test-preview", + urls: ["https://noinherit.test-worker.cloudflare.app"], + compatibility_date: "2025-01-01", + env: { + MY_CONTAINER: { + type: "durable_object_namespace", + class_name: "MyContainer", + namespace_id: "preview-do-ns-id", + }, + }, + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ); + } + ), + http.get("*/applications", () => { + listApplicationsCalls++; + return HttpResponse.json({ success: true, result: [] }); + }) + ); + await runWrangler("preview --name test-preview"); + expect(deploymentRequestBody).not.toHaveProperty("containers"); + expect(listApplicationsCalls).toBe(0); + }); + + test("should omit containers from deployment when none are configured", async ({ + expect, + }) => { + let deploymentRequestBody: Record | undefined; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "preview-id-no-containers", + name: "test-preview", + slug: "test-preview", + urls: ["https://test-preview.test-worker.cloudflare.app"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + async ({ request }) => { + deploymentRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json( + { + success: true, + result: { + id: "deployment-id-no-containers", + preview_id: "preview-id-no-containers", + preview_name: "test-preview", + urls: ["https://nocontainers.test-worker.cloudflare.app"], + compatibility_date: "2025-01-01", + env: {}, + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ); + } + ) + ); + await runWrangler("preview --name test-preview"); + expect(deploymentRequestBody).toBeDefined(); + expect(deploymentRequestBody).not.toHaveProperty("containers"); + }); + + test("should include only DO-bound container classes in deployment", async ({ + expect, + }) => { + writeFileSync( + "src/index.ts", + "export class BoundContainer { fetch() { return new Response('ok'); } } export class UnboundContainer { fetch() { return new Response('ok'); } } export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + previews: { + durable_objects: { + bindings: [ + { + name: "BOUND_CONTAINER", + class_name: "BoundContainer", + }, + ], + }, + containers: [ + { + class_name: "BoundContainer", + image: "registry.cloudflare.com/some-account-id/bound:latest", + }, + { + class_name: "UnboundContainer", + image: "registry.cloudflare.com/some-account-id/unbound:latest", + }, + ], + }, + }) + ); + vi.spyOn(user, "getScopes").mockReturnValue(["containers:write"]); + let deploymentRequestBody: Record | undefined; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "preview-id-bound-only", + name: "test-preview", + slug: "test-preview", + urls: ["https://test-preview.test-worker.cloudflare.app"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + async ({ request }) => { + deploymentRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json( + { + success: true, + result: { + id: "deployment-id-bound-only", + preview_id: "preview-id-bound-only", + preview_name: "test-preview", + urls: ["https://boundonly.test-worker.cloudflare.app"], + compatibility_date: "2025-01-01", + env: { + BOUND_CONTAINER: { + type: "durable_object_namespace", + class_name: "BoundContainer", + namespace_id: "preview-do-ns-bound", + }, + }, + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ); + } + ), + http.get("*/me", () => + HttpResponse.json({ + success: true, + result: { + external_account_id: "some-account-id", + limits: { disk_mb_per_deployment: 2000 }, + }, + }) + ), + http.get("*/applications", () => + HttpResponse.json({ success: true, result: [] }) + ), + http.post("*/applications", async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + success: true, + result: { id: "app-id", ...body }, + }); + }) + ); + await runWrangler("preview --name test-preview"); + expect(deploymentRequestBody?.containers).toEqual([ + { class_name: "BoundContainer" }, + ]); + }); + + test("should exclude containers whose DO binding has script_name set", async ({ + expect, + }) => { + writeFileSync( + "src/index.ts", + "export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + previews: { + durable_objects: { + bindings: [ + { + name: "EXTERNAL_CONTAINER", + class_name: "ExternalContainer", + script_name: "owner-worker", + }, + ], + }, + containers: [ + { + class_name: "ExternalContainer", + image: + "registry.cloudflare.com/some-account-id/external:latest", + }, + ], + }, + }) + ); + let deploymentRequestBody: Record | undefined; + let createApplicationCalls = 0; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "preview-id-cross-script", + name: "test-preview", + slug: "test-preview", + urls: ["https://test-preview.test-worker.cloudflare.app"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + async ({ request }) => { + deploymentRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json( + { + success: true, + result: { + id: "deployment-id-cross-script", + preview_id: "preview-id-cross-script", + preview_name: "test-preview", + urls: ["https://crossscript.test-worker.cloudflare.app"], + compatibility_date: "2025-01-01", + env: {}, + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ); + } + ), + http.post("*/applications", () => { + createApplicationCalls++; + return HttpResponse.json({ + success: true, + result: { id: "app-id" }, + }); + }) + ); + await runWrangler("preview --name test-preview"); + expect(deploymentRequestBody).not.toHaveProperty("containers"); + expect(createApplicationCalls).toBe(0); + }); + + test("should throw a UserError when the preview deployment env is missing namespace_id for a container's class", async ({ + expect, + }) => { + writeFileSync( + "src/index.ts", + "export class MyContainer { fetch() { return new Response('ok'); } } export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + previews: { + durable_objects: { + bindings: [{ name: "MY_CONTAINER", class_name: "MyContainer" }], + }, + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/some-account-id/test:latest", + }, + ], + }, + }) + ); + vi.spyOn(user, "getScopes").mockReturnValue(["containers:write"]); + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "preview-id-missing-ns", + name: "test-preview", + slug: "test-preview", + urls: ["https://test-preview.test-worker.cloudflare.app"], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.post( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments`, + () => + HttpResponse.json( + { + success: true, + result: { + id: "deployment-id-missing-ns", + preview_id: "preview-id-missing-ns", + preview_name: "test-preview", + urls: ["https://missingns.test-worker.cloudflare.app"], + compatibility_date: "2025-01-01", + env: {}, + created_on: new Date().toISOString(), + }, + }, + { status: 201 } + ) + ), + http.get("*/me", () => + HttpResponse.json({ + success: true, + result: { + external_account_id: "some-account-id", + limits: { disk_mb_per_deployment: 2000 }, + }, + }) + ) + ); + await expect(runWrangler("preview --name test-preview")).rejects.toThrow( + /did not return a namespace_id/ + ); + }); + test("should include source maps in deployment modules when upload_source_maps is enabled", async ({ expect, }) => { @@ -3167,5 +3778,183 @@ describe("wrangler preview", () => { await runWrangler("preview delete --env staging --name test-preview -y"); expect(deleteUrl).toContain("/workers/workers/staging-worker/previews/"); }); + + test("should delete preview-scoped container applications when previews.containers is configured", async ({ + expect, + }) => { + mkdirSync("src", { recursive: true }); + writeFileSync( + "src/index.ts", + "export class MyContainer { fetch() { return new Response('ok'); } } export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + previews: { + durable_objects: { + bindings: [{ name: "MY_CONTAINER", class_name: "MyContainer" }], + }, + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/some-account-id/test:latest", + }, + ], + }, + }) + ); + vi.spyOn(user, "getScopes").mockReturnValue(["containers:write"]); + const deletedAppIds: string[] = []; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json({ + success: true, + result: { + id: "preview-id-cleanup", + name: "feature/my-branch", + slug: "feature-my-branch", + urls: [], + worker_name: "test-worker", + created_on: new Date().toISOString(), + }, + }) + ), + http.delete( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => HttpResponse.json({ success: true, result: null }) + ), + http.get("*/me", () => + HttpResponse.json({ + success: true, + result: { + external_account_id: "some-account-id", + limits: { disk_mb_per_deployment: 2000 }, + }, + }) + ), + http.get("*/applications", () => + HttpResponse.json({ + success: true, + result: [ + { + id: "matching-app-1", + name: "test-worker_feature-my-branch_MyContainer", + }, + { + id: "matching-app-2", + name: "test-worker_feature-my-branch_OtherClass", + }, + { + id: "different-preview", + name: "test-worker_other-branch_MyContainer", + }, + { + id: "different-worker", + name: "another-worker_feature-my-branch_MyContainer", + }, + { + id: "prod-app", + name: "test-worker-MyContainer", + }, + ], + }) + ), + http.delete("*/applications/:id", ({ params }) => { + deletedAppIds.push(params.id as string); + return HttpResponse.json({ success: true, result: null }); + }) + ); + await runWrangler( + "preview delete --name feature/my-branch -y --worker-name test-worker" + ); + expect(deletedAppIds.sort()).toEqual([ + "matching-app-1", + "matching-app-2", + ]); + }); + + test("should not list applications when no previews.containers are configured", async ({ + expect, + }) => { + let listApplicationsCalls = 0; + msw.use( + http.delete( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => HttpResponse.json({ success: true, result: null }) + ), + http.get("*/applications", () => { + listApplicationsCalls++; + return HttpResponse.json({ success: true, result: [] }); + }) + ); + await runWrangler( + "preview delete --name my-feature -y --worker-name test-worker" + ); + expect(listApplicationsCalls).toBe(0); + }); + + test("should continue deleting the preview when container cleanup fails", async ({ + expect, + }) => { + mkdirSync("src", { recursive: true }); + writeFileSync( + "src/index.ts", + "export class MyContainer { fetch() { return new Response('ok'); } } export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + previews: { + durable_objects: { + bindings: [{ name: "MY_CONTAINER", class_name: "MyContainer" }], + }, + containers: [ + { + class_name: "MyContainer", + image: "registry.cloudflare.com/some-account-id/test:latest", + }, + ], + }, + }) + ); + vi.spyOn(user, "getScopes").mockReturnValue(["containers:write"]); + let previewDeleted = false; + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => + HttpResponse.json( + { + success: false, + result: null, + errors: [{ code: 10025, message: "Preview not found" }], + }, + { status: 404 } + ) + ), + http.delete( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId`, + () => { + previewDeleted = true; + return HttpResponse.json({ success: true, result: null }); + } + ) + ); + await runWrangler( + "preview delete --name my-feature -y --worker-name test-worker" + ); + expect(previewDeleted).toBe(true); + expect(std.warn).toContain( + 'Preview "my-feature" was not found; skipping container application cleanup' + ); + }); }); }); diff --git a/packages/wrangler/src/preview/api.ts b/packages/wrangler/src/preview/api.ts index 2134dafa3e..26c9dc68b8 100644 --- a/packages/wrangler/src/preview/api.ts +++ b/packages/wrangler/src/preview/api.ts @@ -109,6 +109,7 @@ export type CreatePreviewDeploymentRequestParams = { placement?: CfPlacement; cache?: CacheOptions; env?: EnvBindings; + containers?: Array<{ class_name: string }>; }; export type CreatePreviewRequestParams = { diff --git a/packages/wrangler/src/preview/containers.ts b/packages/wrangler/src/preview/containers.ts new file mode 100644 index 0000000000..2114a043f8 --- /dev/null +++ b/packages/wrangler/src/preview/containers.ts @@ -0,0 +1,222 @@ +import { ApplicationsService } from "@cloudflare/containers-shared"; +import { getDockerPath, UserError } from "@cloudflare/workers-utils"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { containersScope } from "../containers"; +import { buildContainer } from "../containers/build"; +import { getNormalizedContainerOptions } from "../containers/config"; +import { apply } from "../containers/deploy"; +import { logger } from "../logger"; +import type { DeploymentResource } from "./api"; +import type { ImageURIConfig } from "@cloudflare/containers-shared"; +import type { + Config, + ContainerApp, + PreviewsConfig, +} from "@cloudflare/workers-utils"; + +/** + * Compose the auto-generated container application name for a preview-scoped + * container. Mirrors the EWC server-side `PreviewNamer` so wrangler-managed + * apps can be reliably correlated with the preview's own DO namespaces. + * + * Format: `{parentWorkerName}_{previewSlug}_{className}`. + */ +export function previewContainerAppName( + parentWorkerName: string, + previewSlug: string, + className: string +): string { + return `${parentWorkerName}_${previewSlug}_${className}`; +} + +/** + * Returns the set of DO `class_name`s that are bound in the preview AND + * implemented by THIS script (i.e. no `script_name` override). DOs implemented + * by another worker have their containers managed by that worker, so we + * intentionally exclude them. + */ +export function getOwnPreviewBoundDOClassNames( + previews: PreviewsConfig | undefined +): Set { + return new Set( + (previews?.durable_objects?.bindings ?? []) + .filter((b) => b.script_name === undefined) + .map((b) => b.class_name) + ); +} + +/** + * Construct a synthetic `Config` that overlays the previews block onto the + * top-level config: containers come from `previews.containers` (with + * auto-generated names), DO bindings come from `previews.durable_objects`, + * and observability defaults to the previews override if set. This lets us + * reuse `getNormalizedContainerOptions` and `apply` from the standard + * `wrangler deploy` container path without forking either. + * + * Returns `undefined` if no containers in the previews block resolve to an + * own (non cross-script) DO binding in the preview. + */ +function buildPreviewContainerConfig( + config: Config, + parentWorkerName: string, + previewSlug: string, + previewContainers: ContainerApp[] +): Config | undefined { + const previews = config.previews as PreviewsConfig | undefined; + const ownBoundDOClasses = getOwnPreviewBoundDOClassNames(previews); + const filteredContainers = previewContainers + .filter((c) => ownBoundDOClasses.has(c.class_name)) + .map((c) => ({ + ...c, + name: previewContainerAppName( + parentWorkerName, + previewSlug, + c.class_name + ), + })); + + if (filteredContainers.length === 0) { + return undefined; + } + + const observability = previews?.observability ?? config.observability; + return { + ...config, + containers: filteredContainers, + durable_objects: { + bindings: previews?.durable_objects?.bindings ?? [], + }, + observability, + }; +} + +/** + * Deploy preview-scoped container applications after a preview deployment + * has been created. For each container declared in `previews.containers` + * whose class is bound to an own DO in the preview, we register or update a + * Cloudchamber application named `{worker}_{previewSlug}_{className}` bound to + * the DO namespace_id resolved by the preview deployment API. + * + * The DO namespace for a preview is provisioned by the workers control plane + * and returned in the create-deployment response — we read it directly from + * `deployment.env` rather than re-fetching. + */ +export async function deployPreviewContainers( + config: Config, + parentWorkerName: string, + previewSlug: string, + deployment: DeploymentResource, + previewContainers: ContainerApp[] +): Promise { + const scopedConfig = buildPreviewContainerConfig( + config, + parentWorkerName, + previewSlug, + previewContainers + ); + if (!scopedConfig) { + return; + } + + const normalised = await getNormalizedContainerOptions(scopedConfig, { + dryRun: false, + }); + if (normalised.length === 0) { + return; + } + + await fillOpenAPIConfiguration(config, containersScope); + const dockerPath = getDockerPath(); + + const classNameToNamespaceId = new Map(); + for (const binding of Object.values(deployment.env ?? {})) { + if ( + binding.type === "durable_object_namespace" && + binding.class_name && + binding.namespace_id + ) { + classNameToNamespaceId.set(binding.class_name, binding.namespace_id); + } + } + + for (const container of normalised) { + const namespaceId = classNameToNamespaceId.get(container.class_name); + if (!namespaceId) { + throw new UserError( + `Could not deploy preview container application "${container.name}": the preview deployment API did not return a namespace_id for Durable Object class "${container.class_name}". This is likely a bug in Wrangler — please file an issue.`, + { + telemetryMessage: "preview containers deploy missing do namespace id", + } + ); + } + + let imageRef; + if ("dockerfile" in container) { + imageRef = await buildContainer( + container, + deployment.id, + false, + dockerPath + ); + } else { + imageRef = { newTag: (container as ImageURIConfig).image_uri }; + } + + await apply( + { imageRef, durable_object_namespace_id: namespaceId }, + container, + scopedConfig + ); + } +} + +/** + * Delete every Cloudchamber application whose name matches the preview + * scoped naming pattern `{parentWorkerName}_{previewSlug}_*`. Failures on + * individual apps are logged but do not abort the loop, so a partial cleanup + * failure does not prevent the preview itself from being deleted. + * + * Skipped entirely if `previews.containers` is empty, to avoid unnecessary + * Cloudchamber API calls. + */ +export async function deletePreviewContainers( + config: Config, + parentWorkerName: string, + previewSlug: string +): Promise { + const previews = config.previews as PreviewsConfig | undefined; + if (!previews?.containers || previews.containers.length === 0) { + return; + } + + await fillOpenAPIConfiguration(config, containersScope); + + const prefix = `${parentWorkerName}_${previewSlug}_`; + let apps; + try { + apps = await promiseSpinner(ApplicationsService.listApplications(), { + message: "Listing preview container applications", + }); + } catch (error) { + logger.warn( + `Failed to list preview container applications for cleanup: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + const matches = apps.filter((app) => app.name.startsWith(prefix)); + for (const app of matches) { + try { + await promiseSpinner(ApplicationsService.deleteApplication(app.id), { + message: `Deleting container application "${app.name}"`, + }); + } catch (error) { + logger.warn( + `Failed to delete preview container application "${app.name}": ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/packages/wrangler/src/preview/preview.ts b/packages/wrangler/src/preview/preview.ts index 5cf9305b59..3b18e31bf2 100644 --- a/packages/wrangler/src/preview/preview.ts +++ b/packages/wrangler/src/preview/preview.ts @@ -42,6 +42,11 @@ import { getPreview, getWorkerPreviewDefaults, } from "./api"; +import { + deletePreviewContainers, + deployPreviewContainers, + getOwnPreviewBoundDOClassNames, +} from "./containers"; import { assemblePreviewScriptSettings, extractConfigBindings, @@ -394,6 +399,30 @@ async function assemblePreviewDeploymentSettings( request.placement = parseConfigPlacement(config); } + // Containers: declare which DO classes are container-backed so the runtime + // populates `ctx.container` on those DO instances, mirroring the metadata + // emitted by `wrangler deploy`. + // + // Container config is non-inheritable: only `previews.containers` is read, + // not the top-level `containers` field. This matches the behavior of + // `previews.durable_objects` and forces users to explicitly opt-in to + // containers in previews. + // + // We only emit `class_name`s that are bound as DOs in this preview AND + // where the DO is implemented by THIS script (i.e. no `script_name` is set + // — those bindings reference DOs implemented by another worker, which owns + // their own container application). + const previewContainers = previews?.containers ?? []; + if (previewContainers.length > 0) { + const ownBoundDOClasses = getOwnPreviewBoundDOClassNames(previews); + const containers = previewContainers + .filter((c) => ownBoundDOClasses.has(c.class_name)) + .map((c) => ({ class_name: c.class_name })); + if (containers.length > 0) { + request.containers = containers; + } + } + const env = extractConfigBindings(config); if (Object.keys(env).length > 0) { request.env = env; @@ -869,6 +898,18 @@ export async function handlePreviewCommand( { ignoreDefaults } ); + const previewContainers = + (config.previews as PreviewsConfig | undefined)?.containers ?? []; + if (previewContainers.length > 0) { + await deployPreviewContainers( + config, + workerName, + preview.slug, + deployment, + previewContainers + ); + } + writeOutput({ type: "preview", version: 1, @@ -944,6 +985,32 @@ export async function handlePreviewDeleteCommand( } const accountId = await requireAuth(config); + + const hasPreviewContainers = + ((config.previews as PreviewsConfig | undefined)?.containers?.length ?? 0) > + 0; + if (hasPreviewContainers) { + try { + const preview = await getPreview( + config, + accountId, + workerName, + previewName + ); + await deletePreviewContainers(config, workerName, preview.slug); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === 10025) { + logger.warn( + `Preview "${previewName}" was not found; skipping container application cleanup.` + ); + } else { + logger.warn( + `Failed to clean up preview container applications: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + await deletePreview(config, accountId, workerName, previewName); logger.log(`\n✨ Preview "${previewName}" deleted successfully.`); }