1import * as Cloudflare from "@/Cloudflare";
2import * as Drift from "@/Drift.ts";
3import * as Deploy from "@/Deploy.ts";
4import * as Destroy from "@/Destroy.ts";
5import { Docker, DockerLive } from "@/Docker/Docker.ts";
6import * as Layer from "effect/Layer";
7import * as Config from "effect/Config";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import { ExternalContainer } from "./fixtures/external/object.ts";
14import ExternalContainerWorker from "./fixtures/external/worker.ts";
15import MyContainerLive, {
16 MyContainer,
17} from "./fixtures/effectful/container.ts";
18import EffectfulContainerWorker from "./fixtures/effectful/worker.ts";
19import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
20import * as Provider from "@/Provider";
21import { Stack } from "@/Stack";
22import { State, type ResourceState } from "@/State";
23import * as Test from "@/Test/Alchemy";
24import * as Containers from "@distilled.cloud/cloudflare/containers";
25import { assert, describe, expect } from "alchemy-test";
26import * as Effect from "effect/Effect";
27import { MinimumLogLevel } from "effect/References";
28import * as Schedule from "effect/Schedule";
29import { applications } from "./fixtures/identity/applications.ts";
30import {
31 publicationApplications,
32 sharedApplication,
33 recoveryApplications,
34 historyApplications,
35} from "./fixtures/publication/applications.ts";
36import { ContainerPlatform } from "@/Cloudflare/Containers/ContainerPlatform.ts";
37import * as Cause from "effect/Cause";
38import * as Exit from "effect/Exit";
39import * as Fiber from "effect/Fiber";
40import {
41 buildHistory,
42 supportsRegistryExport,
43 withBuilder,
44} from "./fixtures/buildx.ts";
45import { EnvBucket, RemoteContainer } from "./fixtures/remote/object.ts";
46import RemoteContainerWorker from "./fixtures/remote/worker.ts";
47const { test } = Test.make({
48 providers: Layer.mergeAll(Cloudflare.providers(), DockerLive),
49});
50const { test: registryCacheTest } = Test.make({
51 // Each deployment must consult the registry, not a previous provider's map.
52 providers: Layer.fresh(Layer.mergeAll(Cloudflare.providers(), DockerLive)),
53});
54
55const logLevel = Effect.provideService(
56 MinimumLogLevel,
57 process.env.DEBUG ? "Debug" : "Info",
58);
59
60type Scratch = Parameters<Parameters<typeof test.provider>[1]>[0];
61
62/** Deploy a standalone container application from a remote `image`. */
63const deployImage = (scratch: Scratch, image: string) =>
64 scratch.deploy(
65 Effect.gen(function* () {
66 return {
67 app: yield* Cloudflare.Container("DigestReuse", { image }).Application,
68 };
69 }),
70 );
71
72/** The live (active) image reference + version of an application. */
73const live = (accountId: string, applicationId: string) =>
74 Containers.getContainerApplication({ accountId, applicationId }).pipe(
75 Effect.map((app) => ({
76 version: app.version,
77 image: app.configuration.image,
78 durableObjects: app.durableObjects ?? undefined,
79 })),
80 );
81
82/**
83 * Poll until the application's active image is `image`. Cloudflare reports
84 * the ACTIVE configuration (and version) until a rollout completes, so any
85 * assertion on a changed image has to wait for the rollout.
86 */
87const waitForImage = (
88 accountId: string,
89 applicationId: string,
90 image: string,
91) =>
92 live(accountId, applicationId).pipe(
93 Effect.repeat({
94 schedule: Schedule.spaced("3 seconds"),
95 until: (app) => app.image === image,
96 times: 30,
97 }),
98 );
99
100/** `<repo>:<sourceHash>` — the mutable tag the provider pushed `app` as. */
101const taggedRefOf = (app: {
102 configuration: { image?: string };
103 hash?: { image: string };
104}) => {
105 const digestRef = app.configuration.image!;
106 return `${digestRef.slice(0, digestRef.indexOf("@"))}:${app.hash!.image}`;
107};
108
109/** Rewrite the persisted attributes of the scratch row for `fqn`. */
110const patchRow = <A extends Record<string, any>>(
111 fqn: string,
112 patch: (attr: A) => A,
113) =>
114 Effect.gen(function* () {
115 const state = yield* yield* State;
116 const stk = yield* Stack;
117 const key = { stack: stk.name, stage: stk.stage, fqn };
118 const row = (yield* state.get(key)) as ResourceState;
119 yield* state.set({
120 ...key,
121 value: { ...row, attr: patch(row.attr as A) },
122 });
123 });
124
125// Every test owns a name-namespaced scratch stack, so they can run
126// concurrently. The `exclusive: true` builder tests below still take the
127// whole-process lock (they mutate `BUILDX_BUILDER`) and serialize among
128// themselves.
129describe.concurrent(
130 "ContainerApplication",
131 {
132 tags: [
133 "provider:cloudflare",
134 "provider:cloudflare:container",
135 "provider:docker",
136 "live",
137 ],
138 },
139 () => {
140 test.provider(
141 "recovers an interrupted generated create and reconciles observed drift",
142 (stack) =>
143 Effect.gen(function* () {
144 yield* stack.destroy();
145 const first = yield* stack.deploy(applications());
146 const state = yield* yield* State;
147 const key = {
148 stack: stack.name,
149 stage: stack.stage,
150 fqn: "CachedIdentity",
151 };
152 const row = yield* state.get(key);
153 assert(row?.status === "created" || row?.status === "updated");
154 yield* Containers.updateContainerApplication({
155 accountId: first.owned.accountId,
156 applicationId: first.owned.applicationId,
157 maxInstances: 4,
158 });
159 const observed = yield* Containers.getContainerApplication({
160 accountId: first.owned.accountId,
161 applicationId: first.owned.applicationId,
162 });
163 expect(observed.maxInstances).toBe(4);
164 const interrupted = {
165 ...row,
166 status: "creating" as const,
167 attr: undefined,
168 };
169 yield* Effect.gen(function* () {
170 yield* state.set({ ...key, value: interrupted });
171 const plan = yield* stack.plan(applications());
172 expect(plan.resources.CachedIdentity).toMatchObject({
173 action: "create",
174 state: {
175 instanceId: row.instanceId,
176 attr: {
177 applicationId: first.owned.applicationId,
178 applicationName: first.owned.applicationName,
179 accountId: first.owned.accountId,
180 maxInstances: 4,
181 },
182 },
183 });
184 expect(plan.resources.OtherIdentity.action).toBe("noop");
185 expect(yield* state.get(key)).toEqual(interrupted);
186
187 const renamed = yield* stack.plan(
188 applications(
189 2,
190 `renamed-${first.owned.applicationName.slice(-24)}`,
191 ),
192 );
193 expect(renamed.resources.CachedIdentity.action).toBe("replace");
194 expect(yield* state.get(key)).toEqual(interrupted);
195 expect(
196 yield* Containers.getContainerApplication({
197 accountId: first.owned.accountId,
198 applicationId: first.owned.applicationId,
199 }),
200 ).toEqual(observed);
201
202 yield* state.set({
203 ...key,
204 value: { ...interrupted, providerMode: "local" },
205 });
206 const switched = yield* stack.plan(applications());
207 expect(switched.resources.CachedIdentity.action).toBe("replace");
208 expect(
209 switched.resources.CachedIdentity.state?.attr,
210 ).toBeUndefined();
211 yield* state.set({
212 ...key,
213 value: {
214 ...interrupted,
215 instanceId: "00000000000000000000000000000000",
216 },
217 });
218 const unrelated = yield* stack.plan(applications());
219 expect(unrelated.resources.CachedIdentity.action).toBe("create");
220 expect(
221 unrelated.resources.CachedIdentity.state?.attr,
222 ).toBeUndefined();
223
224 yield* state.delete(key);
225 const fresh = yield* stack.plan(applications());
226 expect(fresh.resources.CachedIdentity.action).toBe("create");
227 expect(fresh.resources.CachedIdentity.state).toBeUndefined();
228 expect(yield* state.get(key)).toBeUndefined();
229
230 yield* state.set({ ...key, value: interrupted });
231 const recovered = yield* stack.deploy(applications(5));
232 expect(recovered.owned.applicationId).toBe(
233 first.owned.applicationId,
234 );
235 expect(recovered.other.applicationId).toBe(
236 first.other.applicationId,
237 );
238 expect(yield* state.get(key)).toMatchObject({
239 status: "created",
240 instanceId: row.instanceId,
241 attr: {
242 applicationId: first.owned.applicationId,
243 maxInstances: 5,
244 },
245 });
246 const live = yield* Containers.getContainerApplication({
247 accountId: first.owned.accountId,
248 applicationId: first.owned.applicationId,
249 });
250 expect(live.maxInstances).toBe(5);
251 }).pipe(
252 Effect.ensuring(
253 Effect.gen(function* () {
254 yield* stack.destroy();
255 yield* Effect.gen(function* () {
256 const restored = yield* yield* State;
257 yield* restored.set({ ...key, value: row });
258 expect(yield* restored.get(key)).toEqual(row);
259 }).pipe(Effect.provide(Layer.fresh(stack.state)));
260 yield* stack.destroy();
261 }).pipe(Effect.orDie),
262 ),
263 );
264 for (const app of [first.owned, first.other]) {
265 const deleted = yield* Containers.getContainerApplication({
266 accountId: app.accountId,
267 applicationId: app.applicationId,
268 }).pipe(
269 Effect.catchTag("ContainerApplicationNotFound", () =>
270 Effect.succeed(undefined),
271 ),
272 Effect.repeat({
273 schedule: Schedule.spaced("1 second"),
274 until: (app) => app === undefined,
275 times: 8,
276 }),
277 );
278 expect(deleted).toBeUndefined();
279 }
280 }).pipe(logLevel),
281 { timeout: 120_000 },
282 );
283
284 test.provider(
285 "keeps explicit names unowned even when they match a generated identity",
286 (stack) =>
287 Effect.gen(function* () {
288 yield* stack.destroy();
289 const first = yield* stack.deploy(applications());
290 const program = applications(2, first.owned.applicationName);
291 const state = yield* yield* State;
292 const key = {
293 stack: stack.name,
294 stage: stack.stage,
295 fqn: "CachedIdentity",
296 };
297 const row = yield* state.get(key);
298 assert(row?.status === "created" || row?.status === "updated");
299 const observed = yield* Containers.getContainerApplication({
300 accountId: first.owned.accountId,
301 applicationId: first.owned.applicationId,
302 });
303 yield* Effect.gen(function* () {
304 const interrupted = {
305 ...row,
306 props: { ...row.props, name: first.owned.applicationName },
307 status: "creating" as const,
308 attr: undefined,
309 };
310 yield* state.set({ ...key, value: interrupted });
311 const recovery = yield* stack
312 .plan(program)
313 .pipe(
314 Effect.catchTag("OwnedBySomeoneElse", () =>
315 Effect.succeed("unowned"),
316 ),
317 );
318 expect(recovery).toBe("unowned");
319 expect(yield* state.get(key)).toEqual(interrupted);
320 yield* state.delete(key);
321 const discovery = yield* stack
322 .plan(program)
323 .pipe(
324 Effect.catchTag("OwnedBySomeoneElse", () =>
325 Effect.succeed("unowned"),
326 ),
327 );
328 expect(discovery).toBe("unowned");
329 expect(yield* state.get(key)).toBeUndefined();
330 expect(
331 yield* Containers.getContainerApplication({
332 accountId: first.owned.accountId,
333 applicationId: first.owned.applicationId,
334 }),
335 ).toEqual(observed);
336 }).pipe(
337 Effect.ensuring(
338 Effect.gen(function* () {
339 yield* stack.destroy();
340 yield* Effect.gen(function* () {
341 const restored = yield* yield* State;
342 yield* restored.set({ ...key, value: row });
343 expect(yield* restored.get(key)).toEqual(row);
344 }).pipe(Effect.provide(Layer.fresh(stack.state)));
345 yield* stack.destroy();
346 }).pipe(Effect.orDie),
347 ),
348 );
349 for (const app of [first.owned, first.other]) {
350 const deleted = yield* Containers.getContainerApplication({
351 accountId: app.accountId,
352 applicationId: app.applicationId,
353 }).pipe(
354 Effect.catchTag("ContainerApplicationNotFound", () =>
355 Effect.succeed(undefined),
356 ),
357 Effect.repeat({
358 schedule: Schedule.spaced("1 second"),
359 until: (app) => app === undefined,
360 times: 8,
361 }),
362 );
363 expect(deleted).toBeUndefined();
364 }
365 }).pipe(logLevel),
366 { timeout: 120_000 },
367 );
368
369 test.provider(
370 "destroys an interrupted generated create without resuming it",
371 (stack) =>
372 Effect.gen(function* () {
373 yield* stack.destroy();
374 const first = yield* stack.deploy(applications());
375 const state = yield* yield* State;
376 const key = {
377 stack: stack.name,
378 stage: stack.stage,
379 fqn: "CachedIdentity",
380 };
381 const row = yield* state.get(key);
382 assert(row?.status === "created" || row?.status === "updated");
383 yield* Effect.gen(function* () {
384 yield* state.set({
385 ...key,
386 value: { ...row, status: "creating", attr: undefined },
387 });
388 yield* stack.destroy();
389 for (const app of [first.owned, first.other]) {
390 const deleted = yield* Containers.getContainerApplication({
391 accountId: app.accountId,
392 applicationId: app.applicationId,
393 }).pipe(
394 Effect.catchTag("ContainerApplicationNotFound", () =>
395 Effect.succeed(undefined),
396 ),
397 Effect.repeat({
398 schedule: Schedule.spaced("1 second"),
399 until: (app) => app === undefined,
400 times: 8,
401 }),
402 );
403 expect(deleted).toBeUndefined();
404 }
405 expect(yield* state.get(key)).toBeUndefined();
406 }).pipe(
407 Effect.ensuring(
408 Effect.gen(function* () {
409 const restored = yield* yield* State;
410 yield* restored.set({ ...key, value: row });
411 expect(yield* restored.get(key)).toEqual(row);
412 }).pipe(Effect.provide(Layer.fresh(stack.state)), Effect.orDie),
413 ),
414 );
415 yield* stack.destroy();
416 }).pipe(logLevel),
417 { timeout: 120_000 },
418 );
419
420 test.provider(
421 "compares each legacy application's previous digest independently",
422 (stack) =>
423 Effect.gen(function* () {
424 yield* stack.destroy();
425 const initial = yield* stack.deploy(historyApplications());
426 expect(initial.first.hash?.digest).not.toBe(
427 initial.second.hash?.digest,
428 );
429 const state = yield* yield* State;
430 const ids = ["HistoryFirst", "HistorySecond"];
431 const updated = yield* Effect.acquireUseRelease(
432 Effect.forEach(ids, (id) =>
433 state.get({ stack: stack.name, stage: stack.stage, fqn: id }),
434 ),
435 () =>
436 Effect.gen(function* () {
437 for (const id of ids) {
438 yield* patchRow<typeof initial.first>(id, (attr) => ({
439 ...attr,
440 hash: { image: attr.hash!.image },
441 }));
442 }
443 const updated = yield* stack.deploy(historyApplications(true));
444 assert(updated.target);
445 expect(updated.target.configuration.image).not.toBe(
446 initial.first.configuration.image,
447 );
448 expect(updated.target.hash?.digest).toBe(
449 initial.first.hash?.digest,
450 );
451 expect(updated.first.applicationId).toBe(
452 initial.first.applicationId,
453 );
454 expect(updated.first.configuration.image).toBe(
455 initial.first.configuration.image,
456 );
457 expect(updated.second.applicationId).toBe(
458 initial.second.applicationId,
459 );
460 expect(updated.second.configuration.image).toBe(
461 updated.target.configuration.image,
462 );
463 expect(updated.second.hash?.digest).toBe(
464 initial.first.hash?.digest,
465 );
466 const desiredImage = updated.target.configuration.image;
467 const observed = yield* live(
468 updated.second.accountId,
469 updated.second.applicationId,
470 ).pipe(
471 Effect.repeat({
472 schedule: Schedule.spaced("5 seconds"),
473 until: (app) => app.image === desiredImage,
474 times: 8,
475 }),
476 );
477 expect(observed.image).toBe(updated.target.configuration.image);
478 return updated;
479 }),
480 (rows) =>
481 Effect.forEach(rows, (row, i) => {
482 assert(row);
483 return state.set({
484 stack: stack.name,
485 stage: stack.stage,
486 fqn: ids[i],
487 value: row,
488 });
489 }).pipe(Effect.orDie),
490 );
491 yield* stack.destroy();
492 for (const app of [updated.first, updated.second, updated.target]) {
493 assert(app);
494 const deleted = yield* Containers.getContainerApplication({
495 accountId: app.accountId,
496 applicationId: app.applicationId,
497 }).pipe(
498 Effect.catchTag("ContainerApplicationNotFound", () =>
499 Effect.succeed(undefined),
500 ),
501 Effect.repeat({
502 schedule: Schedule.spaced("1 second"),
503 until: (app) => app === undefined,
504 times: 8,
505 }),
506 );
507 expect(deleted).toBeUndefined();
508 }
509 }).pipe(logLevel),
510 { timeout: 120_000 },
511 );
512
513 for (const failure of ["failed", "interrupted"] as const) {
514 test.provider(
515 `retries ${failure === "failed" ? "a failed" : "an interrupted"} publication in the same provider`,
516 (stack) =>
517 Effect.gen(function* () {
518 yield* stack.destroy();
519 const provider = yield* Provider.findProvider(ContainerPlatform);
520 const program = (includeSecond = false) =>
521 Effect.gen(function* () {
522 expect(yield* Provider.findProvider(ContainerPlatform)).toBe(
523 provider,
524 );
525 return yield* recoveryApplications(
526 failure === "interrupted" ? 10 : 0,
527 includeSecond,
528 );
529 });
530 if (failure === "failed") {
531 const failed = yield* Effect.acquireUseRelease(
532 Effect.sync(() => {
533 const previous = process.env.BUILDX_BUILDER;
534 process.env.BUILDX_BUILDER = "alchemy-publication-missing";
535 return previous;
536 }),
537 () => stack.deploy(program()).pipe(Effect.exit),
538 (previous) =>
539 Effect.sync(() => {
540 if (previous === undefined)
541 delete process.env.BUILDX_BUILDER;
542 else process.env.BUILDX_BUILDER = previous;
543 }),
544 );
545 assert(Exit.isFailure(failed));
546 expect(Cause.pretty(failed.cause)).toContain(
547 "alchemy-publication-missing",
548 );
549 } else {
550 yield* Effect.acquireUseRelease(
551 stack.deploy(program()).pipe(Effect.forkChild),
552 (fiber) =>
553 Effect.gen(function* () {
554 const history = yield* buildHistory.pipe(
555 Effect.repeat({
556 schedule: Schedule.spaced("1 second"),
557 until: (builds) =>
558 builds.some((build) => build.status === "Running"),
559 times: 10,
560 }),
561 );
562 const running = history.find(
563 (build) => build.status === "Running",
564 );
565 assert(running);
566 yield* Fiber.interrupt(fiber);
567 const interrupted = yield* Fiber.await(fiber);
568 assert(Exit.isFailure(interrupted));
569 expect(Cause.hasInterrupts(interrupted.cause)).toBe(true);
570 const stopped = yield* buildHistory.pipe(
571 Effect.repeat({
572 schedule: Schedule.spaced("1 second"),
573 until: (builds) =>
574 builds.some(
575 (build) =>
576 build.ref === running.ref &&
577 build.status !== "Running",
578 ),
579 times: 8,
580 }),
581 );
582 const stoppedBuild = stopped.find(
583 (build) => build.ref === running.ref,
584 );
585 assert(stoppedBuild);
586 expect(stoppedBuild.status).not.toBe("Running");
587 }),
588 (fiber) => Fiber.interrupt(fiber),
589 );
590 }
591 const failedBuilds = yield* buildHistory;
592 expect(
593 failedBuilds.filter((build) => build.status === "Completed"),
594 ).toHaveLength(0);
595 const recovered = yield* stack.deploy(program());
596 const recoveredBuilds = yield* buildHistory;
597 expect(
598 recoveredBuilds.length - failedBuilds.length,
599 ).toBeGreaterThanOrEqual(1);
600 expect(
601 recoveredBuilds.length - failedBuilds.length,
602 ).toBeLessThanOrEqual(6);
603 expect(
604 recoveredBuilds.filter((build) => build.status === "Completed"),
605 ).toHaveLength(1);
606 const shared = yield* stack.deploy(program(true));
607 assert(shared.second);
608 expect(shared.first.applicationId).toBe(
609 recovered.first.applicationId,
610 );
611 expect(shared.second.applicationId).not.toBe(
612 shared.first.applicationId,
613 );
614 expect(shared.second.configuration.image).toBe(
615 shared.first.configuration.image,
616 );
617 expect(yield* buildHistory).toEqual(recoveredBuilds);
618 for (const app of [shared.first, shared.second]) {
619 const observed = yield* Containers.getContainerApplication({
620 accountId: app.accountId,
621 applicationId: app.applicationId,
622 });
623 expect(observed.configuration.image).toBe(
624 shared.first.configuration.image,
625 );
626 }
627 yield* stack.destroy();
628 for (const app of [shared.first, shared.second]) {
629 const deleted = yield* Containers.getContainerApplication({
630 accountId: app.accountId,
631 applicationId: app.applicationId,
632 }).pipe(
633 Effect.catchTag("ContainerApplicationNotFound", () =>
634 Effect.succeed(undefined),
635 ),
636 Effect.repeat({
637 schedule: Schedule.spaced("1 second"),
638 until: (app) => app === undefined,
639 times: 8,
640 }),
641 );
642 expect(deleted).toBeUndefined();
643 }
644 }).pipe(withBuilder(`alchemy-publication-${failure}`), logLevel),
645 { timeout: 120_000, exclusive: true },
646 );
647 }
648
649 test.provider(
650 "shares publication without mixing contexts or application settings",
651 (stack) =>
652 Effect.gen(function* () {
653 yield* stack.destroy();
654 const fs = yield* FileSystem.FileSystem;
655 const path = yield* Path.Path;
656 const root = yield* fs.makeTempDirectoryScoped({
657 prefix: "alchemy-publication-",
658 });
659 const contexts = {
660 shared: path.join(root, "shared"),
661 other: path.join(root, "other"),
662 changed: path.join(root, "changed"),
663 };
664 for (const [kind, context] of Object.entries(contexts)) {
665 yield* fs.makeDirectory(context);
666 yield* fs.writeFileString(
667 path.join(context, ".gitignore"),
668 "payload.txt\n",
669 );
670 yield* fs.writeFileString(path.join(context, "payload.txt"), kind);
671 yield* fs.writeFileString(path.join(context, "input.txt"), "first");
672 yield* fs.writeFileString(
673 path.join(context, "Dockerfile"),
674 [
675 "FROM alpine:3.19",
676 "COPY payload.txt input.txt /",
677 'CMD ["sleep", "3600"]',
678 ...(kind === "changed" ? ["LABEL variant=changed"] : []),
679 ].join("\n"),
680 );
681 }
682 const program = publicationApplications(contexts);
683 const first = yield* stack.deploy(program);
684 const history = yield* buildHistory;
685 expect(
686 history.filter((build) => build.status === "Completed"),
687 ).toHaveLength(3);
688 // Each distinct publication can make at most six export attempts.
689 expect(history.length).toBeLessThanOrEqual(18);
690 expect(first.first.hash?.image).not.toBe(first.other.hash?.image);
691 expect(first.first.configuration.image).toBe(
692 first.second.configuration.image,
693 );
694 expect(first.first.configuration.image).not.toBe(
695 first.other.configuration.image,
696 );
697 expect(first.first.hash?.image).not.toBe(first.changed.hash?.image);
698 expect(first.first.applicationId).not.toBe(
699 first.second.applicationId,
700 );
701 for (const [slot, app] of [
702 ["first", first.first],
703 ["second", first.second],
704 ] as const) {
705 const observed = yield* Containers.getContainerApplication({
706 accountId: app.accountId,
707 applicationId: app.applicationId,
708 });
709 expect(observed.configuration.image).toBe(
710 first.first.configuration.image,
711 );
712 expect(observed.configuration.environmentVariables).toContainEqual({
713 name: "SLOT",
714 value: slot,
715 });
716 expect(observed.maxInstances).toBe(slot === "first" ? 2 : 3);
717 }
718 const unchanged = yield* stack.deploy(program);
719 expect(unchanged.first.configuration.image).toBe(
720 first.first.configuration.image,
721 );
722 expect(yield* buildHistory).toEqual(history);
723
724 yield* fs.writeFileString(
725 path.join(contexts.shared, "input.txt"),
726 "second",
727 );
728 const updated = yield* stack.deploy(program);
729 const updateHistory = (yield* buildHistory).filter(
730 (build) => !history.some((previous) => previous.ref === build.ref),
731 );
732 expect(
733 updateHistory.filter((build) => build.status === "Completed"),
734 ).toHaveLength(1);
735 expect(updateHistory.length).toBeLessThanOrEqual(6);
736 expect(updated.first.applicationId).toBe(first.first.applicationId);
737 expect(updated.second.applicationId).toBe(first.second.applicationId);
738 expect(updated.first.configuration.image).toBe(
739 updated.second.configuration.image,
740 );
741 expect(updated.first.hash?.digest).not.toBe(first.first.hash?.digest);
742 expect(updated.other.configuration.image).toBe(
743 first.other.configuration.image,
744 );
745 expect(updated.changed.configuration.image).toBe(
746 first.changed.configuration.image,
747 );
748 yield* stack.destroy();
749 for (const app of Object.values(updated)) {
750 const deleted = yield* Containers.getContainerApplication({
751 accountId: app.accountId,
752 applicationId: app.applicationId,
753 }).pipe(
754 Effect.catchTag("ContainerApplicationNotFound", () =>
755 Effect.succeed(undefined),
756 ),
757 Effect.repeat({
758 schedule: Schedule.spaced("1 second"),
759 until: (app) => app === undefined,
760 times: 8,
761 }),
762 );
763 expect(deleted).toBeUndefined();
764 }
765 }).pipe(withBuilder("alchemy-publication-cache"), logLevel),
766 { timeout: 120_000, exclusive: true },
767 );
768
769 registryCacheTest.provider(
770 "caches default publications and imports repository layers in a fresh builder",
771 (stack) =>
772 Effect.gen(function* () {
773 yield* stack.destroy();
774 const fs = yield* FileSystem.FileSystem;
775 const path = yield* Path.Path;
776 const docker = yield* Docker;
777 const context = yield* fs.makeTempDirectoryScoped({
778 prefix: "alchemy-default-cache-",
779 });
780 yield* fs.writeFileString(
781 path.join(context, "Dockerfile"),
782 'FROM alpine:3.19\nRUN cat /proc/sys/kernel/random/uuid > /layer\nCOPY payload /payload\nCMD ["sleep", "3600"]\n',
783 );
784 yield* fs.writeFileString(path.join(context, "payload"), "first");
785 const program = sharedApplication(context);
786 const first = yield* stack.deploy(program);
787 expect(first.app.configuration.image).toContain(
788 `/${first.app.applicationName}@sha256:`,
789 );
790 const firstHistory = yield* buildHistory;
791 expect(
792 firstHistory.filter((build) => build.status === "Completed"),
793 ).toHaveLength(1);
794
795 const credentials =
796 yield* Containers.createContainerRegistryCredentials({
797 accountId: first.app.accountId,
798 registryId: "registry.cloudflare.com",
799 permissions: ["pull"],
800 expirationMinutes: 15,
801 });
802 const username = credentials.username ?? credentials.user;
803 assert(username);
804 const client = yield* HttpClient.HttpClient;
805 const cacheDigest = Effect.gen(function* () {
806 const response = yield* client.execute(
807 HttpClientRequest.head(
808 `https://registry.cloudflare.com/v2/${first.app.accountId}/${first.app.applicationName}/manifests/buildcache`,
809 ).pipe(
810 HttpClientRequest.basicAuth(username, credentials.password),
811 HttpClientRequest.setHeader(
812 "Accept",
813 "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
814 ),
815 ),
816 );
817 expect(response.status).toBe(200);
818 return response.headers["docker-content-digest"];
819 });
820 expect(yield* cacheDigest).toBe(first.app.hash?.digest);
821 const layersOf = Effect.fn(function* (digest: string) {
822 const response = yield* client.execute(
823 HttpClientRequest.get(
824 `https://registry.cloudflare.com/v2/${first.app.accountId}/${first.app.applicationName}/manifests/${digest}`,
825 ).pipe(
826 HttpClientRequest.basicAuth(username, credentials.password),
827 HttpClientRequest.setHeader(
828 "Accept",
829 "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
830 ),
831 ),
832 );
833 expect(response.status).toBe(200);
834 const manifest = yield* response.json.pipe(
835 Effect.flatMap(
836 Schema.decodeUnknownEffect(
837 Schema.Struct({
838 layers: Schema.Array(
839 Schema.Struct({ digest: Schema.String }),
840 ),
841 }),
842 ),
843 ),
844 );
845 return manifest.layers.map((layer) => layer.digest);
846 });
847 const firstLayers = yield* layersOf(first.app.hash!.digest!);
848 expect(firstLayers.length).toBeGreaterThanOrEqual(2);
849
850 yield* fs.writeFileString(path.join(context, "payload"), "changed");
851 const changed = yield* Effect.gen(function* () {
852 // A new BuildKit instance cannot reuse the first builder's local layers.
853 expect(yield* buildHistory).toHaveLength(0);
854 const changed = yield* stack.deploy(program);
855 const completed = (yield* buildHistory).filter(
856 (build) => build.status === "Completed",
857 );
858 expect(completed).toHaveLength(1);
859 const log = yield* docker.run([
860 "buildx",
861 "history",
862 "logs",
863 completed[0]!.ref.split("/").at(-1)!,
864 "--progress",
865 "plain",
866 ]);
867 const text = `${log.stdout}\n${log.stderr}`;
868 expect(text).toContain(
869 `importing cache manifest from registry.cloudflare.com/${first.app.accountId}/${first.app.applicationName}:buildcache`,
870 );
871 const changedLayers = yield* layersOf(changed.app.hash!.digest!);
872 // Re-executing RUN would create a different UUID and therefore a different layer.
873 expect(changedLayers.slice(0, -1)).toEqual(
874 firstLayers.slice(0, -1),
875 );
876 expect(changedLayers.at(-1)).not.toBe(firstLayers.at(-1));
877 return changed;
878 }).pipe(
879 withBuilder("alchemy-default-cache-changed", {
880 attestations: false,
881 }),
882 );
883 expect(changed.app.applicationId).toBe(first.app.applicationId);
884 expect(changed.app.applicationName).toBe(first.app.applicationName);
885 expect(changed.app.configuration.image).not.toBe(
886 first.app.configuration.image,
887 );
888 // The registry keeps serving an overwritten tag's previous target
889 // for a while (observed ~25s), so wait for the new one.
890 expect(
891 yield* cacheDigest.pipe(
892 Effect.repeat({
893 schedule: Schedule.spaced("4 seconds"),
894 until: (digest) => digest === changed.app.hash?.digest,
895 times: 10,
896 }),
897 ),
898 ).toBe(changed.app.hash?.digest);
899
900 yield* fs.writeFileString(path.join(context, "payload"), "first");
901 const restored = yield* Effect.acquireUseRelease(
902 Effect.sync(() => {
903 const previous = process.env.BUILDX_BUILDER;
904 process.env.BUILDX_BUILDER =
905 "alchemy-default-cache-must-not-build";
906 return previous;
907 }),
908 () => stack.deploy(program),
909 (previous) =>
910 Effect.sync(() => {
911 if (previous === undefined) delete process.env.BUILDX_BUILDER;
912 else process.env.BUILDX_BUILDER = previous;
913 }),
914 );
915 expect(restored.app.applicationId).toBe(first.app.applicationId);
916 expect(restored.app.configuration.image).toBe(
917 first.app.configuration.image,
918 );
919 expect(yield* buildHistory).toEqual(firstHistory);
920 // Reusing a finished image leaves the latest exported layer cache in place.
921 expect(yield* cacheDigest).toBe(changed.app.hash?.digest);
922 expect(
923 (yield* waitForImage(
924 restored.app.accountId,
925 restored.app.applicationId,
926 restored.app.configuration.image!,
927 )).image,
928 ).toBe(first.app.configuration.image);
929 yield* stack.destroy();
930 const gone = yield* Containers.getContainerApplication({
931 accountId: first.app.accountId,
932 applicationId: first.app.applicationId,
933 }).pipe(
934 Effect.catchTag("ContainerApplicationNotFound", () =>
935 Effect.succeed(undefined),
936 ),
937 Effect.repeat({
938 until: (app) => app === undefined,
939 schedule: Schedule.spaced("1 second"),
940 times: 8,
941 }),
942 );
943 expect(gone).toBeUndefined();
944 }).pipe(
945 withBuilder("alchemy-default-cache", { attestations: false }),
946 logLevel,
947 ),
948 { timeout: 120_000, exclusive: true },
949 );
950
951 // Publication reuse was confined to a provider instance in:
952 // https://github.com/alchemy-run/alchemy/commit/275361dfe8358684439e42120298723305175d39
953 registryCacheTest.provider(
954 "reuses registry images across fresh providers and invalidates changed build inputs",
955 (stack) =>
956 Effect.gen(function* () {
957 yield* stack.destroy();
958 const fs = yield* FileSystem.FileSystem;
959 const path = yield* Path.Path;
960 const root = yield* fs.makeTempDirectoryScoped({
961 prefix: "alchemy-registry-cache-",
962 });
963 const context = path.join(root, "context");
964 const repositoryName = `alchemy-registry-cache-${stack.stage.toLowerCase()}`;
965 yield* fs.makeDirectory(context);
966 yield* fs.writeFileString(
967 path.join(root, "bun.lock"),
968 "outside-first",
969 );
970 yield* fs.writeFileString(
971 path.join(context, ".gitignore"),
972 "payload\n",
973 );
974 yield* fs.writeFileString(
975 path.join(context, ".dockerignore"),
976 "ignored\n",
977 );
978 yield* fs.writeFileString(
979 path.join(context, "ignored"),
980 "ignored-first",
981 );
982 // Fresh payload bytes force a cold source tag even after a prior run.
983 yield* fs.writeFileString(path.join(context, "payload"), root);
984 yield* fs.writeFileString(
985 path.join(context, "Dockerfile"),
986 'FROM alpine:3.19\nCOPY payload /payload\nCMD ["sleep", "3600"]\n',
987 );
988 const deploy = (id: string, repository = repositoryName) =>
989 stack.deploy(
990 Effect.gen(function* () {
991 return {
992 app: yield* Cloudflare.Container(id, {
993 context,
994 publish: { repository },
995 }).Application,
996 };
997 }),
998 );
999
1000 const first = yield* deploy("First");
1001 const history = yield* buildHistory;
1002 expect(
1003 history.filter((build) => build.status === "Completed"),
1004 ).toHaveLength(1);
1005 expect(first.app.configuration.image).toContain(
1006 `/${repositoryName}@sha256:`,
1007 );
1008 const credentials =
1009 yield* Containers.createContainerRegistryCredentials({
1010 accountId: first.app.accountId,
1011 registryId: "registry.cloudflare.com",
1012 permissions: ["pull"],
1013 expirationMinutes: 15,
1014 });
1015 const username = credentials.username ?? credentials.user;
1016 assert(username);
1017 const http = yield* HttpClient.HttpClient;
1018 // The registry keeps serving an overwritten tag's previous target
1019 // for a while (observed ~25s), so wait for the new one.
1020 const cached = yield* http
1021 .execute(
1022 HttpClientRequest.head(
1023 `https://registry.cloudflare.com/v2/${first.app.accountId}/${repositoryName}/manifests/buildcache`,
1024 ).pipe(
1025 HttpClientRequest.basicAuth(username, credentials.password),
1026 HttpClientRequest.setHeader(
1027 "Accept",
1028 "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
1029 ),
1030 ),
1031 )
1032 .pipe(
1033 Effect.repeat({
1034 schedule: Schedule.spaced("4 seconds"),
1035 until: (response) =>
1036 response.headers["docker-content-digest"] ===
1037 first.app.hash?.digest,
1038 times: 10,
1039 }),
1040 );
1041 expect(cached.status).toBe(200);
1042 expect(cached.headers["docker-content-digest"]).toBe(
1043 first.app.hash?.digest,
1044 );
1045
1046 yield* stack.destroy();
1047 yield* fs.writeFileString(
1048 path.join(root, "bun.lock"),
1049 "outside-second",
1050 );
1051 yield* fs.writeFileString(
1052 path.join(context, "ignored"),
1053 "ignored-second",
1054 );
1055 const second = yield* deploy("Second");
1056 expect(yield* buildHistory).toEqual(history);
1057 expect(second.app.applicationId).not.toBe(first.app.applicationId);
1058 expect(second.app.configuration.image).toBe(
1059 first.app.configuration.image,
1060 );
1061
1062 // Git ignores this file, but Docker copies it into the image.
1063 yield* fs.writeFileString(
1064 path.join(context, "payload"),
1065 `${root}-changed`,
1066 );
1067 const changed = yield* deploy("Second");
1068 expect(changed.app.hash?.digest).not.toBe(second.app.hash?.digest);
1069 expect(
1070 (yield* buildHistory).filter(
1071 (build) => build.status === "Completed",
1072 ),
1073 ).toHaveLength(2);
1074
1075 const moved = yield* deploy("Second", `${repositoryName}-other`);
1076 expect(moved.app.configuration.image).toContain(
1077 `/${repositoryName}-other@sha256:`,
1078 );
1079 expect(
1080 (yield* buildHistory).filter(
1081 (build) => build.status === "Completed",
1082 ),
1083 ).toHaveLength(3);
1084 yield* stack.destroy();
1085 const deleted = yield* Containers.getContainerApplication({
1086 accountId: moved.app.accountId,
1087 applicationId: moved.app.applicationId,
1088 }).pipe(
1089 Effect.catchTag("ContainerApplicationNotFound", () =>
1090 Effect.succeed(undefined),
1091 ),
1092 Effect.repeat({
1093 schedule: Schedule.spaced("1 second"),
1094 until: (app) => app === undefined,
1095 times: 8,
1096 }),
1097 );
1098 expect(deleted).toBeUndefined();
1099 }).pipe(withBuilder("alchemy-registry-cache"), logLevel),
1100 { timeout: 180_000, exclusive: true },
1101 );
1102
1103 test.provider(
1104 "reuses shared registry images across stages without a builder and moves repositories with an unchanged digest",
1105 (stack) =>
1106 Effect.gen(function* () {
1107 yield* stack.destroy();
1108 const fs = yield* FileSystem.FileSystem;
1109 const path = yield* Path.Path;
1110 const root = yield* fs.makeTempDirectoryScoped({
1111 prefix: "alchemy-shared-publication-",
1112 });
1113 const context = path.join(root, "first");
1114 const otherContext = path.join(root, "second");
1115 for (const dir of [context, otherContext]) {
1116 yield* fs.makeDirectory(dir);
1117 yield* fs.writeFileString(path.join(dir, "payload.txt"), "first");
1118 yield* fs.writeFileString(
1119 path.join(dir, "Dockerfile"),
1120 'FROM alpine:3.19\nCOPY payload.txt /payload.txt\nCMD ["sleep", "3600"]\n',
1121 );
1122 }
1123 const initial = yield* stack.deploy(sharedApplication(context));
1124 const repositoryName = initial.app.applicationName;
1125 const shared = yield* stack.deploy(
1126 sharedApplication(context, repositoryName),
1127 );
1128 expect(shared.app.applicationId).toBe(initial.app.applicationId);
1129 const history = yield* buildHistory;
1130 expect(
1131 history.filter((build) => build.status === "Completed"),
1132 ).toHaveLength(2);
1133
1134 const secondStage = {
1135 stage: `${stack.stage}-shared`,
1136 stack: Stack(
1137 stack.name,
1138 {
1139 providers: Layer.fresh(Cloudflare.providers()),
1140 state: stack.state,
1141 },
1142 sharedApplication(otherContext, repositoryName),
1143 ),
1144 };
1145 yield* Destroy.destroy(secondStage);
1146 const reused = yield* Effect.gen(function* () {
1147 // A fresh provider and an unusable builder rule out process-local reuse.
1148 const reused = yield* Effect.acquireUseRelease(
1149 Effect.sync(() => {
1150 const previous = process.env.BUILDX_BUILDER;
1151 process.env.BUILDX_BUILDER =
1152 "alchemy-shared-cache-must-not-build";
1153 return previous;
1154 }),
1155 () => Deploy.deploy(secondStage),
1156 (previous) =>
1157 Effect.sync(() => {
1158 if (previous === undefined) delete process.env.BUILDX_BUILDER;
1159 else process.env.BUILDX_BUILDER = previous;
1160 }),
1161 );
1162 expect(reused.app.applicationId).not.toBe(shared.app.applicationId);
1163 expect(reused.app.hash?.image).toBe(shared.app.hash?.image);
1164 expect(reused.app.configuration.image).toBe(
1165 shared.app.configuration.image,
1166 );
1167 expect(yield* buildHistory).toEqual(history);
1168 expect(
1169 (yield* live(reused.app.accountId, reused.app.applicationId))
1170 .image,
1171 ).toBe(shared.app.configuration.image);
1172
1173 const moved = yield* stack.deploy(
1174 sharedApplication(context, `${repositoryName}-moved`),
1175 );
1176 expect(moved.app.applicationId).toBe(shared.app.applicationId);
1177 expect(moved.app.hash?.digest).toBe(shared.app.hash?.digest);
1178 expect(moved.app.configuration.image).toContain(
1179 `/${repositoryName}-moved@sha256:`,
1180 );
1181 expect(
1182 (yield* waitForImage(
1183 moved.app.accountId,
1184 moved.app.applicationId,
1185 moved.app.configuration.image!,
1186 )).image,
1187 ).toBe(moved.app.configuration.image);
1188 expect(
1189 (yield* live(reused.app.accountId, reused.app.applicationId))
1190 .image,
1191 ).toBe(shared.app.configuration.image);
1192 return reused;
1193 }).pipe(
1194 Effect.ensuring(Destroy.destroy(secondStage).pipe(Effect.orDie)),
1195 );
1196 yield* stack.destroy();
1197 for (const app of [shared.app, reused.app]) {
1198 const deleted = yield* Containers.getContainerApplication({
1199 accountId: app.accountId,
1200 applicationId: app.applicationId,
1201 }).pipe(
1202 Effect.catchTag("ContainerApplicationNotFound", () =>
1203 Effect.succeed(undefined),
1204 ),
1205 Effect.repeat({
1206 schedule: Schedule.spaced("1 second"),
1207 until: (app) => app === undefined,
1208 times: 8,
1209 }),
1210 );
1211 expect(deleted).toBeUndefined();
1212 }
1213 }).pipe(
1214 // Timestamped provenance would give identical image builds different digests.
1215 withBuilder("alchemy-shared-registry-cache", { attestations: false }),
1216 logLevel,
1217 ),
1218 { timeout: 120_000, exclusive: true },
1219 );
1220
1221 for (const fixture of [
1222 {
1223 name: "Dockerfile",
1224 program: Effect.gen(function* () {
1225 const worker = yield* ExternalContainerWorker;
1226 const app: Cloudflare.ContainerApplication =
1227 yield* ExternalContainer.Application;
1228 return { app, url: worker.url.as<string>() };
1229 }),
1230 route: "/hello",
1231 response: "hello from external container",
1232 },
1233 {
1234 name: "generated",
1235 program: Effect.gen(function* () {
1236 const worker = yield* EffectfulContainerWorker;
1237 const app: Cloudflare.ContainerApplication =
1238 yield* MyContainer.Application;
1239 return { app, url: worker.url.as<string>() };
1240 }).pipe(Effect.provide(MyContainerLive)),
1241 route: "/ping",
1242 response: "pong",
1243 },
1244 ]) {
1245 test.provider(
1246 `exports ${fixture.name} builds from a non-loading builder to the registry`,
1247 (stack) =>
1248 Effect.gen(function* () {
1249 yield* stack.destroy();
1250 const docker = yield* Docker;
1251 const fs = yield* FileSystem.FileSystem;
1252 const path = yield* Path.Path;
1253 const configDir = yield* Config.String("DOCKER_CONFIG").pipe(
1254 Config.orElse(() =>
1255 Config.String("HOME").pipe(
1256 Config.map((home) => path.join(home, ".docker")),
1257 ),
1258 ),
1259 );
1260 const configFile = path.join(configDir, "config.json");
1261 const readConfig = fs
1262 .readFileString(configFile)
1263 .pipe(
1264 Effect.catchReason("PlatformError", "NotFound", () =>
1265 Effect.succeed(undefined),
1266 ),
1267 );
1268 const configBefore = yield* readConfig;
1269 const builder = `alchemy-registry-export-${fixture.name.toLowerCase()}`;
1270 const selected = yield* docker.run(["buildx", "inspect"]);
1271 expect(selected.stdout).toMatch(/Driver:\s+docker-container/);
1272 expect(selected.stdout).toContain(builder);
1273
1274 const deployed = yield* stack.deploy(fixture.program);
1275 const tag = taggedRefOf(deployed.app);
1276 const local = yield* docker.image
1277 .inspect(tag)
1278 .pipe(
1279 Effect.catchReason("PlatformError", "NotFound", () =>
1280 Effect.succeed(undefined),
1281 ),
1282 );
1283 // Buildx 0.26+ exports from BuildKit straight to the registry, so
1284 // the image never enters the local store. Older plugins `--load`
1285 // it and `docker push` from there.
1286 const exported = yield* supportsRegistryExport;
1287 if (exported) {
1288 expect(local).toBeUndefined();
1289 } else {
1290 expect(local).toBeDefined();
1291 }
1292 expect(deployed.app.hash?.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
1293
1294 const { accountId, applicationId } = deployed.app;
1295 const credentials =
1296 yield* Containers.createContainerRegistryCredentials({
1297 accountId,
1298 registryId: "registry.cloudflare.com",
1299 permissions: ["pull"],
1300 expirationMinutes: 15,
1301 });
1302 const username = credentials.username ?? credentials.user;
1303 assert(username);
1304 const client = yield* HttpClient.HttpClient;
1305 const repository = tag.slice(
1306 "registry.cloudflare.com/".length,
1307 tag.lastIndexOf(":"),
1308 );
1309 const manifest = yield* client.execute(
1310 HttpClientRequest.get(
1311 `https://registry.cloudflare.com/v2/${repository}/manifests/${deployed.app.hash!.digest}`,
1312 ).pipe(
1313 HttpClientRequest.basicAuth(username, credentials.password),
1314 HttpClientRequest.setHeader(
1315 "Accept",
1316 "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
1317 ),
1318 ),
1319 );
1320 expect(manifest.status).toBe(200);
1321 expect(manifest.headers["docker-content-digest"]).toBe(
1322 deployed.app.hash!.digest,
1323 );
1324 const cache = yield* client.execute(
1325 HttpClientRequest.head(
1326 `https://registry.cloudflare.com/v2/${repository}/manifests/buildcache`,
1327 ).pipe(
1328 HttpClientRequest.basicAuth(username, credentials.password),
1329 HttpClientRequest.setHeader(
1330 "Accept",
1331 "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
1332 ),
1333 ),
1334 );
1335 expect(cache.status).toBe(200);
1336 expect(cache.headers["docker-content-digest"]).toBe(
1337 deployed.app.hash!.digest,
1338 );
1339 if (exported) {
1340 // BuildKit's registry exporter publishes an OCI index (with
1341 // attestations) carrying the requested platform.
1342 const index = yield* manifest.json.pipe(
1343 Effect.flatMap(
1344 Schema.decodeUnknownEffect(
1345 Schema.Struct({
1346 mediaType: Schema.String,
1347 manifests: Schema.Array(
1348 Schema.Struct({
1349 platform: Schema.Struct({
1350 os: Schema.String,
1351 architecture: Schema.String,
1352 }),
1353 }),
1354 ),
1355 }),
1356 ),
1357 ),
1358 );
1359 expect(index.mediaType).toBe(
1360 "application/vnd.oci.image.index.v1+json",
1361 );
1362 expect(
1363 index.manifests.some(
1364 ({ platform }) =>
1365 platform.os === "linux" &&
1366 platform.architecture === "amd64",
1367 ),
1368 ).toBe(true);
1369 } else {
1370 // `docker push --platform` ships the single platform variant.
1371 const single = yield* manifest.json.pipe(
1372 Effect.flatMap(
1373 Schema.decodeUnknownEffect(
1374 Schema.Struct({ mediaType: Schema.String }),
1375 ),
1376 ),
1377 );
1378 expect([
1379 "application/vnd.oci.image.manifest.v1+json",
1380 "application/vnd.docker.distribution.manifest.v2+json",
1381 ]).toContain(single.mediaType);
1382 }
1383 const observed = yield* Containers.getContainerApplication({
1384 accountId,
1385 applicationId,
1386 });
1387 expect(observed.configuration.image).toBe(
1388 deployed.app.configuration.image,
1389 );
1390
1391 const response = yield* Effect.gen(function* () {
1392 const response = yield* client.get(
1393 `${deployed.url}${fixture.route}`,
1394 );
1395 return { status: response.status, body: yield* response.text };
1396 }).pipe(
1397 Effect.timeout("45 seconds"),
1398 Effect.repeat({
1399 schedule: Schedule.spaced("3 seconds"),
1400 until: (response) =>
1401 response.status === 200 &&
1402 response.body.includes(fixture.response),
1403 times: 8,
1404 }),
1405 );
1406 expect(response.status).toBe(200);
1407 expect(response.body).toContain(fixture.response);
1408 expect(yield* readConfig).toBe(configBefore);
1409 const unchanged = yield* stack.deploy(fixture.program);
1410 expect(unchanged.app.applicationId).toBe(applicationId);
1411 expect(unchanged.app.hash).toEqual(deployed.app.hash);
1412
1413 yield* stack.destroy();
1414 const deleted = yield* Containers.getContainerApplication({
1415 accountId,
1416 applicationId,
1417 }).pipe(
1418 Effect.catchTag("ContainerApplicationNotFound", () =>
1419 Effect.succeed(undefined),
1420 ),
1421 Effect.repeat({
1422 schedule: Schedule.spaced("1 second"),
1423 until: (app) => app === undefined,
1424 times: 8,
1425 }),
1426 );
1427 expect(deleted).toBeUndefined();
1428 }).pipe(
1429 withBuilder(
1430 `alchemy-registry-export-${fixture.name.toLowerCase()}`,
1431 ),
1432 logLevel,
1433 ),
1434 {
1435 tags: ["provider:cloudflare:r2", "provider:cloudflare:worker"],
1436 timeout: 120_000,
1437 exclusive: true,
1438 },
1439 );
1440 }
1441
1442 for (const maxInstances of [2, 4]) {
1443 for (const field of [
1444 "applicationId",
1445 "applicationName",
1446 "accountId",
1447 ] as const) {
1448 test.provider(
1449 `plans replacement for cached ${field} mismatches with ${maxInstances === 2 ? "unchanged" : "changed"} props`,
1450 (stack) =>
1451 Effect.gen(function* () {
1452 yield* stack.destroy();
1453
1454 const first = yield* stack.deploy(applications());
1455 const { accountId } = first.owned;
1456 const observed = yield* Effect.forEach(
1457 [first.owned, first.other],
1458 (app) =>
1459 Containers.getContainerApplication({
1460 accountId,
1461 applicationId: app.applicationId,
1462 }),
1463 );
1464 const state = yield* yield* State;
1465 const key = {
1466 stack: stack.name,
1467 stage: stack.stage,
1468 fqn: "CachedIdentity",
1469 };
1470 const row = yield* state.get(key);
1471 assert(row?.status === "created" || row?.status === "updated");
1472
1473 yield* Effect.gen(function* () {
1474 const inconsistent = {
1475 ...row,
1476 attr: {
1477 ...row.attr,
1478 [field]:
1479 field === "accountId"
1480 ? "00000000000000000000000000000000"
1481 : first.other[field],
1482 },
1483 };
1484 yield* state.set({ ...key, value: inconsistent });
1485
1486 const plan = yield* stack.plan(applications(maxInstances));
1487 expect(plan.resources.CachedIdentity.action).toBe("replace");
1488 expect(plan.resources.CachedIdentity.state).toEqual(
1489 inconsistent,
1490 );
1491 expect(plan.resources.OtherIdentity.action).toBe("noop");
1492 expect(yield* state.get(key)).toEqual(inconsistent);
1493
1494 for (const before of observed) {
1495 const after = yield* Containers.getContainerApplication({
1496 accountId,
1497 applicationId: before.id,
1498 });
1499 expect(after).toMatchObject({
1500 id: before.id,
1501 name: before.name,
1502 version: before.version,
1503 maxInstances: before.maxInstances,
1504 configuration: before.configuration,
1505 });
1506 }
1507 }).pipe(
1508 // Restore the real identity before teardown, even on failure.
1509 Effect.ensuring(
1510 state.set({ ...key, value: row }).pipe(Effect.orDie),
1511 ),
1512 );
1513
1514 const checked = yield* Drift.detect(stack);
1515 expect(checked.resources.CachedIdentity.attr).toMatchObject({
1516 applicationId: first.owned.applicationId,
1517 applicationName: first.owned.applicationName,
1518 accountId,
1519 });
1520 const recovered = yield* stack.deploy(applications());
1521 expect(recovered.owned.applicationId).toBe(
1522 first.owned.applicationId,
1523 );
1524 expect(recovered.other.applicationId).toBe(
1525 first.other.applicationId,
1526 );
1527
1528 yield* stack.destroy();
1529 for (const app of [first.owned, first.other]) {
1530 const deleted = yield* Containers.getContainerApplication({
1531 accountId,
1532 applicationId: app.applicationId,
1533 }).pipe(
1534 Effect.catchTag("ContainerApplicationNotFound", () =>
1535 Effect.succeed(undefined),
1536 ),
1537 Effect.repeat({
1538 schedule: Schedule.spaced("1 second"),
1539 until: (app) => app === undefined,
1540 times: 8,
1541 }),
1542 );
1543 expect(deleted).toBeUndefined();
1544 }
1545 }).pipe(logLevel),
1546 { timeout: 120_000 },
1547 );
1548 }
1549 }
1550
1551 test.provider(
1552 "replaces the fixture when its configured name changes",
1553 (stack) =>
1554 Effect.gen(function* () {
1555 yield* stack.destroy();
1556 const first = yield* stack.deploy(applications());
1557 const name = `renamed-${first.owned.applicationName.slice(-24)}`;
1558 const renamed = applications(2, name);
1559
1560 const scaling = yield* stack.plan(applications(4));
1561 expect(scaling.resources.CachedIdentity.action).toBe("update");
1562 const plan = yield* stack.plan(renamed);
1563 expect(plan.resources.CachedIdentity.action).toBe("replace");
1564 expect(plan.resources.OtherIdentity.action).toBe("noop");
1565 const before = yield* Containers.getContainerApplication({
1566 accountId: first.owned.accountId,
1567 applicationId: first.owned.applicationId,
1568 });
1569 expect(before.name).toBe(first.owned.applicationName);
1570 expect(before.maxInstances).toBe(2);
1571
1572 const second = yield* stack.deploy(renamed);
1573 expect(second.owned.applicationName).toBe(name);
1574 expect(second.owned.applicationId).not.toBe(
1575 first.owned.applicationId,
1576 );
1577 expect(second.other.applicationId).toBe(first.other.applicationId);
1578 const observed = yield* Containers.getContainerApplication({
1579 accountId: second.owned.accountId,
1580 applicationId: second.owned.applicationId,
1581 });
1582 expect(observed.name).toBe(name);
1583
1584 yield* stack.destroy();
1585 for (const app of [first.owned, second.owned, second.other]) {
1586 const deleted = yield* Containers.getContainerApplication({
1587 accountId: app.accountId,
1588 applicationId: app.applicationId,
1589 }).pipe(
1590 Effect.catchTag("ContainerApplicationNotFound", () =>
1591 Effect.succeed(undefined),
1592 ),
1593 Effect.repeat({
1594 schedule: Schedule.spaced("1 second"),
1595 until: (app) => app === undefined,
1596 times: 8,
1597 }),
1598 );
1599 expect(deleted).toBeUndefined();
1600 }
1601 }).pipe(logLevel),
1602 { timeout: 120_000 },
1603 );
1604
1605 test.provider(
1606 "forwards the fixture's memoryMib to Cloudflare",
1607 (stack) =>
1608 Effect.gen(function* () {
1609 yield* stack.destroy();
1610
1611 const app = yield* stack.deploy(RemoteContainer.Application);
1612 expect(app.configuration.memoryMib).toBe(4096);
1613 const live = yield* Containers.getContainerApplication({
1614 accountId: app.accountId,
1615 applicationId: app.applicationId,
1616 });
1617 expect(live.configuration.memoryMib).toBe(4096);
1618 expect(live.configuration.instanceType).not.toBe("lite");
1619
1620 yield* stack.destroy();
1621 const deleted = yield* Containers.getContainerApplication({
1622 accountId: app.accountId,
1623 applicationId: app.applicationId,
1624 }).pipe(
1625 Effect.catchTag("ContainerApplicationNotFound", () =>
1626 Effect.succeed(undefined),
1627 ),
1628 Effect.repeat({
1629 schedule: Schedule.spaced("1 second"),
1630 until: (app) => app === undefined,
1631 times: 8,
1632 }),
1633 );
1634 expect(deleted).toBeUndefined();
1635 }).pipe(logLevel),
1636 { tags: ["provider:cloudflare:r2"], timeout: 120_000 },
1637 );
1638
1639 // Canonical `list()` test (Cloudflare account collection, pattern (b)).
1640 // `listContainerApplications` returns the full application objects in one
1641 // (non-paginated) response, so `list()` maps each into the exact `read`
1642 // Attributes shape. Deploying a real container application requires a Docker
1643 // build + push to the Cloudflare registry (not feasible in this harness), so
1644 // this is a read-only enumeration assertion: the result is a well-typed array
1645 // (possibly empty on an account with no container applications) and every
1646 // element carries the full Attributes shape.
1647 test.provider("list enumerates container applications", (stack) =>
1648 Effect.gen(function* () {
1649 yield* stack.destroy();
1650
1651 const provider = yield* Provider.findProvider(Cloudflare.Container);
1652 const all = yield* provider.list();
1653
1654 expect(Array.isArray(all)).toBe(true);
1655 for (const app of all) {
1656 expect(typeof app.applicationId).toBe("string");
1657 expect(typeof app.applicationName).toBe("string");
1658 expect(typeof app.accountId).toBe("string");
1659 expect(app.configuration).toBeDefined();
1660 }
1661
1662 yield* stack.destroy();
1663 }).pipe(logLevel),
1664 );
1665
1666 // Issue #953 (2): an `image` that already references the target registry
1667 // (e.g. pushed by CI) is deployed as-is — no docker pull/tag/push
1668 // round-trip. The first deploy pushes a public image into the account
1669 // registry the normal way; the second deploy references it directly, both
1670 // by digest and by tag. The old (remote) path would have re-tagged it into
1671 // a repository named after the consumer app, so `configuration.image`
1672 // landing on the source's digest reference proves the as-is path ran.
1673 //
1674 // The tag consumer is the only place `resolveRegistryDigest` (the
1675 // `HEAD /v2/<repo>/manifests/<tag>` probe against Cloudflare's registry)
1676 // runs on the happy path — a digest reference short-circuits it and a
1677 // local push reads the digest from `docker push` output.
1678 test.provider(
1679 "pre-pushed registry image is deployed as-is",
1680 (scratch) =>
1681 Effect.gen(function* () {
1682 yield* scratch.destroy();
1683
1684 const source = yield* scratch.deploy(
1685 Effect.gen(function* () {
1686 return {
1687 app: yield* Cloudflare.Container("PrepushSource", {
1688 image: "mendhak/http-https-echo:latest",
1689 }).Application,
1690 };
1691 }),
1692 );
1693 const pushedRef = source.app.configuration.image!;
1694 expect(pushedRef).toMatch(
1695 /^registry\.cloudflare\.com\/.*@sha256:[a-f0-9]{64}$/,
1696 );
1697 // The mutable tag the provider pushed: `<repo>:<sourceHash>`.
1698 const taggedRef = `${pushedRef.slice(0, pushedRef.indexOf("@"))}:${source.app.hash!.image}`;
1699
1700 const all = yield* scratch.deploy(
1701 Effect.gen(function* () {
1702 return {
1703 app: yield* Cloudflare.Container("PrepushSource", {
1704 image: "mendhak/http-https-echo:latest",
1705 }).Application,
1706 byDigest: yield* Cloudflare.Container("PrepushByDigest", {
1707 image: pushedRef,
1708 }).Application,
1709 byTag: yield* Cloudflare.Container("PrepushByTag", {
1710 image: taggedRef,
1711 }).Application,
1712 };
1713 }),
1714 );
1715 expect(all.byDigest.configuration.image).toBe(pushedRef);
1716 expect(all.byTag.configuration.image).toBe(pushedRef);
1717
1718 yield* scratch.destroy();
1719 }).pipe(logLevel),
1720 { timeout: 600_000 },
1721 );
1722
1723 // #1282: the image tag is `<repo>:<sourceHash>`, so any change to the
1724 // build inputs pushes a new tag — even when the resulting image is
1725 // byte-for-byte identical (a context file the Dockerfile never COPYs, a
1726 // lockfile, or here: the same remote image spelled with an explicit
1727 // registry host). The provider used to feed that new tag straight into
1728 // `updateContainerApplication`, minting a new application version and
1729 // rolling every instance for nothing. It now resolves the pushed manifest
1730 // digest and, when it matches the live image, skips the update entirely.
1731 test.provider(
1732 "re-pushing an identical image does not create a new application version",
1733 (scratch) =>
1734 Effect.gen(function* () {
1735 yield* scratch.destroy();
1736 const { accountId } = yield* yield* CloudflareEnvironment;
1737
1738 const first = yield* deployImage(
1739 scratch,
1740 "mendhak/http-https-echo:41",
1741 );
1742
1743 // Same image, different reference: the source hash changes and the
1744 // image is pulled + pushed again, but the registry digest does not.
1745 const second = yield* deployImage(
1746 scratch,
1747 "docker.io/mendhak/http-https-echo:41",
1748 );
1749 expect(second.app.applicationId).toBe(first.app.applicationId);
1750 expect(second.app.configuration.image).toBe(
1751 first.app.configuration.image,
1752 );
1753 expect(second.app.version).toBe(first.app.version);
1754 expect(yield* live(accountId, first.app.applicationId)).toMatchObject(
1755 {
1756 version: first.app.version,
1757 image: first.app.configuration.image,
1758 },
1759 );
1760 // The live reference is the immutable digest, not the mutable tag.
1761 expect(first.app.configuration.image).toMatch(
1762 /^registry\.cloudflare\.com\/.*@sha256:[a-f0-9]{64}$/,
1763 );
1764
1765 // A genuinely different image still updates and rolls out. The API
1766 // reports the ACTIVE configuration (and version) until the rollout
1767 // completes, so poll until the new digest is live.
1768 const third = yield* deployImage(
1769 scratch,
1770 "mendhak/http-https-echo:40",
1771 );
1772 expect(third.app.applicationId).toBe(first.app.applicationId);
1773 expect(third.app.configuration.image).toMatch(
1774 /^registry\.cloudflare\.com\/.*@sha256:[a-f0-9]{64}$/,
1775 );
1776 expect(third.app.configuration.image).not.toBe(
1777 first.app.configuration.image,
1778 );
1779 const rolledOut = yield* waitForImage(
1780 accountId,
1781 first.app.applicationId,
1782 third.app.configuration.image!,
1783 );
1784 expect(rolledOut.image).toBe(third.app.configuration.image);
1785 expect(rolledOut.version).toBeGreaterThan(first.app.version);
1786
1787 yield* scratch.destroy();
1788 }).pipe(logLevel),
1789 { timeout: 900_000 },
1790 );
1791
1792 test.provider(
1793 "Container.ref reads a container application deployed by another stack",
1794 (scratch) =>
1795 Effect.gen(function* () {
1796 yield* scratch.destroy();
1797 const owner = yield* scratch.deploy(
1798 Effect.gen(function* () {
1799 return {
1800 app: yield* Cloudflare.Container("RefTarget", {
1801 image: "mendhak/http-https-echo:41",
1802 }).Application,
1803 };
1804 }),
1805 );
1806
1807 // A second stack reaches the application only through `ref`.
1808 const consumer = {
1809 stage: scratch.stage,
1810 stack: Stack(
1811 `${scratch.name}-consumer`,
1812 {
1813 providers: Layer.fresh(Cloudflare.providers()),
1814 state: scratch.state,
1815 },
1816 Effect.gen(function* () {
1817 const app = yield* Cloudflare.Container.ref("RefTarget", {
1818 stack: scratch.name,
1819 });
1820 return {
1821 applicationId: app.applicationId,
1822 applicationName: app.applicationName,
1823 image: app.configuration.image,
1824 };
1825 }),
1826 ),
1827 };
1828 const referenced = yield* Deploy.deploy(consumer).pipe(
1829 Effect.ensuring(Destroy.destroy(consumer).pipe(Effect.orDie)),
1830 );
1831 expect(referenced).toEqual({
1832 applicationId: owner.app.applicationId,
1833 applicationName: owner.app.applicationName,
1834 image: owner.app.configuration.image,
1835 });
1836
1837 // Destroying the referencing stack leaves the application running.
1838 const { accountId } = yield* yield* CloudflareEnvironment;
1839 expect((yield* live(accountId, owner.app.applicationId)).image).toBe(
1840 owner.app.configuration.image,
1841 );
1842
1843 yield* scratch.destroy();
1844 }).pipe(logLevel),
1845 { timeout: 900_000 },
1846 );
1847
1848 // State written before #1282 carries only `hash.image`, and its live
1849 // application runs the mutable `<repo>:<sourceHash>` tag. On the first
1850 // reconcile after upgrading, the provider resolves that tag's digest
1851 // through the registry, keeps the live tag reference when the rebuilt
1852 // image matches, and persists the digest + configuration fingerprint
1853 // through one normal update. Every later source-hash drift is then a noop.
1854 test.provider(
1855 "legacy state without a digest migrates through one update",
1856 (scratch) =>
1857 Effect.gen(function* () {
1858 yield* scratch.destroy();
1859 const { accountId } = yield* yield* CloudflareEnvironment;
1860
1861 const first = yield* deployImage(
1862 scratch,
1863 "mendhak/http-https-echo:41",
1864 );
1865 const { applicationId } = first.app;
1866 const taggedRef = taggedRefOf(first.app);
1867
1868 // Put the cloud where a pre-digest engine left it: the mutable tag
1869 // is the ACTIVE image (update + rollout, then wait for it to land).
1870 const legacyConfiguration = {
1871 ...first.app.configuration,
1872 image: taggedRef,
1873 };
1874 yield* Containers.updateContainerApplication({
1875 accountId,
1876 applicationId,
1877 configuration: legacyConfiguration,
1878 });
1879 yield* Containers.createContainerApplicationRollout({
1880 accountId,
1881 applicationId,
1882 description: "legacy tag reference",
1883 strategy: "rolling",
1884 kind: "full_auto",
1885 stepPercentage: 100,
1886 targetConfiguration: legacyConfiguration,
1887 });
1888 const legacy = yield* waitForImage(
1889 accountId,
1890 applicationId,
1891 taggedRef,
1892 );
1893
1894 // And the state row: tag reference, source hash only.
1895 yield* patchRow<typeof first.app>("DigestReuse", (attr) => ({
1896 ...attr,
1897 configuration: { ...attr.configuration, image: taggedRef },
1898 hash: { image: attr.hash!.image },
1899 }));
1900
1901 // Source-hash drift on the migrated row: the rebuilt digest is
1902 // compared against the live tag's digest (resolved via the
1903 // registry), the live reference is kept, and the digest +
1904 // fingerprint are persisted through one update. That update still
1905 // carries one rollout — the live configuration is Cloudflare-
1906 // enriched, so the pre-fingerprint `deepEqual` misses — which is
1907 // exactly what the fingerprint prevents from here on.
1908 const migrated = yield* deployImage(
1909 scratch,
1910 "docker.io/mendhak/http-https-echo:41",
1911 );
1912 expect(migrated.app.applicationId).toBe(applicationId);
1913 expect(migrated.app.configuration.image).toBe(taggedRef);
1914 expect(migrated.app.hash?.digest).toBe(first.app.hash?.digest);
1915 expect(migrated.app.hash?.configuration).toBeDefined();
1916 const after = yield* live(accountId, applicationId).pipe(
1917 Effect.repeat({
1918 schedule: Schedule.spaced("3 seconds"),
1919 until: (app) => app.version > legacy.version,
1920 times: 30,
1921 }),
1922 );
1923 expect(after.image).toBe(taggedRef);
1924
1925 // From here on, drift is free: no update, no rollout.
1926 const settled = yield* deployImage(
1927 scratch,
1928 "mendhak/http-https-echo:41",
1929 );
1930 expect(settled.app.configuration.image).toBe(taggedRef);
1931 expect(settled.app.hash?.image).not.toBe(migrated.app.hash?.image);
1932 yield* Effect.sleep("10 seconds");
1933 expect(yield* live(accountId, applicationId)).toMatchObject({
1934 image: taggedRef,
1935 version: after.version,
1936 });
1937
1938 yield* scratch.destroy();
1939 }).pipe(logLevel),
1940 { timeout: 900_000 },
1941 );
1942
1943 // The Durable Object attachment is immutable, so a reconcile that finds an
1944 // application without it (precreate's stub, or a deploy that died between
1945 // precreate and reconcile) deletes and re-creates the application. When
1946 // the source hash has drifted in between, that path rebuilds the image and
1947 // must apply the same digest comparison as a plain update.
1948 test.provider(
1949 "re-creating an application to attach its Durable Object reuses an unchanged digest",
1950 (scratch) =>
1951 Effect.gen(function* () {
1952 yield* scratch.destroy();
1953 const { accountId } = yield* yield* CloudflareEnvironment;
1954
1955 const program = Effect.gen(function* () {
1956 yield* EnvBucket;
1957 const worker = yield* RemoteContainerWorker;
1958 const app = yield* RemoteContainer.Application;
1959 return { url: worker.url.as<string>(), app };
1960 });
1961
1962 const first = yield* scratch.deploy(program);
1963 const namespaceId = first.app.durableObjects?.namespaceId;
1964 expect(namespaceId).toBeDefined();
1965 const digestRef = first.app.configuration.image!;
1966
1967 // Out-of-band, leave the cloud the way precreate does: same name and
1968 // configuration, no Durable Object attachment.
1969 yield* Containers.deleteContainerApplication({
1970 accountId,
1971 applicationId: first.app.applicationId,
1972 });
1973 const deleted = yield* Effect.gen(function* () {
1974 const app = yield* Containers.getContainerApplication({
1975 accountId,
1976 applicationId: first.app.applicationId,
1977 }).pipe(
1978 Effect.catchTag("ContainerApplicationNotFound", () =>
1979 Effect.succeed(undefined),
1980 ),
1981 );
1982 const apps = yield* Containers.listContainerApplications({
1983 accountId,
1984 });
1985 const absentFromList = apps.every(
1986 (app) => app.id !== first.app.applicationId,
1987 );
1988 if (app !== undefined && absentFromList) {
1989 yield* Effect.logInfo(
1990 "Deleted container is absent from list but still readable by ID",
1991 { applicationId: first.app.applicationId },
1992 );
1993 }
1994 return app === undefined && absentFromList;
1995 }).pipe(
1996 Effect.repeat({
1997 schedule: Schedule.spaced("2 seconds"),
1998 until: (deleted) => deleted,
1999 times: 8,
2000 }),
2001 );
2002 expect(deleted).toBe(true);
2003 const detached = yield* Containers.createContainerApplication({
2004 accountId,
2005 name: first.app.applicationName,
2006 maxInstances: first.app.maxInstances,
2007 instances: first.app.instances,
2008 schedulingPolicy: first.app.schedulingPolicy,
2009 constraints: first.app.constraints,
2010 affinities: first.app.affinities,
2011 configuration: first.app.configuration,
2012 });
2013 expect(detached.id).not.toBe(first.app.applicationId);
2014 expect(detached.durableObjects ?? undefined).toBeUndefined();
2015 const observed = yield* Containers.getContainerApplication({
2016 accountId,
2017 applicationId: detached.id,
2018 }).pipe(
2019 Effect.retry({
2020 while: (error) => error._tag === "ContainerApplicationNotFound",
2021 schedule: Schedule.spaced("1 second"),
2022 times: 8,
2023 }),
2024 );
2025 expect(observed.name).toBe(first.app.applicationName);
2026 expect(observed.durableObjects ?? undefined).toBeUndefined();
2027
2028 // Stale the persisted source hash so the re-create must rebuild.
2029 yield* patchRow<typeof first.app>("RemoteContainer", (attr) => ({
2030 ...attr,
2031 hash: { ...attr.hash!, image: "0000000000000000" },
2032 }));
2033
2034 const second = yield* scratch.deploy(program);
2035 yield* Effect.logInfo("Container reattachment identities", {
2036 original: first.app.applicationId,
2037 detached: detached.id,
2038 recovered: second.app.applicationId,
2039 });
2040 expect(second.app.applicationId).not.toBe(first.app.applicationId);
2041 expect(second.app.applicationId).not.toBe(detached.id);
2042 expect(second.app.durableObjects?.namespaceId).toBe(namespaceId);
2043 expect(second.app.configuration.image).toBe(digestRef);
2044 expect(second.app.hash?.digest).toBe(first.app.hash?.digest);
2045 expect(second.app.hash?.image).not.toBe("0000000000000000");
2046 expect(
2047 yield* live(accountId, second.app.applicationId).pipe(
2048 Effect.retry({
2049 while: (error) => error._tag === "ContainerApplicationNotFound",
2050 schedule: Schedule.spaced("1 second"),
2051 times: 8,
2052 }),
2053 ),
2054 ).toMatchObject({
2055 image: digestRef,
2056 durableObjects: { namespaceId },
2057 });
2058
2059 yield* scratch.destroy();
2060 }).pipe(logLevel),
2061 {
2062 tags: ["provider:cloudflare:r2", "provider:cloudflare:worker"],
2063 timeout: 900_000,
2064 },
2065 );
2066 },
2067);