1import * as Containers from "@distilled.cloud/cloudflare/containers";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import { Unowned } from "../../AdoptPolicy.ts";
12import { AlchemyContext } from "../../AlchemyContext.ts";
13import { getStableContextDir } from "../../Bundle/TempRoot.ts";
14import { hashDockerBuildInputs } from "../../Docker/BuildHash.ts";
15import { deepEqual, isResolved } from "../../Diff.ts";
16import { Docker } from "../../Docker/Docker.ts";
17import { repositoryFromImageRef } from "../../Docker/Registry.ts";
18import * as Provider from "../../Provider.ts";
19import { type ResourceBinding } from "../../Resource.ts";
20import { sha256Object } from "../../Util/sha256.ts";
21import { normalizeNulls } from "../../Util/stable.ts";
22import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
23import { isLiveId } from "../LocalRuntime.ts";
24import { CloudflareLogs, type TelemetryFilter } from "../Logs.ts";
25import type {
26 AnyContainerApplicationProps,
27 ContainerApplication,
28} from "./ContainerApplication.ts";
29import { isInlineDockerfile } from "../../Docker/Dockerfile.ts";
30import {
31 buildFinalDockerfile,
32 bundleContainerProgram,
33 containerEnvPreamble,
34 createContainerApplicationName,
35 makeContainerEnv,
36 materializeInlineDockerfileContext,
37 validateContainerImageProps,
38} from "./ContainerBundle.ts";
39import { ContainerPlatform } from "./ContainerPlatform.ts";
40import { retryContainerPublication } from "./ContainerPublication.ts";
41
42/**
43 * The image source resolved from a {@link ContainerApplicationProps}. Selects
44 * one of three strategies used by `buildAndPushImage`:
45 *
46 * - `effectful` — bundle an Effect-native `main` and build a generated image.
47 * - `external` — build a user-supplied Dockerfile against a context directory.
48 * - `remote` — pull a pre-built remote image and re-push it to Cloudflare.
49 * - `prepushed` — the image already lives in the target registry; use the
50 * reference as-is with no docker pull/build/push at all.
51 */
52type ImageBuild =
53 | {
54 readonly kind: "effectful";
55 readonly files: ReadonlyArray<{ path: string; content: Uint8Array }>;
56 }
57 | {
58 readonly kind: "external";
59 readonly context: string;
60 readonly dockerfile: string;
61 }
62 | {
63 readonly kind: "remote";
64 readonly image: string;
65 }
66 | {
67 readonly kind: "prepushed";
68 readonly image: string;
69 };
70
71/**
72 * Whether an image reference already points at the target registry host —
73 * e.g. `registry.cloudflare.com/<accountId>/repo@sha256:...` pushed by CI.
74 * Such references are deployed as-is; there is nothing to pull or push.
75 */
76const isTargetRegistryRef = (image: string, registryId: string) =>
77 image.startsWith(`${registryId}/`);
78
79/**
80 * Insert the account namespace into a Cloudflare-registry reference that
81 * omits it (`registry.cloudflare.com/app:tag` →
82 * `registry.cloudflare.com/<accountId>/app:tag`), mirroring wrangler's
83 * `resolveImageName`. Custom registries are left untouched — the account
84 * namespace rule is specific to Cloudflare's managed registry.
85 */
86const normalizePrepushedRef = (
87 image: string,
88 registryId: string,
89 accountId: string,
90) => {
91 if (registryId !== "registry.cloudflare.com") return image;
92 const rest = image.slice(registryId.length + 1);
93 const first = rest.split("/")[0];
94 return first !== undefined && /^[a-f0-9]{32}$/.test(first)
95 ? image
96 : `${registryId}/${accountId}/${rest}`;
97};
98
99const RegistryDigest = Schema.String.pipe(
100 Schema.check(Schema.isPattern(/^[a-z0-9]+:[a-f0-9]{64}$/i)),
101);
102const isRegistryDigest = Schema.is(RegistryDigest);
103
104class ContainerRegistryError extends Schema.TaggedError<ContainerRegistryError>()(
105 "ContainerRegistryError",
106 {
107 reason: Schema.Literals([
108 "CredentialsMissingUsername",
109 "ImageOutsideRegistry",
110 "InvalidImageReference",
111 "ImageNotFound",
112 "ManifestRequestFailed",
113 "InvalidManifestDigest",
114 ]),
115 message: Schema.String,
116 imageRef: Schema.optional(Schema.String),
117 cause: Schema.optional(Schema.Defect({ includeStack: true })),
118 },
119) {}
120
121const digestFromImageRef = (imageRef: string) => {
122 const separator = imageRef.lastIndexOf("@");
123 if (separator === -1) return undefined;
124 const digest = imageRef.slice(separator + 1);
125 return isRegistryDigest(digest) ? digest : undefined;
126};
127
128export const LiveContainerProvider = () =>
129 Provider.effect(
130 ContainerPlatform,
131 Effect.gen(function* () {
132 const { dotAlchemy } = yield* AlchemyContext;
133 const fs = yield* FileSystem.FileSystem;
134 const path = yield* Path.Path;
135 const docker = yield* Docker;
136 const http = yield* HttpClient.HttpClient;
137
138 const telemetry = yield* CloudflareLogs;
139
140 const createApplicationName = createContainerApplicationName;
141
142 const findApplicationByName = Effect.fn(function* (name: string) {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144
145 return yield* Containers.listContainerApplications({ accountId }).pipe(
146 Effect.map((apps) => apps.find((app) => app.name === name)),
147 );
148 });
149
150 const findApplicationByNamespace = Effect.fn(function* (
151 namespaceId: string,
152 ) {
153 const { accountId } = yield* yield* CloudflareEnvironment;
154
155 return yield* Containers.listContainerApplications({ accountId }).pipe(
156 Effect.map((apps) =>
157 apps.find((app) => app.durableObjects?.namespaceId === namespaceId),
158 ),
159 );
160 });
161
162 // After deleting an application by id, Cloudflare's account-scoped
163 // `list` endpoint stays eventually-consistent for a short window and can
164 // keep returning the now-deleted row. A subsequent recreate that
165 // re-discovers the application by name (`createApplication`) would then
166 // "adopt" that stale row and try to UPDATE it — which fails permanently
167 // with `ContainerApplicationNotFound` (the app is really gone) and
168 // exhausts the readiness retry. Block until the deleted id no longer
169 // appears under that name (or a different id has taken the name, i.e. a
170 // concurrent recreate) before proceeding. Bounded by the readiness
171 // schedule; if it never clears we fall through and let create handle it.
172 const waitForApplicationDeleted = (name: string, deletedId: string) =>
173 findApplicationByName(name).pipe(
174 Effect.repeat({
175 schedule: containerApplicationReadinessSchedule,
176 until: (app) => app?.id !== deletedId,
177 }),
178 Effect.asVoid,
179 );
180
181 const desiredConfiguration = (
182 props: AnyContainerApplicationProps,
183 env: Record<string, string | Redacted.Redacted<string>>,
184 imageRef: string,
185 ) =>
186 normalizeNulls({
187 image: imageRef,
188 // Default to wrangler's instance type ("lite") so containers schedule
189 // the same way out of the box. `instance_type` is mutually exclusive
190 // with explicit vcpu/memory/disk, so only default it when none are
191 // set. ("dev" is wrangler's deprecated alias for "lite".)
192 instanceType:
193 props.instanceType ??
194 (props.vcpu === undefined &&
195 props.memory === undefined &&
196 props.memoryMib === undefined &&
197 props.disk === undefined
198 ? "lite"
199 : undefined),
200 observability: props.observability,
201 sshPublicKeyIds: props.sshPublicKeyIds,
202 secrets: props.secrets,
203 vcpu: props.vcpu,
204 memory: props.memory,
205 memoryMib: props.memoryMib,
206 disk: props.disk,
207 environmentVariables: Object.entries(env).map(([name, value]) => ({
208 name,
209 value: Redacted.isRedacted(value) ? Redacted.value(value) : value,
210 })),
211 labels: props.labels,
212 network: props.network,
213 command: props.command,
214 entrypoint: props.entrypoint,
215 dns: props.dns,
216 ports: props.ports,
217 checks: props.checks,
218 }) as ContainerApplication.Configuration;
219
220 // Scaling/placement defaults mirror wrangler's container defaults
221 // (`wrangler-dist/cli.js`) so an Alchemy container behaves like a
222 // `wrangler deploy`d one without extra config:
223 // - max_instances: 20 (`container.max_instances ?? 20`)
224 // - instances: 0 (wrangler forces 0 whenever
225 // max_instances is set, which we always
226 // do — pure scale-from-zero)
227 // - scheduling_policy: "default"
228 // (wrangler also defaults `constraints.tiers` to `[1, 2]`, but the
229 // distilled SDK models constraints as singular `tier`, not the `tiers`
230 // array, so we leave constraints untouched — it's a minor placement hint
231 // next to the scaling defaults.)
232 // A maxInstances default of 1 (the previous value) silently serialised
233 // every Durable Object instance through a single container slot, which is
234 // the dominant cause of "containers are slow under load".
235 const scalingDefaults = (props: AnyContainerApplicationProps) => ({
236 instances: props.instances ?? 0,
237 maxInstances: props.maxInstances ?? 20,
238 schedulingPolicy: props.schedulingPolicy ?? "default",
239 constraints: props.constraints ?? {},
240 });
241
242 const applicationConfigurationHash = Effect.fn(
243 "applicationConfigurationHash",
244 )(function* (
245 scaling: ReturnType<typeof scalingDefaults>,
246 affinities: ContainerApplication.Affinities | undefined,
247 configuration: ContainerApplication.Configuration,
248 ) {
249 return yield* sha256Object({
250 scaling,
251 affinities: normalizeNulls(affinities),
252 configuration,
253 });
254 });
255
256 const registryCredentials = Effect.fn("registryCredentials")(function* (
257 props: AnyContainerApplicationProps,
258 permissions: Array<"pull" | "push">,
259 ) {
260 const { accountId } = yield* yield* CloudflareEnvironment;
261 const registryId = props.registryId ?? "registry.cloudflare.com";
262 const credentials =
263 yield* Containers.createContainerRegistryCredentials({
264 accountId,
265 registryId,
266 permissions,
267 expirationMinutes: 60,
268 });
269 const username = credentials.username ?? credentials.user;
270 if (!username) {
271 return yield* new ContainerRegistryError({
272 reason: "CredentialsMissingUsername",
273 message: `Cloudflare registry ${registryId} did not return a username`,
274 });
275 }
276 return {
277 server: registryId,
278 username,
279 password: credentials.password,
280 };
281 });
282
283 const resolveRegistryDigest = Effect.fn("resolveRegistryDigest")(
284 function* (
285 imageRef: string,
286 credentials: {
287 server: string;
288 username: string;
289 password: string | Redacted.Redacted<string>;
290 },
291 ) {
292 const embeddedDigest = digestFromImageRef(imageRef);
293 if (embeddedDigest !== undefined) return embeddedDigest;
294
295 const registryHost = credentials.server
296 .replace(/^https?:\/\//, "")
297 .replace(/\/$/, "");
298 if (!imageRef.startsWith(`${registryHost}/`)) {
299 return yield* new ContainerRegistryError({
300 reason: "ImageOutsideRegistry",
301 message: `Cannot resolve an image outside registry ${registryHost}`,
302 imageRef,
303 });
304 }
305 const repositoryAndTag = imageRef.slice(registryHost.length + 1);
306 const tagSeparator = repositoryAndTag.lastIndexOf(":");
307 if (tagSeparator <= repositoryAndTag.lastIndexOf("/")) {
308 return yield* new ContainerRegistryError({
309 reason: "InvalidImageReference",
310 message: "Container image reference has no tag or digest",
311 imageRef,
312 });
313 }
314 const repository = repositoryAndTag.slice(0, tagSeparator);
315 const tag = repositoryAndTag.slice(tagSeparator + 1);
316 const manifestUrl = `https://${registryHost}/v2/${repository
317 .split("/")
318 .map(encodeURIComponent)
319 .join("/")}/manifests/${encodeURIComponent(tag)}`;
320 const request = HttpClientRequest.head(manifestUrl).pipe(
321 HttpClientRequest.basicAuth(
322 credentials.username,
323 credentials.password,
324 ),
325 HttpClientRequest.setHeader(
326 "Accept",
327 "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json",
328 ),
329 );
330 const response = yield* http.execute(request).pipe(
331 Effect.mapError(
332 () =>
333 new ContainerRegistryError({
334 reason: "ManifestRequestFailed",
335 message: "Failed to resolve the container registry digest",
336 imageRef,
337 }),
338 ),
339 );
340 if (response.status === 404) {
341 return yield* new ContainerRegistryError({
342 reason: "ImageNotFound",
343 message: "Container image is not published",
344 imageRef,
345 });
346 }
347 if (response.status < 200 || response.status >= 300) {
348 return yield* new ContainerRegistryError({
349 reason: "ManifestRequestFailed",
350 message: `Container registry returned HTTP ${response.status}`,
351 imageRef,
352 });
353 }
354 return yield* Schema.decodeUnknownEffect(RegistryDigest)(
355 response.headers["docker-content-digest"],
356 ).pipe(
357 Effect.mapError(
358 (cause) =>
359 new ContainerRegistryError({
360 reason: "InvalidManifestDigest",
361 message: "Registry response did not include a valid digest",
362 imageRef,
363 cause,
364 }),
365 ),
366 );
367 },
368 );
369
370 const resolvePublishedImageRef = Effect.fn("resolvePublishedImageRef")(
371 function* (props: AnyContainerApplicationProps, imageRef: string) {
372 let digest = digestFromImageRef(imageRef);
373 if (digest === undefined) {
374 const credentials = yield* registryCredentials(props, ["pull"]);
375 digest = yield* resolveRegistryDigest(imageRef, credentials);
376 }
377 return {
378 imageRef: `${repositoryFromImageRef(imageRef)}@${digest}`,
379 digest,
380 };
381 },
382 );
383
384 const computeImage = Effect.fn(function* (
385 id: string,
386 props: AnyContainerApplicationProps,
387 env: Record<string, string | Redacted.Redacted<string>>,
388 ) {
389 const { accountId } = yield* yield* CloudflareEnvironment;
390 const name = yield* createApplicationName(id, props.name);
391 const registryId = props.registryId ?? "registry.cloudflare.com";
392 const repositoryName = (
393 props.publish?.repository ?? name
394 ).toLowerCase();
395 const makeRef = (imageHash: string) =>
396 `${registryId}/${accountId}/${repositoryName}:${imageHash}`;
397
398 yield* validateContainerImageProps(props);
399
400 // Preserve serialized imageName keys so existing publication hashes remain valid.
401 // Variant 1 — Effect-native program. Bundle `main` and build a
402 // generated Dockerfile around it; the environment preamble comes
403 // from `image` / inline `dockerfile` (default: the runtime base).
404 if (props.main) {
405 const runtime = props.runtime ?? "bun";
406 const { files, hash: bundleHash } = yield* bundleContainerProgram({
407 id,
408 main: props.main,
409 runtime,
410 handler: props.handler,
411 isExternal: props.isExternal,
412 external: props.external,
413 build: props.build,
414 });
415 const finalDockerfile = buildFinalDockerfile(
416 yield* containerEnvPreamble(props),
417 runtime,
418 props.external,
419 props.autoInstallExternals,
420 );
421 const imageHash = (yield* sha256Object({
422 bundleHash,
423 dockerfile: finalDockerfile,
424 imageName: props.publish?.repository?.toLowerCase(),
425 })).slice(0, 16);
426 // The dev image is the deterministic build-context directory that
427 // `buildAndPushImage` materializes into (and that the local provider
428 // regenerates on the next `alchemy dev`). We persist the path here so
429 // a dev run after a live deploy has an image to `docker build` — the
430 // live deploy pushes to Cloudflare's registry, which the local
431 // `workerd` runtime can't pull. See `prepareContainerBuildContext`.
432 const contextDir = yield* getStableContextDir(
433 process.cwd(),
434 dotAlchemy,
435 `${id}-container`,
436 );
437 return {
438 build: { kind: "effectful" as const, files },
439 imageRef: makeRef(imageHash),
440 imageHash,
441 dev: {
442 context: path.relative(process.cwd(), contextDir),
443 dockerfile: "Dockerfile",
444 env,
445 },
446 };
447 }
448
449 // Variant 2 — pre-built remote image. The image reference is the
450 // identity; we pull and re-push it without building anything.
451 if (props.image) {
452 const imageHash = (yield* sha256Object({
453 image: props.image,
454 imageName: props.publish?.repository?.toLowerCase(),
455 })).slice(0, 16);
456 // Already in the target registry (e.g. pushed by CI as a digest
457 // reference) — deploy the reference as-is and skip the docker
458 // pull/tag/push round-trip entirely.
459 if (isTargetRegistryRef(props.image, registryId)) {
460 const prepushedRef = normalizePrepushedRef(
461 props.image,
462 registryId,
463 accountId,
464 );
465 return {
466 build: { kind: "prepushed" as const, image: prepushedRef },
467 imageRef: prepushedRef,
468 imageHash,
469 // The local runtime pulls this image directly (no build
470 // context); pulling from the Cloudflare registry requires a
471 // local `docker login`.
472 dev: { imageUri: prepushedRef, env },
473 };
474 }
475 return {
476 build: { kind: "remote" as const, image: props.image },
477 imageRef: makeRef(imageHash),
478 imageHash,
479 // The local runtime pulls this image directly (no build context).
480 dev: { imageUri: props.image, env },
481 };
482 }
483
484 // Variant 3a — inline Dockerfile content (`Dockerfile.inline`), no
485 // build context. Materialize the content into a stable generated
486 // context directory and build that.
487 if (
488 props.dockerfile !== undefined &&
489 isInlineDockerfile(props.dockerfile)
490 ) {
491 const content = props.dockerfile.content;
492 if (typeof content !== "string") {
493 return yield* Effect.die(
494 new Error(
495 "Inline `dockerfile` content is an unresolved Output at image-build time — its dependencies have not resolved yet (e.g. during precreate of a circular binding). Break the cycle or inline the resolved value.",
496 ),
497 );
498 }
499 const { context, dockerfile } =
500 yield* materializeInlineDockerfileContext(id, content);
501 const imageHash = (yield* sha256Object({
502 dockerfile: content,
503 imageName: props.publish?.repository?.toLowerCase(),
504 })).slice(0, 16);
505 return {
506 build: { kind: "external" as const, context, dockerfile },
507 imageRef: makeRef(imageHash),
508 imageHash,
509 // The local runtime builds the same materialized context.
510 dev: {
511 context: path.relative(process.cwd(), context),
512 dockerfile: "Dockerfile",
513 env,
514 },
515 };
516 }
517
518 // Variant 3b — user-supplied Dockerfile path + build context
519 // directory.
520 const context = yield* fs.realPath(props.context ?? ".");
521 const dockerfile = props.dockerfile
522 ? yield* fs.realPath(props.dockerfile)
523 : path.join(context, "Dockerfile");
524 // Hash what Docker can consume, including gitignored files and file
525 // modes, but excluding ancestor lockfiles outside the build context.
526 const contextHash = yield* hashDockerBuildInputs(
527 { context, dockerfile, platform: publicationPlatform },
528 "effective",
529 );
530 const imageHash = (yield* sha256Object({
531 contextHash,
532 imageName: props.publish?.repository?.toLowerCase(),
533 })).slice(0, 16);
534 return {
535 build: { kind: "external" as const, context, dockerfile },
536 imageRef: makeRef(imageHash),
537 imageHash,
538 // The local runtime builds the user's Dockerfile against the same
539 // (already real-path'd) context directory.
540 dev: {
541 context: path.relative(process.cwd(), context),
542 dockerfile: path.relative(context, dockerfile),
543 env,
544 },
545 };
546 });
547
548 const publicationPlatform = "linux/amd64";
549 const publishImage = Effect.fn("publishImage")(function* (
550 id: string,
551 props: AnyContainerApplicationProps,
552 build: ImageBuild,
553 imageRef: string,
554 session?: { note: (message: string) => Effect.Effect<void> },
555 ) {
556 const platform = publicationPlatform;
557
558 if (build.kind === "prepushed") {
559 // The reference already lives in the target registry — nothing to
560 // pull, build, or push.
561 yield* Effect.logInfo(
562 `Cloudflare Container image: using pre-pushed ${imageRef}`,
563 );
564 return yield* resolvePublishedImageRef(props, imageRef);
565 }
566
567 const credentials = yield* registryCredentials(props, ["pull", "push"]);
568
569 if (build.kind !== "remote") {
570 const digest = yield* resolveRegistryDigest(
571 imageRef,
572 credentials,
573 ).pipe(
574 Effect.catchTag("ContainerRegistryError", (error) =>
575 error.reason === "ImageNotFound"
576 ? Effect.succeed(undefined)
577 : Effect.fail(error),
578 ),
579 );
580 if (digest !== undefined) {
581 const published = `${repositoryFromImageRef(imageRef)}@${digest}`;
582 yield* Effect.logInfo(
583 `Cloudflare Container image: registry cache hit ${published}`,
584 );
585 if (session) {
586 yield* session.note(
587 `Reusing registry container image ${published}.`,
588 );
589 }
590 return { imageRef: published, digest };
591 }
592 }
593 const cacheRef = `${repositoryFromImageRef(imageRef)}:buildcache`;
594 const cacheOptions = {
595 "cache-from": [`type=registry,ref=${cacheRef}`],
596 "cache-to": ["type=inline"],
597 };
598
599 if (build.kind === "remote") {
600 // Pull the pre-built image and re-tag it to the Cloudflare registry
601 // reference; nothing is built locally.
602 yield* Effect.logInfo(
603 `Cloudflare Container image: pulling ${build.image}`,
604 );
605 if (session) {
606 yield* session.note(`Pulling container image ${build.image}...`);
607 }
608 yield* docker.image.pull(build.image, platform);
609 yield* docker.image.tag(build.image, imageRef);
610 yield* Effect.logInfo(
611 `Cloudflare Container image: pushing ${imageRef}`,
612 );
613 if (session) {
614 yield* session.note(`Pushing container image ${imageRef}...`);
615 }
616 // Push the same platform that was pulled. A containerd image store
617 // may also hold a host-architecture variant under this tag.
618 yield* docker.image
619 .push(imageRef, credentials, platform)
620 .pipe(retryContainerPublication);
621 } else if (build.kind === "external") {
622 // Build the user's Dockerfile directly against their context dir so
623 // relative `COPY`/`ADD` paths resolve as the author intended.
624 yield* Effect.logInfo(
625 `Cloudflare Container image: building ${imageRef}`,
626 );
627 if (session) {
628 yield* session.note(`Building container image ${imageRef}...`);
629 }
630 yield* docker.image
631 .build(
632 {
633 ...cacheOptions,
634 tag: [imageRef, cacheRef],
635 context: build.context,
636 platform,
637 file: build.dockerfile,
638 },
639 undefined,
640 credentials,
641 )
642 .pipe(retryContainerPublication);
643 } else {
644 // Effect-native program: materialize the generated Dockerfile and
645 // bundled chunks into a stable staging dir, then build.
646 yield* Effect.logInfo(
647 `Cloudflare Container image: building ${imageRef}`,
648 );
649 if (session) {
650 yield* session.note(`Building container image ${imageRef}...`);
651 }
652 const runtime = props.runtime ?? "bun";
653 const contextDir = yield* getStableContextDir(
654 process.cwd(),
655 dotAlchemy,
656 `${id}-container`,
657 );
658 const finalDockerfile = buildFinalDockerfile(
659 yield* containerEnvPreamble(props),
660 runtime,
661 props.external,
662 props.autoInstallExternals,
663 );
664 yield* docker.materialize({
665 context: contextDir,
666 dockerfile: finalDockerfile,
667 files: build.files.map((f, i) => ({
668 path: i === 0 ? "index.mjs" : f.path,
669 content: f.content,
670 })),
671 });
672 yield* docker.image
673 .build(
674 {
675 ...cacheOptions,
676 tag: [imageRef, cacheRef],
677 context: contextDir,
678 platform,
679 },
680 undefined,
681 credentials,
682 )
683 .pipe(retryContainerPublication);
684 }
685
686 // Resolve the pushed manifest digest from the registry itself rather
687 // than scraping `docker push` output: one mechanism for every image
688 // source (local build, remote re-push, pre-pushed tag), and the
689 // registry is authoritative for what the application will pull.
690 const digest = yield* resolveRegistryDigest(imageRef, credentials);
691 return {
692 imageRef: `${repositoryFromImageRef(imageRef)}@${digest}`,
693 digest,
694 };
695 });
696
697 // Applications with identical image inputs can share one immutable
698 // registry reference. Keep publication state within this provider instance.
699 const publications = new Map<string, ReturnType<typeof publishImage>>();
700 const buildAndPushImage = Effect.fn("buildAndPushImage")(function* (
701 id: string,
702 props: AnyContainerApplicationProps,
703 build: ImageBuild,
704 imageRef: string,
705 imageHash: string,
706 previousImageRef: string | undefined,
707 session?: { note: (message: string) => Effect.Effect<void> },
708 ) {
709 const { accountId } = yield* yield* CloudflareEnvironment;
710 const key = JSON.stringify([
711 accountId,
712 props.registryId ?? "registry.cloudflare.com",
713 props.publish?.repository,
714 publicationPlatform,
715 build.kind,
716 imageHash,
717 ]);
718 const candidate: ReturnType<typeof publishImage> = yield* Effect.cached(
719 publishImage(id, props, build, imageRef, session).pipe(
720 Effect.onExit((exit) =>
721 Exit.isFailure(exit)
722 ? Effect.sync(() => {
723 // A failed or interrupted publisher must not poison a
724 // later attempt, or remove an entry that replaced it.
725 if (publications.get(key) === candidate) {
726 publications.delete(key);
727 }
728 })
729 : Effect.void,
730 ),
731 ),
732 );
733 const publication = yield* Effect.sync(() => {
734 // Allocate atomically: concurrent callers execute only the winning
735 // candidate, sharing both its in-flight work and successful result.
736 const existing = publications.get(key);
737 if (existing) return existing;
738 publications.set(key, candidate);
739 return candidate;
740 });
741 const published = yield* publication;
742 return {
743 ...published,
744 // Previous image identity belongs to each application, not the
745 // shared image publication. Resolve it separately for every caller.
746 previousDigest:
747 previousImageRef === undefined
748 ? undefined
749 : (yield* resolvePublishedImageRef(props, previousImageRef))
750 .digest,
751 };
752 });
753
754 const maybeCreateRollout = Effect.fn(function* ({
755 applicationId,
756 configuration,
757 rollout,
758 }: {
759 applicationId: string;
760 configuration: ContainerApplication.Configuration;
761 rollout: ContainerApplication.Rollout | undefined;
762 }) {
763 const { accountId } = yield* yield* CloudflareEnvironment;
764
765 const strategy = rollout?.strategy ?? "immediate";
766 const stepPercentage =
767 strategy === "immediate" ? 100 : (rollout?.stepPercentage ?? 25);
768
769 yield* retryForContainerApplicationReadiness(
770 "rollout",
771 applicationId,
772 Containers.createContainerApplicationRollout({
773 accountId,
774 applicationId,
775 description:
776 strategy === "immediate"
777 ? "Immediate update"
778 : "Progressive update",
779 strategy: "rolling",
780 kind: rollout?.kind ?? "full_auto",
781 stepPercentage,
782 targetConfiguration: configuration,
783 }),
784 );
785 });
786
787 const createApplication = Effect.fn(function* ({
788 id,
789 news,
790 bindings,
791 name,
792 configuration,
793 durableObjects,
794 session,
795 }: {
796 id: string;
797 news: AnyContainerApplicationProps;
798 bindings: ResourceBinding<ContainerApplication["Binding"]>[];
799 name: string;
800 configuration: ContainerApplication.Configuration;
801 durableObjects:
802 | {
803 namespaceId: string;
804 }
805 | undefined;
806 session: { note: (message: string) => Effect.Effect<void> };
807 }) {
808 const { accountId } = yield* yield* CloudflareEnvironment;
809
810 const describeError = (error: unknown) => {
811 if (error instanceof Error) {
812 return JSON.stringify(
813 Object.fromEntries(
814 Object.getOwnPropertyNames(error).map((key) => [
815 key,
816 (error as unknown as Record<string, unknown>)[key],
817 ]),
818 ),
819 null,
820 2,
821 );
822 }
823 return String(error);
824 };
825
826 // Engine has cleared us via `read` (foreign-named applications are
827 // surfaced as `Unowned`). Re-fetch the existing application to fold
828 // it into the upsert path.
829 const existingByName = yield* findApplicationByName(name);
830
831 if (existingByName) {
832 yield* Effect.logInfo(
833 `Cloudflare Container create: adopting existing application ${name}`,
834 );
835 return yield* upsertApplication({
836 id,
837 news,
838 bindings,
839 existing: toAttributes(existingByName),
840 durableObjects,
841 session,
842 });
843 }
844
845 yield* Effect.logInfo(
846 `Cloudflare Container create: creating application ${name}`,
847 );
848 yield* session.note(`Creating container application ${name}...`);
849 const adoptExistingByName = Effect.gen(function* () {
850 yield* Effect.logInfo(
851 `Cloudflare Container create: application ${name} already exists, adopting`,
852 );
853 const existing = yield* findApplicationByName(name);
854 if (!existing) {
855 return yield* Effect.fail(
856 new Error(
857 `Container application "${name}" already exists but could not be found for adoption.`,
858 ),
859 );
860 }
861 return yield* upsertApplication({
862 id,
863 news,
864 bindings,
865 existing: toAttributes(existing),
866 durableObjects,
867 session,
868 });
869 });
870
871 const application = yield* Containers.createContainerApplication({
872 accountId,
873 name,
874 ...scalingDefaults(news),
875 affinities: news.affinities,
876 configuration,
877 durableObjects,
878 }).pipe(
879 Effect.catchTag("DurableObjectAlreadyHasApplication", () =>
880 durableObjects
881 ? Effect.gen(function* () {
882 const existing = yield* findApplicationByNamespace(
883 durableObjects.namespaceId,
884 );
885 const recovery = resolveDurableObjectApplicationRecovery({
886 namespaceId: durableObjects.namespaceId,
887 expectedName: name,
888 existingName: existing?.name,
889 });
890 if (!recovery.canAdopt) {
891 return yield* Effect.fail(new Error(recovery.message));
892 }
893 if (!existing) {
894 return yield* Effect.fail(
895 new Error(
896 `Container application for Durable Object namespace "${durableObjects.namespaceId}" already exists but could not be found for adoption.`,
897 ),
898 );
899 }
900 return yield* upsertApplication({
901 id,
902 news,
903 bindings,
904 existing: toAttributes(existing),
905 durableObjects,
906 session,
907 });
908 })
909 : Effect.fail(
910 new Error(
911 "Durable Object namespace already has a container application. Set AdoptPolicy to adopt it.",
912 ),
913 ),
914 ),
915 Effect.catchIf(
916 (e) =>
917 "message" in (e as any) &&
918 String((e as any).message).includes("already exists"),
919 () => adoptExistingByName,
920 ),
921 Effect.tapError((error) =>
922 Effect.logError(
923 `Cloudflare Container create error: ${describeError(error)}`,
924 ),
925 ),
926 );
927
928 return "applicationId" in application
929 ? application
930 : toAttributes(application);
931 });
932
933 const upsertApplication = Effect.fn(function* ({
934 id,
935 news,
936 bindings,
937 existing,
938 durableObjects,
939 session,
940 }: {
941 id: string;
942 news: AnyContainerApplicationProps;
943 bindings: ResourceBinding<ContainerApplication["Binding"]>[];
944 existing: ContainerApplication["Attributes"];
945 // The DO attachment to (re)create with if the "existing" application
946 // turns out to be gone. Threaded through so the update→create fallback
947 // below preserves the binding.
948 durableObjects: { namespaceId: string } | undefined;
949 session: { note: (message: string) => Effect.Effect<void> };
950 }) {
951 const { accountId } = yield* yield* CloudflareEnvironment;
952
953 yield* Effect.logInfo(
954 `Cloudflare Container update: preparing ${existing.applicationName}`,
955 );
956 const env = makeContainerEnv(news, accountId, bindings);
957 const { build, imageRef, imageHash, dev } = yield* computeImage(
958 id,
959 news,
960 env,
961 );
962 let deploymentImageRef = existing.configuration.image;
963 let imageDigest = existing.hash?.digest;
964 if (imageHash !== existing.hash?.image) {
965 const published = yield* buildAndPushImage(
966 id,
967 news,
968 build,
969 imageRef,
970 imageHash,
971 existing.hash?.digest === undefined
972 ? existing.configuration.image
973 : undefined,
974 session,
975 );
976 const existingDigest =
977 existing.hash?.digest ?? published.previousDigest;
978 deploymentImageRef =
979 published.digest === existingDigest &&
980 news.publish?.repository === undefined
981 ? existing.configuration.image
982 : published.imageRef;
983 imageDigest = published.digest;
984 }
985 const configuration = desiredConfiguration(
986 news,
987 env,
988 deploymentImageRef,
989 );
990 const scaling = scalingDefaults(news);
991 const configurationHash = yield* applicationConfigurationHash(
992 scaling,
993 news.affinities,
994 configuration,
995 );
996 if (existing.hash?.configuration === configurationHash) {
997 yield* Effect.logInfo(
998 `Cloudflare Container update: ${existing.applicationName} has no effective changes`,
999 );
1000 yield* session.note(
1001 `Container application ${existing.applicationName} is unchanged.`,
1002 );
1003 return {
1004 ...existing,
1005 configuration,
1006 hash: {
1007 image: imageHash,
1008 digest: imageDigest,
1009 configuration: configurationHash,
1010 },
1011 dev,
1012 };
1013 }
1014
1015 yield* session.note(
1016 `Updating container application ${existing.applicationName}...`,
1017 );
1018 const application = yield* retryForContainerApplicationReadiness(
1019 "update",
1020 existing.applicationId,
1021 Containers.updateContainerApplication({
1022 accountId,
1023 applicationId: existing.applicationId,
1024 ...scaling,
1025 affinities: news.affinities,
1026 configuration,
1027 }),
1028 ).pipe(
1029 // The "existing" application was observed from an eventually-
1030 // consistent list/get but is actually gone — e.g. a stale row that
1031 // lingered after a replacement/DO-recreate delete, surfaced by
1032 // either the by-name or by-namespace lookup. Updating a ghost
1033 // exhausts the readiness window and then fails permanently with
1034 // `ContainerApplicationNotFound`. Instead, create it fresh so
1035 // reconcile converges regardless of the stale observation. By the
1036 // time the bounded readiness retry has elapsed, the deleted row has
1037 // fallen out of the eventually-consistent views, so this create
1038 // does not re-collide.
1039 Effect.catchTag("ContainerApplicationNotFound", () =>
1040 Effect.gen(function* () {
1041 yield* Effect.logInfo(
1042 `Cloudflare Container update: ${existing.applicationName} no longer exists, creating fresh`,
1043 );
1044 return yield* Containers.createContainerApplication({
1045 accountId,
1046 name: existing.applicationName,
1047 ...scaling,
1048 affinities: news.affinities,
1049 configuration,
1050 durableObjects,
1051 });
1052 }),
1053 ),
1054 );
1055 const updated = toAttributes(application);
1056 if (!deepEqual(existing.configuration, configuration)) {
1057 yield* Effect.logInfo(
1058 `Cloudflare Container update: creating rollout for ${updated.applicationName}`,
1059 );
1060 yield* maybeCreateRollout({
1061 applicationId: updated.applicationId,
1062 configuration,
1063 rollout: news.rollout,
1064 });
1065 }
1066 return {
1067 ...updated,
1068 configuration,
1069 hash: {
1070 image: imageHash,
1071 digest: imageDigest,
1072 configuration: configurationHash,
1073 },
1074 dev,
1075 };
1076 });
1077
1078 const getDurableObjects = (
1079 bindings: ResourceBinding<ContainerApplication["Binding"]>[],
1080 ) => {
1081 // A stale Worker namespace map can resolve a binding to an object
1082 // without an id. It does not request removing the live attachment.
1083 const dos = bindings.flatMap((b) =>
1084 b.data.durableObjects?.namespaceId ? [b.data.durableObjects] : [],
1085 );
1086 // A single DO namespace may appear in multiple bindings (e.g. when
1087 // a Container is referenced by several resources). Dedupe by namespaceId.
1088 const uniqueDos = dos.filter(
1089 (d, i, arr) =>
1090 arr.findIndex((other) => other.namespaceId === d.namespaceId) === i,
1091 );
1092 if (uniqueDos.length === 0) {
1093 return Effect.succeed(undefined);
1094 }
1095 if (uniqueDos.length === 1) {
1096 return Effect.succeed(uniqueDos[0]);
1097 }
1098 return Effect.die(
1099 new Error(
1100 `A Container can only be bound to one Durable Object namespace. Found ${uniqueDos.length} unique namespaces in bindings: ${uniqueDos.map((d) => d.namespaceId).join(", ")}`,
1101 ),
1102 );
1103 };
1104
1105 return ContainerPlatform.Provider.of({
1106 stables: ["accountId", "applicationId"],
1107 diff: Effect.fn(function* ({
1108 id,
1109 olds = {},
1110 news = {},
1111 output,
1112 newBindings,
1113 oldBindings,
1114 }) {
1115 if (!isResolved(news) || !isResolved(newBindings)) {
1116 return undefined;
1117 }
1118 const { accountId } = yield* yield* CloudflareEnvironment;
1119
1120 const oldName =
1121 output?.applicationName ??
1122 (yield* createApplicationName(id, olds.name));
1123 // Auto-generated names are engine-owned: the deployed name stays
1124 // authoritative even if the generator would name this id differently
1125 // today. Only an explicit user-provided name can force a replace.
1126 const name = news.name ?? oldName;
1127
1128 if (
1129 (output?.accountId ?? accountId) !== accountId ||
1130 name !== oldName
1131 ) {
1132 return { action: "replace" } as const;
1133 }
1134
1135 const hasDurableObjects =
1136 (yield* getDurableObjects(newBindings)) !== undefined;
1137 const hasUnresolvedAttachment =
1138 !hasDurableObjects &&
1139 newBindings.some(
1140 (binding) => binding.data.durableObjects !== undefined,
1141 );
1142 const hadDurableObjects =
1143 (yield* getDurableObjects(oldBindings)) !== undefined;
1144 if (
1145 !hasUnresolvedAttachment &&
1146 hasDurableObjects !== hadDurableObjects
1147 ) {
1148 return { action: "replace" } as const;
1149 }
1150
1151 if (!output) {
1152 return undefined;
1153 }
1154
1155 // A `dev:` applicationId means the resource only exists locally and
1156 // the real application has never been created. Promote it by forcing
1157 // an update so reconcile creates the live application.
1158 if (!isLiveId(output.applicationId)) {
1159 // Override stables to only include the accountId because the applicationId is going to change.
1160 return { action: "update", stables: ["accountId"] } as const;
1161 }
1162
1163 const application = yield* Containers.getContainerApplication({
1164 accountId: output.accountId,
1165 applicationId: output.applicationId,
1166 }).pipe(
1167 Effect.catchTag("ContainerApplicationNotFound", () =>
1168 Effect.succeed(undefined),
1169 ),
1170 );
1171 if (
1172 application &&
1173 (application.id !== output.applicationId ||
1174 application.name !== output.applicationName ||
1175 application.accountId !== output.accountId)
1176 ) {
1177 return { action: "replace" } as const;
1178 }
1179
1180 const { imageHash, dev } = yield* computeImage(
1181 id,
1182 news,
1183 makeContainerEnv(news, accountId, newBindings),
1184 );
1185 if (imageHash !== output.hash?.image || !deepEqual(dev, output.dev)) {
1186 return { action: "update" } as const;
1187 }
1188 }),
1189 precreate: Effect.fn(function* ({ id, news = {}, session }) {
1190 const name = yield* createApplicationName(id, news.name);
1191 yield* Effect.logInfo(
1192 `Cloudflare Container precreate: starting ${name}`,
1193 );
1194
1195 const { accountId } = yield* yield* CloudflareEnvironment;
1196 const env = makeContainerEnv(news, accountId);
1197 const { build, imageRef, imageHash, dev } = yield* computeImage(
1198 id,
1199 news,
1200 env,
1201 );
1202 const published = yield* buildAndPushImage(
1203 id,
1204 news,
1205 build,
1206 imageRef,
1207 imageHash,
1208 undefined,
1209 session,
1210 );
1211 const configuration = desiredConfiguration(
1212 news,
1213 env,
1214 published.imageRef,
1215 );
1216 const configurationHash = yield* applicationConfigurationHash(
1217 scalingDefaults(news),
1218 news.affinities,
1219 configuration,
1220 );
1221
1222 // Precreate intentionally omits the Durable Object attachment so the
1223 // worker can bind to this application id and break the circular
1224 // dependency. The final create step recreates the application with the
1225 // resolved namespace when needed.
1226 const result = yield* createApplication({
1227 id,
1228 news,
1229 // Precreate runs before the engine resolves bindings (that is what
1230 // breaks the worker <-> container cycle), so binding-injected env
1231 // lands on the following reconcile.
1232 bindings: [],
1233 name,
1234 configuration,
1235 durableObjects: undefined,
1236 session: {
1237 ...session,
1238 note: (message) =>
1239 session.note(message.replace("Creating", "Pre-creating")),
1240 },
1241 });
1242 return {
1243 ...("applicationId" in result ? result : toAttributes(result)),
1244 hash: {
1245 image: imageHash,
1246 digest: published.digest,
1247 configuration: configurationHash,
1248 },
1249 dev,
1250 };
1251 }),
1252 reconcile: Effect.fn(function* ({
1253 id,
1254 news = {},
1255 bindings,
1256 output,
1257 session,
1258 }) {
1259 // Prefer the deployed name: regenerating would target a different
1260 // resource if the generator's output for this id ever drifts.
1261 const name =
1262 output?.applicationName ??
1263 (yield* createApplicationName(id, news.name));
1264 yield* Effect.logInfo(
1265 `Cloudflare Container reconcile: starting ${name}`,
1266 );
1267 const durableObjects = yield* getDurableObjects(bindings);
1268 const hasUnresolvedAttachment =
1269 durableObjects === undefined &&
1270 bindings.some(
1271 (binding) => binding.data.durableObjects !== undefined,
1272 );
1273 const { accountId } = yield* yield* CloudflareEnvironment;
1274 const env = makeContainerEnv(news, accountId, bindings);
1275
1276 // Observe — re-fetch the cached application to confirm it still
1277 // exists. Cloudflare reports a deleted container application as
1278 // `ContainerApplicationNotFound`; we fall back to a name lookup
1279 // so we can recover from out-of-band deletes or partial state
1280 // persistence failures.
1281 let existing: ContainerApplication["Attributes"] | undefined;
1282 // A `dev:` applicationId never exists on Cloudflare — skip the
1283 // cached-id fetch and fall through to the name lookup / create path
1284 // so we promote the local resource to a real application.
1285 if (output?.applicationId && isLiveId(output.applicationId)) {
1286 existing = yield* Containers.getContainerApplication({
1287 accountId: output.accountId,
1288 applicationId: output.applicationId,
1289 }).pipe(
1290 Effect.map((app) => ({
1291 ...toAttributes(app),
1292 hash: output.hash,
1293 })),
1294 Effect.catchTag("ContainerApplicationNotFound", () =>
1295 Effect.succeed(undefined),
1296 ),
1297 );
1298 }
1299 if (!existing) {
1300 const found = yield* findApplicationByName(name);
1301 if (found) {
1302 existing = {
1303 ...toAttributes(found),
1304 hash: output?.hash,
1305 };
1306 }
1307 }
1308
1309 // Only use cached attachment data after confirming the application
1310 // is missing. An observed live attachment outranks stale state.
1311 const recordedDurableObjects = existing
1312 ? existing.durableObjects
1313 : output?.accountId === accountId && isLiveId(output.applicationId)
1314 ? output.durableObjects
1315 : undefined;
1316 const durableObjectsForRecovery =
1317 durableObjects ??
1318 (hasUnresolvedAttachment && recordedDurableObjects?.namespaceId
1319 ? recordedDurableObjects
1320 : undefined);
1321 if (
1322 hasUnresolvedAttachment &&
1323 durableObjectsForRecovery === undefined
1324 ) {
1325 return yield* Effect.fail(
1326 new Error(
1327 `Container application "${name}" has an unresolved Durable Object namespace and no recorded attachment. Reconcile its Worker first.`,
1328 ),
1329 );
1330 }
1331 const { build, imageRef, imageHash, dev } = yield* computeImage(
1332 id,
1333 news,
1334 env,
1335 );
1336
1337 // Special case: precreate produced an application without the
1338 // durable object attachment, but the real reconcile now has one.
1339 // The DO attachment is immutable, so we delete
1340 // and recreate. Adoption-by-namespace is preferred when an app
1341 // already owns the namespace.
1342 // An unresolved declaration is not an intentional removal.
1343 if (
1344 existing &&
1345 !hasUnresolvedAttachment &&
1346 !deepEqual(existing.durableObjects, durableObjects)
1347 ) {
1348 if (durableObjects) {
1349 const owner = yield* findApplicationByNamespace(
1350 durableObjects.namespaceId,
1351 );
1352 const recovery = resolveDurableObjectApplicationRecovery({
1353 namespaceId: durableObjects.namespaceId,
1354 expectedName: name,
1355 existingName: owner?.name,
1356 });
1357 if (recovery.canAdopt) {
1358 if (!owner) {
1359 return yield* Effect.fail(
1360 new Error(
1361 `Container application for Durable Object namespace "${durableObjects.namespaceId}" already exists but could not be found for adoption.`,
1362 ),
1363 );
1364 }
1365 return yield* upsertApplication({
1366 id,
1367 news,
1368 bindings,
1369 existing: toAttributes(owner),
1370 durableObjects,
1371 session,
1372 });
1373 }
1374 }
1375 let deploymentImageRef = existing.configuration.image;
1376 let imageDigest = existing.hash?.digest;
1377 if (imageHash !== existing.hash?.image) {
1378 const published = yield* buildAndPushImage(
1379 id,
1380 news,
1381 build,
1382 imageRef,
1383 imageHash,
1384 existing.hash?.digest === undefined
1385 ? existing.configuration.image
1386 : undefined,
1387 session,
1388 );
1389 const existingDigest =
1390 existing.hash?.digest ?? published.previousDigest;
1391 deploymentImageRef =
1392 published.digest === existingDigest &&
1393 news.publish?.repository === undefined
1394 ? existing.configuration.image
1395 : published.imageRef;
1396 imageDigest = published.digest;
1397 }
1398 const configuration = desiredConfiguration(
1399 news,
1400 env,
1401 deploymentImageRef,
1402 );
1403 const configurationHash = yield* applicationConfigurationHash(
1404 scalingDefaults(news),
1405 news.affinities,
1406 configuration,
1407 );
1408 yield* Effect.logInfo(
1409 `Cloudflare Container reconcile: recreating ${name} to attach durable object binding`,
1410 );
1411 yield* session.note(
1412 `Recreating container application ${name} with durable object binding...`,
1413 );
1414 yield* Containers.deleteContainerApplication({
1415 accountId: existing.accountId,
1416 applicationId: existing.applicationId,
1417 }).pipe(
1418 Effect.catchTag(
1419 "ContainerApplicationNotFound",
1420 () => Effect.void,
1421 ),
1422 );
1423 // Wait out the eventually-consistent `list` so the recreate below
1424 // doesn't re-adopt the just-deleted application and then try to
1425 // update a now-gone id (see `waitForApplicationDeleted`).
1426 yield* waitForApplicationDeleted(name, existing.applicationId);
1427 const result = yield* createApplication({
1428 id,
1429 news,
1430 bindings,
1431 name,
1432 configuration,
1433 durableObjects,
1434 session,
1435 });
1436 return {
1437 ...("applicationId" in result ? result : toAttributes(result)),
1438 hash: {
1439 image: imageHash,
1440 digest: imageDigest,
1441 configuration: configurationHash,
1442 },
1443 dev,
1444 };
1445 }
1446
1447 // Sync — application exists with correct DO attachment. Apply
1448 // the desired configuration (image + scheduling + secrets, etc.)
1449 // through the upsert path, which builds and pushes the image
1450 // only when the hash changed and creates a rollout if the
1451 // configuration drifted.
1452 if (existing) {
1453 return yield* upsertApplication({
1454 id,
1455 news,
1456 bindings,
1457 existing,
1458 // Keep the live attachment through the ghost-recreate fallback
1459 // when the desired value is unresolved.
1460 durableObjects: durableObjectsForRecovery,
1461 session,
1462 });
1463 }
1464
1465 // Ensure — no application exists. Build and push the image,
1466 // then create. `createApplication` itself tolerates concurrent
1467 // creates by adopting an existing application with the same
1468 // name or namespace.
1469 const published = yield* buildAndPushImage(
1470 id,
1471 news,
1472 build,
1473 imageRef,
1474 imageHash,
1475 undefined,
1476 session,
1477 );
1478 const configuration = desiredConfiguration(
1479 news,
1480 env,
1481 published.imageRef,
1482 );
1483 const configurationHash = yield* applicationConfigurationHash(
1484 scalingDefaults(news),
1485 news.affinities,
1486 configuration,
1487 );
1488 const result = yield* createApplication({
1489 id,
1490 news,
1491 bindings,
1492 name,
1493 configuration,
1494 durableObjects: durableObjectsForRecovery,
1495 session,
1496 });
1497 return {
1498 ...("applicationId" in result ? result : toAttributes(result)),
1499 hash: {
1500 image: imageHash,
1501 digest: published.digest,
1502 configuration: configurationHash,
1503 },
1504 dev,
1505 };
1506 }),
1507 delete: Effect.fn(function* ({ output }) {
1508 // A `dev:` applicationId only exists locally — there is no live
1509 // application to delete on Cloudflare.
1510 if (!isLiveId(output.applicationId)) return;
1511 yield* Effect.logInfo(
1512 `Cloudflare Container delete: deleting ${output.applicationName}`,
1513 );
1514 yield* Containers.deleteContainerApplication({
1515 accountId: output.accountId,
1516 applicationId: output.applicationId,
1517 }).pipe(
1518 Effect.catchTag("ContainerApplicationNotFound", () => Effect.void),
1519 );
1520 }),
1521 read: Effect.fn(function* ({ id, olds, output }) {
1522 const readByName = (name: string) =>
1523 Effect.gen(function* () {
1524 yield* Effect.logInfo(
1525 `Cloudflare Container read: looking up ${name}`,
1526 );
1527 const existing = yield* findApplicationByName(name);
1528 if (!existing) {
1529 yield* Effect.logInfo(
1530 `Cloudflare Container read: ${name} not found`,
1531 );
1532 return undefined;
1533 }
1534 return {
1535 ...toAttributes(existing),
1536 hash: output?.hash,
1537 // The dev image is a local build-context reference that the
1538 // API can't return — preserve the persisted one so a refresh
1539 // doesn't wipe it (which would break a later `alchemy dev`).
1540 dev: output?.dev,
1541 };
1542 });
1543
1544 let attrs: ContainerApplication["Attributes"] | undefined;
1545 // A `dev:` applicationId never exists on Cloudflare — look the
1546 // application up by its (deterministic) name instead of hitting the
1547 // API with a fake id.
1548 if (output?.applicationId && !isLiveId(output.applicationId)) {
1549 return yield* readByName(output.applicationName);
1550 }
1551 if (output?.applicationId) {
1552 yield* Effect.logInfo(
1553 `Cloudflare Container read: checking ${output.applicationName}`,
1554 );
1555 attrs = yield* Containers.getContainerApplication({
1556 accountId: output.accountId,
1557 applicationId: output.applicationId,
1558 }).pipe(
1559 Effect.map((app) => ({
1560 ...toAttributes(app),
1561 hash: output.hash,
1562 dev: output.dev,
1563 })),
1564 Effect.catchTag("ContainerApplicationNotFound", () =>
1565 readByName(output.applicationName),
1566 ),
1567 );
1568 // If we matched by id from prior state, treat as owned.
1569 return attrs;
1570 }
1571
1572 const name = yield* createApplicationName(id, olds?.name);
1573 attrs = yield* readByName(name);
1574 if (!attrs) return undefined;
1575 // Generated names identify this instance by its random suffix.
1576 // Explicit names alone do not establish ownership.
1577 return olds?.name === undefined ? attrs : Unowned(attrs);
1578 }),
1579 list: () =>
1580 Effect.gen(function* () {
1581 const { accountId } = yield* yield* CloudflareEnvironment;
1582 // Account-scoped collection. `listContainerApplications` returns
1583 // the full application objects in one (non-paginated) response, so
1584 // each item already carries the complete `read` attributes shape —
1585 // no per-item hydration is required.
1586 return yield* Containers.listContainerApplications({
1587 accountId,
1588 }).pipe(
1589 Effect.map((apps) => apps.map((app) => toAttributes(app))),
1590 // Accounts without the containers product reject the route; treat
1591 // a non-entitled account as an empty collection rather than an
1592 // error.
1593 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
1594 );
1595 }),
1596 tail: ({ output }) =>
1597 telemetry.tailStream({
1598 accountId: output.accountId,
1599 filters: containerFilters(output.applicationId),
1600 }),
1601 logs: ({ output, options }) =>
1602 telemetry.queryLogs({
1603 accountId: output.accountId,
1604 filters: containerFilters(output.applicationId),
1605 options,
1606 }),
1607 });
1608 }),
1609 );
1610
1611const containerFilters = (applicationId: string): TelemetryFilter[] => [
1612 {
1613 key: "$metadata.type",
1614 operation: "eq",
1615 type: "string",
1616 value: "cf-container",
1617 },
1618 {
1619 key: "$metadata.service",
1620 operation: "eq",
1621 type: "string",
1622 value: applicationId,
1623 },
1624];
1625
1626const resolveDurableObjectApplicationRecovery = ({
1627 namespaceId,
1628 expectedName,
1629 existingName,
1630}: {
1631 namespaceId: string;
1632 expectedName: string;
1633 existingName: string | undefined;
1634}) => {
1635 if (!existingName) {
1636 return {
1637 canAdopt: false as const,
1638 message: `Container application for Durable Object namespace "${namespaceId}" already exists but could not be found for adoption.`,
1639 };
1640 }
1641 if (existingName !== expectedName) {
1642 return {
1643 canAdopt: false as const,
1644 message: `Existing container application "${existingName}" is already attached to Durable Object namespace "${namespaceId}". Use that application name to adopt it.`,
1645 };
1646 }
1647 return {
1648 canAdopt: true as const,
1649 };
1650};
1651
1652// Cap each delay at 3s so the readiness window is ~30s over 10 attempts; an
1653// uncapped `Schedule.exponential(150)` reaches a ~76s single delay by the 10th
1654// retry (~150s total), which both blows test budgets and needlessly stalls the
1655// update→create fallback when the target is genuinely gone.
1656const containerApplicationReadinessSchedule = Schedule.max([
1657 Schedule.min([Schedule.exponential(150), Schedule.spaced("3 seconds")]),
1658 Schedule.recurs(10),
1659]);
1660
1661const isContainerApplicationNotFound = (
1662 error: unknown,
1663): error is Containers.ContainerApplicationNotFound =>
1664 typeof error === "object" &&
1665 error !== null &&
1666 "_tag" in error &&
1667 error._tag === "ContainerApplicationNotFound";
1668
1669export const retryForContainerApplicationReadiness = <A, E, R>(
1670 operation: string,
1671 applicationId: string,
1672 effect: Effect.Effect<A, E, R>,
1673) =>
1674 effect.pipe(
1675 Effect.tapError((error) =>
1676 isContainerApplicationNotFound(error)
1677 ? Effect.logDebug(
1678 `Cloudflare Container ${operation}: application ${applicationId} not found yet, retrying`,
1679 )
1680 : Effect.void,
1681 ),
1682 Effect.retry({
1683 while: isContainerApplicationNotFound,
1684 schedule: containerApplicationReadinessSchedule,
1685 }),
1686 );
1687
1688const toAttributes = (
1689 application:
1690 | Containers.CreateContainerApplicationResponse
1691 | Containers.UpdateContainerApplicationResponse
1692 | Containers.GetContainerApplicationResponse
1693 | Containers.ListContainerApplicationsResponse[number],
1694): ContainerApplication["Attributes"] => ({
1695 applicationId: application.id,
1696 applicationName: application.name,
1697 accountId: application.accountId,
1698 schedulingPolicy: application.schedulingPolicy,
1699 instances: application.instances,
1700 maxInstances: application.maxInstances,
1701 constraints: normalizeNulls(
1702 application.constraints as ContainerApplication.Constraints | undefined,
1703 ),
1704 affinities: normalizeNulls(
1705 application.affinities as ContainerApplication.Affinities | undefined,
1706 ),
1707 configuration: normalizeNulls(
1708 application.configuration as ContainerApplication.Configuration,
1709 ),
1710 durableObjects: normalizeNulls(application.durableObjects) as
1711 | { namespaceId: string }
1712 | undefined,
1713 createdAt: application.createdAt,
1714 version: application.version,
1715 dev: undefined,
1716});