| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import * as Artifacts from "../../Artifacts.ts"; |
| 6 | import { hashDirectory } from "../../Command/Memo.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import type { ResourceBinding } from "../../Resource.ts"; |
| 9 | import * as RpcProvider from "../../Local/RpcProvider.ts"; |
| 10 | import { sha256Object } from "../../Util/sha256.ts"; |
| 11 | import { normalizeNulls } from "../../Util/stable.ts"; |
| 12 | import { localAccountId } from "../LocalAccount.ts"; |
| 13 | import { generateLocalId, LOCAL_PROVIDERS_URL } from "../LocalRuntime.ts"; |
| 14 | import type { |
| 15 | AnyContainerApplicationProps, |
| 16 | ContainerApplication, |
| 17 | DevContainerImage, |
| 18 | } from "./ContainerApplication.ts"; |
| 19 | import { isInlineDockerfile } from "../../Docker/Dockerfile.ts"; |
| 20 | import { |
| 21 | createContainerApplicationName, |
| 22 | makeContainerEnv, |
| 23 | materializeInlineDockerfileContext, |
| 24 | prepareContainerBuildContext, |
| 25 | validateContainerImageProps, |
| 26 | } from "./ContainerBundle.ts"; |
| 27 | import { 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 | */ |
| 44 | export 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 | ); |