1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { destroy as destroyStack } from "@/Destroy.ts";
4import { State, inMemoryState } from "@/State";
5import * as Plan from "@/Plan.ts";
6import { Stage } from "@/Stage.ts";
7import * as Layer from "effect/Layer";
8import * as Containers from "@distilled.cloud/cloudflare/containers";
9import * as Cause from "effect/Cause";
10import * as Exit from "effect/Exit";
11import { assert, describe, expect } from "alchemy-test";
12import AttachmentStack, {
13 attachmentStack,
14 stackName as attachmentStackName,
15} from "./fixtures/attachment/stack.ts";
16import * as Effect from "effect/Effect";
17import { MinimumLogLevel } from "effect/References";
18import * as Schedule from "effect/Schedule";
19import * as HttpClient from "effect/http/HttpClient";
20import * as HttpClientRequest from "effect/http/HttpClientRequest";
21import makeAsyncContainerStack from "./fixtures/async/stack.ts";
22import EffectfulStack from "./fixtures/effectful/stack.ts";
23import makeExternalStack from "./fixtures/external/stack.ts";
24import makeInferredClassStack from "./fixtures/inferred/stack.ts";
25import { DEMO_PLAIN, DEMO_SECRET } from "./fixtures/remote/object.ts";
26import makeRemoteStack from "./fixtures/remote/stack.ts";
27
28describe.concurrent.each([
29 {
30 dev: true,
31 stage: process.env.ALCHEMY_TEST_STAGE
32 ? `${process.env.ALCHEMY_TEST_STAGE}-local`
33 : "test-local",
34 // Must cover the full readiness budget (readinessRetries × 3s ≈ 3 min)
35 // when a saturated machine keeps the local container answering 500s.
36 timeout: 300_000,
37 },
38 {
39 dev: false,
40 stage: process.env.ALCHEMY_TEST_STAGE ?? "test-live",
41 timeout: 300_000,
42 },
43])(
44 "Container (dev: $dev)",
45 ({ dev, stage, timeout }) => {
46 const state = dev ? inMemoryState() : Cloudflare.state();
47 const AsyncContainerStack = makeAsyncContainerStack(state);
48 const ExternalStack = makeExternalStack(state);
49 const InferredClassStack = makeInferredClassStack(state);
50 const RemoteStack = makeRemoteStack(state);
51
52 // We need to create a new test context for each test case because the
53 // local runner stops running after the root scope is closed, which happens
54 // in an afterAll hook registered by `Test.make`.
55 const make = (remote = false) =>
56 Test.make({
57 providers: Cloudflare.providers(),
58 state: remote ? Cloudflare.state() : state,
59 stage,
60 dev,
61 });
62
63 const logLevel = Effect.provideService(
64 MinimumLogLevel,
65 process.env.DEBUG ? "Debug" : "Info",
66 );
67
68 // Container image build + push + worker/DO deploy comfortably exceeds the
69 // default 120s hook budget, so give every deploy/destroy plenty of room.
70 const HOOK_TIMEOUT = 600_000;
71
72 /**
73 * Effect-native container (`main`): the entrypoint Effect is bundled into a
74 * generated image. The Durable Object proxies into the container two ways —
75 * RPC (`container.ping()` / `container.readObject()`) and HTTP over its
76 * port-3000 server (`getTcpPort(3000).fetch`). The bucket tests prove the
77 * full end-to-end R2 path: a value written through the DO's NATIVE binding is
78 * read back from inside the container over its scoped HTTP token, surfaced
79 * both via RPC and via fetch.
80 */
81 describe(
82 "effectful container (main)",
83 { tags: ["provider:cloudflare:r2", "live"] },
84 () => {
85 const { test, beforeAll, afterAll, deploy, destroy } = make(true);
86 const stack = beforeAll(deploy(EffectfulStack), {
87 timeout: HOOK_TIMEOUT,
88 });
89 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(EffectfulStack), {
90 timeout: HOOK_TIMEOUT,
91 });
92
93 test(
94 "RPC: ping round-trips into the container",
95 Effect.gen(function* () {
96 const { url } = yield* stack;
97
98 const pong = yield* fetchReady(new URL("/ping", url), "pong");
99 expect(pong).toContain("pong");
100 }).pipe(logLevel),
101 { timeout },
102 );
103
104 test(
105 "fetch: serves over its TCP port",
106 Effect.gen(function* () {
107 const { url } = yield* stack;
108
109 const hello = yield* fetchReady(
110 new URL("/hello", url),
111 "effectful container",
112 );
113 expect(hello).toContain("effectful container");
114 }).pipe(logLevel),
115 { timeout },
116 );
117
118 // A container is a real process — it has no workerd bindings, so a
119 // capability's only channel into it is the binding contract's `env`.
120 // The provider used to declare that contract and silently drop the
121 // values; this pins that a `Binding.Service`'s `host.bind({ env })`
122 // lands in the container's `process.env`, resolved Output and all.
123 // Same mechanism `Prisma.Connect` rides into a container.
124 test(
125 "a Binding.Service's bound env reaches the container",
126 Effect.gen(function* () {
127 const { url, bucketName } = yield* stack;
128
129 const body = yield* fetchReady(
130 new URL("/bound-env", url),
131 bucketName,
132 );
133 expect(JSON.parse(body)).toEqual({ value: bucketName });
134 }).pipe(logLevel),
135 { timeout },
136 );
137
138 test(
139 "RPC: reads an R2 object from inside the container",
140 Effect.gen(function* () {
141 const { url } = yield* stack;
142
143 yield* seed(url, "rpc.txt", "hello-rpc");
144 const body = yield* fetchReady(
145 new URL("/rpc?key=rpc.txt", url),
146 "hello-rpc",
147 );
148 expect(body).toContain("hello-rpc");
149 }).pipe(logLevel),
150 { timeout },
151 );
152
153 test(
154 "fetch: reads an R2 object from inside the container",
155 Effect.gen(function* () {
156 const { url } = yield* stack;
157
158 yield* seed(url, "fetch.txt", "hello-fetch");
159 const body = yield* fetchReady(
160 new URL("/fetch?key=fetch.txt", url),
161 "hello-fetch",
162 );
163 expect(body).toContain("hello-fetch");
164 }).pipe(logLevel),
165 { timeout },
166 );
167 },
168 );
169
170 /**
171 * External container (`context` / `dockerfile`): Alchemy builds the user's
172 * Dockerfile against the context directory (nginx serving a static page on
173 * port 8080) and the DO proxies a request to it.
174 */
175 describe("external container (context/dockerfile)", () => {
176 const { test, beforeAll, afterAll, deploy, destroy } = make();
177 const stack = beforeAll(deploy(ExternalStack), { timeout: HOOK_TIMEOUT });
178 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(ExternalStack), {
179 timeout: HOOK_TIMEOUT,
180 });
181
182 test(
183 "builds the user Dockerfile and serves it over its TCP port",
184 Effect.gen(function* () {
185 const { url } = yield* stack;
186
187 const hello = yield* fetchReady(
188 new URL("/hello", url),
189 "external container",
190 );
191 expect(hello).toContain("external container");
192 }).pipe(logLevel),
193 { tags: [...(dev ? ["local"] : ["live"])], timeout },
194 );
195 });
196
197 /**
198 * Remote container (`image`): Alchemy pulls a pre-built public image and
199 * re-pushes it to Cloudflare's registry without building anything; the DO
200 * proxies a request to it.
201 */
202 describe(
203 "remote container (image)",
204 { tags: ["provider:cloudflare:r2"] },
205 () => {
206 const { test, beforeAll, afterAll, deploy, destroy } = make();
207 const stack = beforeAll(deploy(RemoteStack), { timeout: HOOK_TIMEOUT });
208 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(RemoteStack), {
209 timeout: HOOK_TIMEOUT,
210 });
211
212 test(
213 "preserves memoryMib and serves the remote image over its TCP port",
214 Effect.gen(function* () {
215 const { url, app } = yield* stack;
216
217 expect(app.configuration.memoryMib).toBe(4096);
218 expect(app.configuration.instanceType).toBeUndefined();
219 expect(app.applicationId.startsWith("dev:")).toBe(dev);
220
221 const hello = yield* fetchReady(new URL("/hello", url), "method");
222 expect(hello).toContain("method");
223 }).pipe(logLevel),
224 { tags: [...(dev ? ["local"] : ["live"])], timeout },
225 );
226
227 // A pre-built image has no Effect runtime to inject config into, so `env`
228 // is the only channel: it lands as the ContainerApplication's
229 // `environmentVariables` and shows up as plain `process.env` entries
230 // inside the image. Covers all three value shapes — a literal, a
231 // `Redacted` (encrypted in state, plain in the container), and an Output
232 // resolved from a sibling resource (the canonical shape of a database
233 // connection string).
234 test(
235 "passes env — literal, Redacted, and sibling-resource Output — into the image",
236 Effect.gen(function* () {
237 const { url, bucketName } = yield* stack;
238 const body = yield* fetchReady(
239 new URL("/hello", url),
240 "DEMO_PLAIN",
241 );
242 const echoed = JSON.parse(body) as { env: Record<string, string> };
243
244 expect(echoed.env.DEMO_PLAIN).toBe(DEMO_PLAIN);
245 expect(echoed.env.DEMO_SECRET).toBe(DEMO_SECRET);
246 expect(echoed.env.DEMO_BUCKET).toBe(bucketName);
247 }).pipe(logLevel),
248 { tags: [...(dev ? ["local"] : ["live"])], timeout },
249 );
250
251 // The proxy pattern from #1334 (`yield* fetch(yield* HttpServerRequest)`):
252 // worker → DO fetch handler → container port, forwarding the raw incoming
253 // request. In the live variant the incoming web Request carries an
254 // https:// URL and workerd rejects TLS on container ports ("Connecting to
255 // a container using HTTPS is not currently supported"), so this pins the
256 // runtime's scheme downgrade on the container hop; dev serves http:// and
257 // just pins the passthrough itself.
258 test(
259 "proxies the raw incoming request through the DO to the container",
260 Effect.gen(function* () {
261 const { url } = yield* stack;
262
263 // The echo image reflects the request as JSON ("method" only appears
264 // in a real echo response, never in an error page).
265 const body = yield* fetchReady(
266 new URL("/passthrough", url),
267 "method",
268 );
269 expect(body).toContain("method");
270 }).pipe(logLevel),
271 { tags: [...(dev ? ["local"] : ["live"])], timeout },
272 );
273 },
274 );
275 /**
276 * Container bound directly on a plain async Worker's `env` (issue #953),
277 * with the Container's logical id EQUAL to the env key
278 * (`ECHO: Container("ECHO", …)`). The Worker's two halves — the
279 * `durable_object_namespace` binding and the `containers` script metadata —
280 * describe the same env entry, so they are contributed under one `sid`.
281 * Contributing them under two (the env key and the ContainerApplication's
282 * logical id) made them collide for exactly this shape, and binding rows
283 * are collapsed by sid — last write wins — so the namespace binding was
284 * silently dropped. Live, the Worker then uploads the Container declaration
285 * itself as a `json` binding (`{"_id":"Effect","op":"alchemy/EffectClass"}`)
286 * and `getContainer` dies with `t.idFromName is not a function`; in dev the
287 * local runtime holds container metadata for a class it has no namespace
288 * for and the deploy dies with
289 * `Durable Object namespace AsyncEchoObject not found`.
290 */
291 describe("async container bound on env", () => {
292 const { test, beforeAll, afterAll, deploy, destroy } = make();
293 const stack = beforeAll(deploy(AsyncContainerStack), {
294 timeout: HOOK_TIMEOUT,
295 });
296 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(AsyncContainerStack), {
297 timeout: HOOK_TIMEOUT,
298 });
299
300 test(
301 "binds the container class as a durable object namespace",
302 Effect.gen(function* () {
303 const { url } = yield* stack;
304
305 const body = yield* fetchReady(new URL("/binding", url), "kind");
306 expect(JSON.parse(body)).toEqual({
307 kind: "durable_object_namespace",
308 });
309 }).pipe(logLevel),
310 { tags: [...(dev ? ["local"] : ["live"])], timeout },
311 );
312
313 test(
314 "serves through the container-backed DO class",
315 Effect.gen(function* () {
316 const { url } = yield* stack;
317
318 // The echo image reflects the request as JSON ("method" only appears
319 // in a real echo response, never in an error page) — proof the
320 // request went Worker → DO class → container port 8080 and back.
321 const hello = yield* fetchReady(new URL("/hello", url), "method");
322 expect(hello).toContain("method");
323 }).pipe(logLevel),
324 { tags: [...(dev ? ["local"] : ["live"])], timeout },
325 );
326 });
327 /**
328 * Issue #1321 — the same sid collision as above in the shape the
329 * Containers guide documents: `className` omitted, so the env key, the
330 * Container's logical id and the DO class are all one name
331 * (`Probe: Container("Probe", …)`). Deployed and driven over HTTP: the
332 * Worker must see a real namespace and reach the container through it.
333 */
334 describe("async container with inferred class name", () => {
335 const { test, beforeAll, afterAll, deploy, destroy } = make();
336 const stack = beforeAll(deploy(InferredClassStack), {
337 timeout: HOOK_TIMEOUT,
338 });
339 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(InferredClassStack), {
340 timeout: HOOK_TIMEOUT,
341 });
342
343 test(
344 "binds the container class as a durable object namespace",
345 Effect.gen(function* () {
346 const { url } = yield* stack;
347
348 const body = yield* fetchReady(new URL("/binding", url), "kind");
349 expect(JSON.parse(body)).toEqual({
350 kind: "durable_object_namespace",
351 });
352 }).pipe(logLevel),
353 { tags: [...(dev ? ["local"] : ["live"])], timeout },
354 );
355
356 test(
357 "serves through the container-backed DO class",
358 Effect.gen(function* () {
359 const { url } = yield* stack;
360
361 const hello = yield* fetchReady(new URL("/hello", url), "method");
362 expect(hello).toContain("method");
363 }).pipe(logLevel),
364 { tags: [...(dev ? ["local"] : ["live"])], timeout },
365 );
366 });
367 },
368 {
369 tags: [
370 "provider:cloudflare",
371 "provider:cloudflare:container",
372 "provider:cloudflare:worker",
373 ],
374 },
375);
376
377describe.sequential(
378 "container attachment recovery (live)",
379 {
380 tags: [
381 "provider:cloudflare",
382 "provider:cloudflare:container",
383 "provider:cloudflare:worker",
384 "live",
385 ],
386 },
387 () => {
388 const stage = process.env.ALCHEMY_TEST_STAGE ?? "test-live";
389 const providers = Cloudflare.providers();
390 const services = Layer.effectContext(
391 AttachmentStack.pipe(Effect.map((stack) => stack.services)),
392 ).pipe(Layer.provide(Layer.succeed(Stage, stage)));
393 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
394 providers,
395 state: Cloudflare.state(),
396 stage,
397 dev: false,
398 });
399 const stack = beforeAll(deploy(AttachmentStack), { timeout: 120_000 });
400 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(AttachmentStack), {
401 timeout: 120_000,
402 });
403 const workerKey = {
404 stack: attachmentStackName,
405 stage,
406 fqn: "AttachmentWorker",
407 };
408 const containerKey = {
409 stack: attachmentStackName,
410 stage,
411 fqn: "AttachmentContainer",
412 };
413
414 test(
415 "preserves the live attachment when the Worker projection and cached attachment are stale",
416 Effect.gen(function* () {
417 const first = yield* stack;
418 const namespaceId = first.app.durableObjects?.namespaceId;
419 const otherNamespaceId = first.otherApp.durableObjects?.namespaceId;
420 assert(namespaceId);
421 assert(otherNamespaceId);
422 expect(otherNamespaceId).not.toBe(namespaceId);
423 const detached = yield* attachmentStack(false).pipe(
424 Effect.flatMap((stack) =>
425 Plan.make(stack).pipe(Effect.provide(stack.services)),
426 ),
427 );
428 expect(detached.resources.AttachmentContainer.action).toBe("replace");
429 const state = yield* yield* State;
430 const workerRow = yield* state.get(workerKey);
431 const containerRow = yield* state.get(containerKey);
432 assert(
433 workerRow?.status === "created" || workerRow?.status === "updated",
434 );
435 assert(
436 containerRow?.status === "created" ||
437 containerRow?.status === "updated",
438 );
439 for (const durableObjects of [
440 { namespaceId: otherNamespaceId },
441 undefined,
442 ]) {
443 yield* Effect.gen(function* () {
444 yield* state.set({
445 ...workerKey,
446 value: {
447 ...workerRow,
448 attr: { ...workerRow.attr, durableObjectNamespaces: {} },
449 },
450 });
451 yield* state.set({
452 ...containerKey,
453 value: {
454 ...containerRow,
455 attr: {
456 ...containerRow.attr,
457 durableObjects,
458 hash: { ...containerRow.attr.hash, image: "stale" },
459 },
460 },
461 });
462 const plan = yield* AttachmentStack.pipe(
463 Effect.flatMap((stack) =>
464 Plan.make(stack).pipe(Effect.provide(stack.services)),
465 ),
466 );
467 expect(plan.resources.AttachmentContainer.action).toBe("update");
468 const next = yield* deploy(AttachmentStack);
469 expect(next.worker.durableObjectNamespaces).toEqual(
470 first.worker.durableObjectNamespaces,
471 );
472 expect(next.app.applicationId).toBe(first.app.applicationId);
473 expect(next.app.durableObjects).toEqual({ namespaceId });
474 const observed = yield* readAttachmentApplication(
475 first.app.accountId,
476 first.app.applicationId,
477 );
478 expect(observed.durableObjects).toEqual({ namespaceId });
479 expect(observed.maxInstances).toBe(2);
480 expect(next.otherApp.applicationId).toBe(
481 first.otherApp.applicationId,
482 );
483 const other = yield* readAttachmentApplication(
484 first.otherApp.accountId,
485 first.otherApp.applicationId,
486 );
487 expect(other.durableObjects).toEqual({
488 namespaceId: otherNamespaceId,
489 });
490 expect(other.maxInstances).toBe(2);
491 const response = yield* fetchReady(
492 new URL("/hello", next.url),
493 "method",
494 8,
495 );
496 expect(JSON.parse(response).method).toBe("GET");
497 }).pipe(
498 Effect.ensuring(
499 Effect.gen(function* () {
500 const current = yield* state.get(containerKey);
501 if (
502 current &&
503 "attr" in current &&
504 current.attr?.applicationId &&
505 current.attr.applicationId !== first.app.applicationId
506 ) {
507 yield* destroyStack({
508 stack: AttachmentStack,
509 stage,
510 dev: false,
511 });
512 }
513 yield* state
514 .set({ ...workerKey, value: workerRow })
515 .pipe(
516 Effect.ensuring(
517 state
518 .set({ ...containerKey, value: containerRow })
519 .pipe(Effect.orDie),
520 ),
521 );
522 }).pipe(Effect.orDie),
523 ),
524 );
525 }
526 }).pipe(Effect.provide(services)),
527 { timeout: 120_000, retry: 0 },
528 );
529
530 test(
531 "recovers an interrupted create with an unresolved Worker attachment",
532 Effect.gen(function* () {
533 const first = yield* stack;
534 const namespaceId = first.app.durableObjects?.namespaceId;
535 assert(namespaceId);
536 const state = yield* yield* State;
537 const workerRow = yield* state.get(workerKey);
538 const containerRow = yield* state.get(containerKey);
539 assert(
540 workerRow?.status === "created" || workerRow?.status === "updated",
541 );
542 assert(
543 containerRow?.status === "created" ||
544 containerRow?.status === "updated",
545 );
546 const interrupted = {
547 ...containerRow,
548 status: "creating" as const,
549 attr: undefined,
550 };
551 yield* Effect.gen(function* () {
552 yield* state.set({
553 ...workerKey,
554 value: {
555 ...workerRow,
556 attr: { ...workerRow.attr, durableObjectNamespaces: {} },
557 },
558 });
559 yield* state.set({ ...containerKey, value: interrupted });
560 const plan = yield* AttachmentStack.pipe(
561 Effect.flatMap((stack) =>
562 Plan.make(stack).pipe(Effect.provide(stack.services)),
563 ),
564 );
565 expect(plan.resources.AttachmentContainer).toMatchObject({
566 action: "create",
567 state: {
568 instanceId: containerRow.instanceId,
569 attr: {
570 applicationId: first.app.applicationId,
571 applicationName: first.app.applicationName,
572 durableObjects: { namespaceId },
573 },
574 },
575 });
576 expect(yield* state.get(containerKey)).toEqual(interrupted);
577 const recovered = yield* deploy(AttachmentStack);
578 expect(recovered.app.applicationId).toBe(first.app.applicationId);
579 expect(recovered.app.durableObjects).toEqual({ namespaceId });
580 expect(recovered.worker.durableObjectNamespaces).toEqual(
581 first.worker.durableObjectNamespaces,
582 );
583 const committed = yield* state.get(containerKey);
584 assert(
585 committed?.status === "created" || committed?.status === "updated",
586 );
587 expect(committed).toMatchObject({
588 instanceId: containerRow.instanceId,
589 attr: {
590 applicationId: first.app.applicationId,
591 durableObjects: { namespaceId },
592 },
593 });
594 const observed = yield* readAttachmentApplication(
595 first.app.accountId,
596 first.app.applicationId,
597 );
598 expect(observed.durableObjects).toEqual({ namespaceId });
599 const other = yield* readAttachmentApplication(
600 first.otherApp.accountId,
601 first.otherApp.applicationId,
602 );
603 expect(other.durableObjects).toEqual(first.otherApp.durableObjects);
604 expect(recovered.otherApp.applicationId).toBe(
605 first.otherApp.applicationId,
606 );
607 const response = yield* fetchReady(
608 new URL("/hello", recovered.url),
609 "method",
610 8,
611 );
612 expect(JSON.parse(response).method).toBe("GET");
613 }).pipe(
614 Effect.ensuring(
615 Effect.gen(function* () {
616 const current = yield* state.get(containerKey);
617 if (
618 current &&
619 "attr" in current &&
620 current.attr?.applicationId &&
621 current.attr.applicationId !== first.app.applicationId
622 ) {
623 yield* destroyStack({
624 stack: AttachmentStack,
625 stage,
626 dev: false,
627 });
628 }
629 yield* state
630 .set({ ...workerKey, value: workerRow })
631 .pipe(
632 Effect.ensuring(
633 state
634 .set({ ...containerKey, value: containerRow })
635 .pipe(Effect.orDie),
636 ),
637 );
638 }).pipe(Effect.orDie),
639 ),
640 );
641 }).pipe(Effect.provide(services)),
642 { timeout: 120_000, retry: 0 },
643 );
644
645 test(
646 "recovers a missing application only with its recorded exact attachment",
647 Effect.gen(function* () {
648 const first = yield* stack;
649 const namespaceId = first.app.durableObjects?.namespaceId;
650 assert(namespaceId);
651 const state = yield* yield* State;
652 const workerRow = yield* state.get(workerKey);
653 const containerRow = yield* state.get(containerKey);
654 assert(
655 workerRow?.status === "created" || workerRow?.status === "updated",
656 );
657 assert(
658 containerRow?.status === "created" ||
659 containerRow?.status === "updated",
660 );
661 const corruptProjection = (
662 durableObjects: { namespaceId?: string } | undefined,
663 ) =>
664 Effect.gen(function* () {
665 yield* state.set({
666 ...workerKey,
667 value: {
668 ...workerRow,
669 attr: { ...workerRow.attr, durableObjectNamespaces: {} },
670 },
671 });
672 yield* state.set({
673 ...containerKey,
674 value: {
675 ...containerRow,
676 attr: {
677 ...containerRow.attr,
678 durableObjects,
679 hash: { ...containerRow.attr.hash, image: "stale" },
680 },
681 },
682 });
683 });
684 yield* Effect.gen(function* () {
685 yield* Containers.deleteContainerApplication({
686 accountId: first.app.accountId,
687 applicationId: first.app.applicationId,
688 });
689 const remaining = yield* Containers.listContainerApplications({
690 accountId: first.app.accountId,
691 }).pipe(
692 Effect.repeat({
693 schedule: Schedule.spaced("1 second"),
694 until: (apps) =>
695 apps.every((app) => app.id !== first.app.applicationId),
696 times: 8,
697 }),
698 );
699 expect(
700 remaining.some((app) => app.id === first.app.applicationId),
701 ).toBe(false);
702 // Reconcile observes by id first; list deletion can propagate earlier.
703 const deleted = yield* Containers.getContainerApplication({
704 accountId: first.app.accountId,
705 applicationId: first.app.applicationId,
706 }).pipe(
707 Effect.tap((app) =>
708 Effect.logInfo(
709 `Deleted container application ${app.id} is absent from list but still readable by id`,
710 ),
711 ),
712 Effect.catchTag("ContainerApplicationNotFound", () =>
713 Effect.succeed(undefined),
714 ),
715 Effect.repeat({
716 schedule: Schedule.spaced("1 second"),
717 until: (app) => app === undefined,
718 times: 8,
719 }),
720 );
721 expect(deleted).toBeUndefined();
722 for (const missing of [undefined, {}]) {
723 yield* corruptProjection(missing);
724 const failed = yield* deploy(AttachmentStack).pipe(Effect.exit);
725 assert(Exit.isFailure(failed));
726 expect(Cause.pretty(failed.cause)).toContain(
727 "unresolved Durable Object namespace",
728 );
729 const applications = yield* Containers.listContainerApplications({
730 accountId: first.app.accountId,
731 });
732 expect(
733 applications.some(
734 (app) => app.name === first.app.applicationName,
735 ),
736 ).toBe(false);
737 }
738 yield* corruptProjection({ namespaceId });
739 const recovered = yield* deploy(AttachmentStack);
740 expect(recovered.worker.durableObjectNamespaces).toEqual(
741 first.worker.durableObjectNamespaces,
742 );
743 expect(recovered.app.applicationId).not.toBe(first.app.applicationId);
744 expect(recovered.app.durableObjects).toEqual({ namespaceId });
745 const observed = yield* readAttachmentApplication(
746 first.app.accountId,
747 recovered.app.applicationId,
748 );
749 expect(observed.durableObjects).toEqual({ namespaceId });
750 expect(observed.maxInstances).toBe(2);
751 const other = yield* readAttachmentApplication(
752 first.otherApp.accountId,
753 first.otherApp.applicationId,
754 );
755 expect(other.durableObjects).toEqual(first.otherApp.durableObjects);
756 const response = yield* fetchReady(
757 new URL("/hello", recovered.url),
758 "method",
759 8,
760 );
761 expect(JSON.parse(response).method).toBe("GET");
762 }).pipe(
763 Effect.ensuring(
764 Effect.gen(function* () {
765 // Delete the replacement while its current identity is still persisted.
766 yield* destroyStack({
767 stack: AttachmentStack,
768 stage,
769 dev: false,
770 });
771 yield* Effect.gen(function* () {
772 const remaining = yield* Containers.listContainerApplications({
773 accountId: first.app.accountId,
774 }).pipe(
775 Effect.repeat({
776 schedule: Schedule.spaced("1 second"),
777 until: (apps) =>
778 apps.every(
779 (app) =>
780 app.name !== first.app.applicationName &&
781 app.name !== first.otherApp.applicationName,
782 ),
783 times: 8,
784 }),
785 );
786 expect(
787 remaining.some(
788 (app) =>
789 app.name === first.app.applicationName ||
790 app.name === first.otherApp.applicationName,
791 ),
792 ).toBe(false);
793 }).pipe(
794 Effect.ensuring(
795 state
796 .set({ ...workerKey, value: workerRow })
797 .pipe(
798 Effect.ensuring(
799 state
800 .set({ ...containerKey, value: containerRow })
801 .pipe(Effect.orDie),
802 ),
803 Effect.orDie,
804 ),
805 ),
806 );
807 }).pipe(Effect.orDie),
808 ),
809 );
810 }).pipe(Effect.provide(services)),
811 { timeout: 120_000, retry: 0 },
812 );
813 },
814);
815
816const readAttachmentApplication = (accountId: string, applicationId: string) =>
817 Containers.getContainerApplication({ accountId, applicationId }).pipe(
818 Effect.retry({
819 while: (error) => error._tag === "ContainerApplicationNotFound",
820 schedule: Schedule.spaced("1 second"),
821 times: 8,
822 }),
823 );
824
825// Note on image choice for the non-Effect (`image`/`dockerfile`) variants:
826// stock nginx images crash-loop inside Cloudflare's container sandbox because
827// they symlink /var/log/nginx/{access,error}.log to /dev/stdout and
828// /dev/stderr, and opening those device paths fails with ENXIO (errno 6) — the
829// nginx master aborts with `[emerg] could not open error log file` before it
830// ever binds the port, so getTcpPort() can never connect. The fixtures work
831// around this two ways: the `external` Dockerfile (which we own) replaces the
832// log symlinks with real files, and the `remote` variant uses a server image
833// (`mendhak/http-https-echo`) that writes to its inherited stdout fd directly
834// instead of opening the /dev/std* paths as files.
835
836// Cap exponential backoff at 3s — keeps the fast path snappy but stops the
837// geometric blow-up from dominating wall time when CF edge is slow.
838const readinessSchedule = Schedule.min([
839 Schedule.exponential("500 millis"),
840 Schedule.spaced("3 seconds"),
841]);
842// ~3 min of 3s-spaced polls: a saturated full-suite run (many concurrent
843// docker builds/pushes on this machine) can keep a dev container answering
844// 500 well past the ~90s that 30 retries buys. Failing attempts return
845// fast, so the extra budget only matters on the slow path.
846const readinessRetries = 60;
847
848// While a freshly pre-created worker propagates, Cloudflare's edge serves
849// Alchemy's pre-create stub (200 with this body); any poll that sees it retries.
850const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed...";
851
852// Force `Connection: close` so each readiness attempt opens a fresh connection
853// and can land on an edge that already has the new deploy (a pooled keep-alive
854// socket stays pinned to one edge metal and can keep reading the stale body).
855const freshConn = HttpClient.HttpClient.pipe(
856 Effect.map(
857 HttpClient.mapRequest(HttpClientRequest.setHeader("connection", "close")),
858 ),
859);
860
861// Retry a freshly-deployed worker route until it answers 200 with a body that
862// contains `expected` — rejecting both transient non-200s and the deploy stub.
863// Each attempt is bounded so a worker that is hung waiting on its container
864// surfaces as a retryable failure rather than blocking the whole test budget.
865const fetchReady = (url: URL, expected: string, retries = readinessRetries) =>
866 Effect.gen(function* () {
867 const client = yield* freshConn;
868 return yield* client.get(url).pipe(
869 Effect.flatMap((r) =>
870 r.text.pipe(
871 Effect.flatMap((body) =>
872 r.status !== 200
873 ? Effect.fail(new Error(`Worker not ready: ${r.status} ${body}`))
874 : body.includes(DEPLOY_PLACEHOLDER) || !body.includes(expected)
875 ? Effect.fail(new Error(`not ready: got ${body}`))
876 : Effect.succeed(body),
877 ),
878 ),
879 ),
880 Effect.timeout("10 seconds"),
881 Effect.retry({ schedule: readinessSchedule, times: retries }),
882 );
883 });
884
885// Seed a value into R2 via the worker's `/seed` route (DO native binding),
886// retrying through cold-start until the producer accepts it (200).
887const seed = (url: string, key: string, value: string) =>
888 Effect.gen(function* () {
889 const client = yield* freshConn;
890 return yield* client
891 .execute(
892 HttpClientRequest.put(
893 new URL(`/seed?key=${encodeURIComponent(key)}`, url),
894 ).pipe(HttpClientRequest.bodyText(value)),
895 )
896 .pipe(
897 Effect.flatMap((r) =>
898 r.status === 200
899 ? Effect.succeed(r)
900 : Effect.fail(new Error(`seed not ready: ${r.status}`)),
901 ),
902 Effect.timeout("10 seconds"),
903 Effect.retry({
904 schedule: readinessSchedule,
905 times: readinessRetries,
906 }),
907 );
908 });