1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Redacted from "effect/Redacted";
5import * as Artifacts from "../../Artifacts.ts";
6import { hashDirectory } from "../../Command/Memo.ts";
7import { isResolved } from "../../Diff.ts";
8import type { ResourceBinding } from "../../Resource.ts";
9import * as RpcProvider from "../../Local/RpcProvider.ts";
10import { sha256Object } from "../../Util/sha256.ts";
11import { normalizeNulls } from "../../Util/stable.ts";
12import { localAccountId } from "../LocalAccount.ts";
13import { generateLocalId, LOCAL_PROVIDERS_URL } from "../LocalRuntime.ts";
14import type {
15 AnyContainerApplicationProps,
16 ContainerApplication,
17 DevContainerImage,
18} from "./ContainerApplication.ts";
19import { isInlineDockerfile } from "../../Docker/Dockerfile.ts";
20import {
21 createContainerApplicationName,
22 makeContainerEnv,
23 materializeInlineDockerfileContext,
24 prepareContainerBuildContext,
25 validateContainerImageProps,
26} from "./ContainerBundle.ts";
27import { ContainerPlatform } from "./ContainerPlatform.ts";
28
29/**
30 * Local (dev) provider for Cloudflare Container applications.
31 *
32 * The Docker build/run is owned by `@alchemy.run/cloudflare-runtime/core`; this
33 * provider's only job is to resolve the `dev` image the runtime should use —
34 * a build context to `docker build` (Effect-native `main` or a user-supplied
35 * Dockerfile) or a remote image to `docker pull` — mirroring the three image
36 * variants of the live provider's `computeImage`.
37 *
38 * Everything else on the attributes is a placeholder: the real
39 * `applicationId`/`configuration`/etc. only exist once the live provider
40 * promotes this resource on a real deploy. The `applicationId` uses the local
41 * id mechanism (`dev:<uuid>`) so the live provider can detect a dev resource
42 * and create the real one.
43 */
44export const LocalContainerProvider = () =>
45 RpcProvider.effect(
46 ContainerPlatform,
47 LOCAL_PROVIDERS_URL,
48 Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51
52 // Resolve the `dev` image plus a content hash for change detection.
53 // Cached per run (`Artifacts.cached`, keyed by resource id) so repeated
54 // diffs/reconciles in a single dev session don't re-bundle or re-hash.
55 //
56 // IMPORTANT: the cached result must stay env-free. The cache is warmed
57 // by `precreate`, which runs against unresolved props — binding-derived
58 // env values (e.g. an ApiToken's value/accountId) are still unresolved
59 // `Output`s there and get skipped. Caching env here would freeze that
60 // incomplete env and start the container without its bindings;
61 // `makeAttributes` attaches the freshly-computed env instead.
62 const prepareImage = (id: string, news: AnyContainerApplicationProps) =>
63 Effect.gen(function* () {
64 yield* validateContainerImageProps(news);
65 // Variant 1 — Effect-native program. Bundle `main` and write it
66 // (plus the generated Dockerfile) into a stable build context
67 // directory. `Docker.build` in cloudflare-runtime reads `dockerfile`
68 // as a file path and uses `context` as the build context, so we
69 // point `dev` at both. The build-context materialization is shared
70 // with the live provider (see `prepareContainerBuildContext`).
71 if (news.main) {
72 const { context, dockerfile, hash } =
73 yield* prepareContainerBuildContext(id, news);
74 return {
75 dev: {
76 context: path.relative(process.cwd(), context),
77 dockerfile: path.relative(context, dockerfile),
78 } as DevContainerImage,
79 hash,
80 };
81 }
82
83 // Variant 2 — pre-built remote image. The runtime pulls it
84 // directly; there is nothing to build.
85 if (news.image) {
86 return {
87 dev: { imageUri: news.image } as DevContainerImage,
88 hash: yield* sha256Object({ image: news.image }),
89 };
90 }
91
92 // Variant 3a — inline Dockerfile content: materialize into the
93 // same stable generated context the live provider uses and point
94 // the runtime's build at it.
95 if (
96 news.dockerfile !== undefined &&
97 isInlineDockerfile(news.dockerfile)
98 ) {
99 const content = news.dockerfile.content;
100 if (typeof content !== "string") {
101 return yield* Effect.die(
102 new Error(
103 "Inline `dockerfile` content is an unresolved Output at image-build time — its dependencies have not resolved yet. Break the cycle or inline the resolved value.",
104 ),
105 );
106 }
107 const { context } = yield* materializeInlineDockerfileContext(
108 id,
109 content,
110 );
111 return {
112 dev: {
113 context: path.relative(process.cwd(), context),
114 dockerfile: "Dockerfile",
115 } as DevContainerImage,
116 hash: yield* sha256Object({ dockerfile: content }),
117 };
118 }
119
120 // Variant 3b — user-supplied Dockerfile path + build context
121 // directory. The runtime builds the user's Dockerfile against the
122 // (real-path'd) context, exactly like the live provider's
123 // `external` variant.
124 const context = yield* fs.realPath(news.context ?? ".");
125 const dockerfile = news.dockerfile
126 ? yield* fs.realPath(news.dockerfile)
127 : path.join(context, "Dockerfile");
128 const contextHash = yield* hashDirectory({ cwd: context });
129 const dockerfileContent = yield* fs.readFileString(dockerfile);
130 return {
131 dev: {
132 context: path.relative(process.cwd(), context),
133 dockerfile: path.relative(context, dockerfile),
134 } as DevContainerImage,
135 hash: yield* sha256Object({
136 contextHash,
137 dockerfile: dockerfileContent,
138 }),
139 };
140 }).pipe(Artifacts.cached(`container-image:${id}`));
141
142 /**
143 * The props that decide what the dev IMAGE is, picked off the
144 * possibly-unresolved plan-time props — or `undefined` when any of
145 * them has not resolved yet. Everything else (env, instances, …) may
146 * still carry Outputs/Effects (the `.make` form's `exports` impl
147 * Effect never resolves at all) without disabling the content check.
148 */
149 const resolvedImageInputs = (
150 input: unknown,
151 ): AnyContainerApplicationProps | undefined => {
152 if (typeof input !== "object" || input === null) return undefined;
153 const news = input as AnyContainerApplicationProps;
154 const picked = {
155 main: news.main,
156 image: news.image,
157 dockerfile: news.dockerfile,
158 context: news.context,
159 runtime: news.runtime,
160 handler: news.handler,
161 isExternal: news.isExternal,
162 external: news.external,
163 build: news.build,
164 autoInstallExternals: news.autoInstallExternals,
165 };
166 if (!isResolved(picked)) return undefined;
167 if (
168 !picked.main &&
169 !picked.image &&
170 !picked.dockerfile &&
171 !picked.context
172 ) {
173 return undefined;
174 }
175 return picked as AnyContainerApplicationProps;
176 };
177
178 const placeholderConfiguration = (
179 props: AnyContainerApplicationProps,
180 env: Record<string, string | Redacted.Redacted<string>>,
181 ) =>
182 normalizeNulls({
183 image: "local",
184 instanceType: props.instanceType,
185 observability: props.observability,
186 sshPublicKeyIds: props.sshPublicKeyIds,
187 secrets: props.secrets,
188 vcpu: props.vcpu,
189 memory: props.memory,
190 memoryMib: props.memoryMib,
191 disk: props.disk,
192 environmentVariables: Object.entries(env).map(([name, value]) => ({
193 name,
194 value: Redacted.isRedacted(value) ? Redacted.value(value) : value,
195 })),
196 labels: props.labels,
197 network: props.network,
198 command: props.command,
199 entrypoint: props.entrypoint,
200 dns: props.dns,
201 ports: props.ports,
202 checks: props.checks,
203 }) as ContainerApplication.Configuration;
204
205 const makeAttributes = Effect.fn(function* ({
206 id,
207 news,
208 bindings,
209 output,
210 }: {
211 id: string;
212 news: AnyContainerApplicationProps;
213 bindings: ResourceBinding<ContainerApplication["Binding"]>[];
214 output: ContainerApplication["Attributes"] | undefined;
215 }) {
216 const accountId = yield* localAccountId;
217 const env = makeContainerEnv(news, accountId, bindings);
218 const { dev, hash } = yield* prepareImage(id, news);
219 return {
220 applicationId: output?.applicationId ?? generateLocalId(),
221 applicationName: yield* createContainerApplicationName(id, news.name),
222 accountId: output?.accountId ?? accountId,
223 schedulingPolicy: news.schedulingPolicy ?? "default",
224 instances: news.instances ?? 1,
225 maxInstances: news.maxInstances ?? 1,
226 constraints: news.constraints,
227 affinities: news.affinities,
228 configuration: placeholderConfiguration(news, env),
229 durableObjects: undefined,
230 createdAt: new Date().toISOString(),
231 version: 1,
232 dev: { ...dev, env },
233 hash: { image: hash },
234 } satisfies ContainerApplication["Attributes"];
235 });
236
237 return {
238 stables: ["accountId", "applicationId"],
239 diff: Effect.fn(function* ({ id, news, output }) {
240 if (!output) return { action: "update" };
241 // A content-only edit (an imported module of `main`, a Dockerfile,
242 // a context file) changes no prop, so the engine's structural
243 // fallback would call it a noop and the image would never rebuild.
244 // Compare the recomputed image hash instead — and gate only on
245 // the IMAGE inputs being resolved, not the whole props bag: the
246 // platform `.make` form carries an `exports` impl Effect that is
247 // never "resolved", and requiring full resolution silently
248 // disabled this check for every effectful container.
249 const imageInputs = resolvedImageInputs(news);
250 if (imageInputs !== undefined) {
251 // Recompute fresh on every plan. `prepareImage` is memoized so
252 // a plan's diff→precreate→reconcile chain bundles once — but
253 // this provider runs in the RPC sidecar, whose `ArtifactStore`
254 // outlives every run, so without this eviction the FIRST run's
255 // hash would be compared forever.
256 yield* (yield* Artifacts.Artifacts).delete(`container-image:${id}`);
257 const input = yield* prepareImage(id, imageInputs);
258 if (input.hash !== output.hash?.image || !output.dev) {
259 return { action: "update" };
260 }
261 }
262 if (!isResolved(news)) return undefined;
263 return !output.dev ? { action: "update" } : undefined;
264 }),
265 read: Effect.fn(function* ({ output }) {
266 return output;
267 }),
268 // Precreate breaks the worker <-> container cycle: the worker depends
269 // on the container's `dev` image, while the container binds the
270 // worker-hosted Durable Object namespace. Building the image here lets
271 // the worker resolve `dev` without waiting on the container's reconcile.
272 precreate: Effect.fn(function* ({ id, news }) {
273 // Bindings are not resolved yet at precreate (that is what breaks
274 // the cycle); binding-injected env lands on the reconcile below.
275 return yield* makeAttributes({
276 id,
277 news,
278 bindings: [],
279 output: undefined,
280 });
281 }),
282 reconcile: Effect.fn(function* ({ id, news, bindings, output }) {
283 return yield* makeAttributes({ id, news, bindings, output });
284 }),
285 delete: Effect.fn(function* () {
286 // Nothing to tear down: the build context lives under `.alchemy/tmp`
287 // and is reused across runs; the running container is owned by the
288 // worker runtime.
289 }),
290 };
291 }),
292 );