| 1 | /** |
| 2 | * `@alchemy.run/floci` — Effect-native manager for the |
| 3 | * [floci](https://floci.io) local cloud emulator. |
| 4 | * |
| 5 | * alchemy's AWS local dev points its providers at a floci endpoint. This |
| 6 | * package owns the emulator lifecycle with a strict resolution order that |
| 7 | * keeps every local loop registry-free: |
| 8 | * |
| 9 | * 1. **Already serving** — if anything answers on the endpoint (e.g. |
| 10 | * `bun floci:dev` running `mvn quarkus:dev` from a floci checkout, or a |
| 11 | * hand-run container), it wins. Nothing is started, nothing owned. |
| 12 | * 2. **`ALCHEMY_FLOCI_IMAGE`** — run a specific image (e.g. a locally |
| 13 | * built `floci:dev` from a patched fork). |
| 14 | * 3. **The pinned release** — {@link DEFAULT_FLOCI_IMAGE}, alchemy's |
| 15 | * distribution of floci (upstream + alchemy patch set, published from |
| 16 | * github.com/alchemy-run/floci). |
| 17 | * |
| 18 | * The container is started detached with a stable name — the DOCKER DAEMON |
| 19 | * is the supervisor, not alchemy: it outlives `alchemy dev` sessions, so |
| 20 | * queues/tables/functions stay warm between runs and the next session |
| 21 | * reattaches via the health probe in milliseconds. |
| 22 | */ |
| 23 | import * as Data from "effect/Data"; |
| 24 | import * as Duration from "effect/Duration"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Schedule from "effect/Schedule"; |
| 27 | import * as Semaphore from "effect/Semaphore"; |
| 28 | import { execFile } from "node:child_process"; |
| 29 | import * as fs from "node:fs/promises"; |
| 30 | import * as net from "node:net"; |
| 31 | import * as os from "node:os"; |
| 32 | import * as path from "node:path"; |
| 33 | |
| 34 | /** |
| 35 | * The pinned floci release alchemy ships: upstream + the alchemy patch |
| 36 | * set, built and published by the alchemy-run/floci fork's release |
| 37 | * workflow. Bumping this pin (with a new `x.y.z-alchemy.N` tag) is how an |
| 38 | * emulator change reaches users. |
| 39 | */ |
| 40 | export const DEFAULT_FLOCI_IMAGE = "ghcr.io/alchemy-run/floci:1.6.0-alchemy.11"; |
| 41 | |
| 42 | /** Default gateway port (LocalStack-compatible). */ |
| 43 | export const DEFAULT_FLOCI_PORT = 4566; |
| 44 | |
| 45 | /** |
| 46 | * ELB listener ports published on the managed container. Floci's ELBv2 |
| 47 | * data plane serves each listener on the listener's OWN port inside the |
| 48 | * container (see floci's ports doc) — its `*.elb.localhost.floci.io` DNS |
| 49 | * resolves to 127.0.0.1, so the port must be published for the emulated |
| 50 | * load balancer to be reachable from the host. Ports already taken on the |
| 51 | * host are skipped (the gateway still works without them). A warning is |
| 52 | * emitted only when the caller asked for those ports via |
| 53 | * {@link FlociConfig.elbListenerPorts} — the default 80/443 publish is |
| 54 | * opportunistic and a taken privileged port is the common case on a |
| 55 | * developer machine, not a problem. |
| 56 | */ |
| 57 | export const DEFAULT_ELB_LISTENER_PORTS = [80, 443]; |
| 58 | |
| 59 | /** |
| 60 | * Ports published for floci's emulated CloudFront edge. Each emulated |
| 61 | * distribution is served on one of these as plain HTTP, so `alchemy dev` |
| 62 | * hands out a `router.url` a browser can open — a distribution's |
| 63 | * `*.cloudfront.net` domain resolves to nothing on a developer's machine. |
| 64 | * The emulator assigns a port per distribution and reports it back; it can |
| 65 | * only use ports the container actually published, so the resolved list is |
| 66 | * passed in as `FLOCI_SERVICES_CLOUDFRONT_EDGE_PORTS`. Taken host ports are |
| 67 | * skipped, exactly like the ELB listener ports above. |
| 68 | */ |
| 69 | export const DEFAULT_CLOUDFRONT_EDGE_PORTS = Array.from( |
| 70 | { length: 20 }, |
| 71 | (_, index) => 9500 + index, |
| 72 | ); |
| 73 | |
| 74 | /** Default name of the managed container. */ |
| 75 | export const DEFAULT_CONTAINER_NAME = "alchemy-floci"; |
| 76 | |
| 77 | /** |
| 78 | * Stable host path of the emulator's self-signed CA bundle, refreshed by |
| 79 | * {@link ensureFloci} on every successful health check (the CA is minted |
| 80 | * inside the container, so it changes whenever the container is recreated). |
| 81 | * Home-anchored (not cwd-relative) so every process in a dev session — test |
| 82 | * runner, RPC sidecars, workerd children — resolves the same file; consumers |
| 83 | * point TLS trust at it (e.g. `NODE_EXTRA_CA_CERTS`, which the local workerd |
| 84 | * runtime folds into its outbound `trustedCertificates`). |
| 85 | */ |
| 86 | export const FLOCI_CA_PATH = path.join(os.homedir(), ".floci", "ca.pem"); |
| 87 | |
| 88 | /** |
| 89 | * Host directory mounted at the container's TLS dir (`/app/data/tls`) so |
| 90 | * the self-signed CA (which floci reuses when the files already exist) |
| 91 | * SURVIVES container recreations. Without it, every recreate — an image |
| 92 | * bump, a port-config change, a Docker daemon restart — mints a fresh CA, |
| 93 | * and every process that loaded the previous bundle (workerd trust stores |
| 94 | * read once at spawn) fails TLS against the emulator until restarted. |
| 95 | */ |
| 96 | export const FLOCI_TLS_DIR = path.join(os.homedir(), ".floci", "tls"); |
| 97 | |
| 98 | /** |
| 99 | * Best-effort download of the emulator's CA bundle to {@link FLOCI_CA_PATH}. |
| 100 | * Never fails the ensure: an emulator predating `/_floci/tls/ca` (or a |
| 101 | * filesystem error) just leaves the previous bundle in place. |
| 102 | */ |
| 103 | const syncCaBundle = (endpoint: string): Effect.Effect<void> => |
| 104 | Effect.tryPromise({ |
| 105 | try: async (signal) => { |
| 106 | const res = await fetch(`${endpoint}/_floci/tls/ca`, { signal }); |
| 107 | if (!res.ok) return; |
| 108 | const pem = await res.text(); |
| 109 | if (!pem.includes("BEGIN CERTIFICATE")) return; |
| 110 | await fs.mkdir(path.dirname(FLOCI_CA_PATH), { recursive: true }); |
| 111 | await fs.writeFile(FLOCI_CA_PATH, pem); |
| 112 | // Point local workerd (and any other child process spawned after this) |
| 113 | // at the emulator CA so cross-cloud MicroVM data-plane TLS is trusted |
| 114 | // under `alchemy dev` — Node reads NODE_EXTRA_CA_CERTS once at startup, |
| 115 | // so setting it here (before local workers spawn during the apply) only |
| 116 | // affects children, which is exactly the target. Never clobber a value |
| 117 | // the caller set deliberately. |
| 118 | if (process.env.NODE_EXTRA_CA_CERTS === undefined) { |
| 119 | process.env.NODE_EXTRA_CA_CERTS = FLOCI_CA_PATH; |
| 120 | } |
| 121 | }, |
| 122 | catch: (cause) => new FlociError({ message: "ca sync failed", cause }), |
| 123 | }).pipe(Effect.ignore); |
| 124 | |
| 125 | export class FlociError extends Data.TaggedError("FlociError")<{ |
| 126 | readonly message: string; |
| 127 | readonly cause?: unknown; |
| 128 | }> {} |
| 129 | |
| 130 | export interface FlociConfig { |
| 131 | /** |
| 132 | * Image to run when the emulator is not already serving. |
| 133 | * Resolution order: `ALCHEMY_FLOCI_IMAGE` env var, this field, |
| 134 | * {@link DEFAULT_FLOCI_IMAGE}. |
| 135 | */ |
| 136 | readonly image?: string | undefined; |
| 137 | /** |
| 138 | * Gateway port on the host. |
| 139 | * @default 4566 |
| 140 | */ |
| 141 | readonly port?: number | undefined; |
| 142 | /** |
| 143 | * Name of the managed container (stable so `docker start` can revive a |
| 144 | * stopped one). |
| 145 | * @default "alchemy-floci" |
| 146 | */ |
| 147 | readonly containerName?: string | undefined; |
| 148 | /** |
| 149 | * Host directory persisted into the container's data dir. Omit for |
| 150 | * floci's default in-memory storage (state lives as long as the |
| 151 | * container). |
| 152 | */ |
| 153 | readonly storageDir?: string | undefined; |
| 154 | /** |
| 155 | * Mount the host Docker socket so floci's container-backed engines |
| 156 | * (Lambda, ECS, EC2, RDS, ElastiCache) work. |
| 157 | * @default true |
| 158 | */ |
| 159 | readonly dockerSocket?: boolean | undefined; |
| 160 | /** |
| 161 | * Extra environment variables for the emulator (e.g. |
| 162 | * `FLOCI_SERVICES_LAMBDA_HOT_RELOAD_ENABLED`). |
| 163 | */ |
| 164 | readonly env?: Record<string, string> | undefined; |
| 165 | /** |
| 166 | * ELB listener ports to publish on the managed container (floci's ALB |
| 167 | * data plane serves each listener on the listener's own port). Ports |
| 168 | * already taken on the host are skipped (with a warning only when this |
| 169 | * field is set — the default 80/443 publish is opportunistic). |
| 170 | * @default [80, 443] |
| 171 | */ |
| 172 | readonly elbListenerPorts?: readonly number[] | undefined; |
| 173 | /** |
| 174 | * Ports to publish for the emulated CloudFront edge (floci serves each |
| 175 | * distribution on its own port). Ports already taken on the host are |
| 176 | * skipped; an empty result turns the feature off in the emulator so it |
| 177 | * never binds a port nothing can reach. |
| 178 | * @default 9500-9519 |
| 179 | */ |
| 180 | readonly cloudfrontEdgePorts?: readonly number[] | undefined; |
| 181 | /** |
| 182 | * How long to wait for the emulator to become healthy after starting it |
| 183 | * (first run includes the image pull). |
| 184 | * @default 180 seconds |
| 185 | */ |
| 186 | readonly readinessTimeout?: Duration.Input | undefined; |
| 187 | } |
| 188 | |
| 189 | /** A running (or reused) emulator. */ |
| 190 | export interface FlociInstance { |
| 191 | /** Gateway endpoint, e.g. `http://localhost:4566`. */ |
| 192 | readonly endpoint: string; |
| 193 | /** |
| 194 | * Whether this manager started the container (`false` when something was |
| 195 | * already serving on the endpoint — a dev-mode JVM, a hand-run |
| 196 | * container, or a previous session's container). |
| 197 | */ |
| 198 | readonly managed: boolean; |
| 199 | /** Container name when {@link managed} is true. */ |
| 200 | readonly containerName?: string | undefined; |
| 201 | } |
| 202 | |
| 203 | const docker = (args: ReadonlyArray<string>) => |
| 204 | Effect.callback<{ stdout: string; stderr: string }, FlociError>((resume) => { |
| 205 | const child = execFile( |
| 206 | "docker", |
| 207 | args as Array<string>, |
| 208 | { maxBuffer: 16 * 1024 * 1024 }, |
| 209 | (error, stdout, stderr) => { |
| 210 | if (error) { |
| 211 | resume( |
| 212 | Effect.fail( |
| 213 | new FlociError({ |
| 214 | message: `docker ${args[0]} failed: ${stderr.trim() || error.message}`, |
| 215 | cause: error, |
| 216 | }), |
| 217 | ), |
| 218 | ); |
| 219 | } else { |
| 220 | resume(Effect.succeed({ stdout, stderr })); |
| 221 | } |
| 222 | }, |
| 223 | ); |
| 224 | return Effect.sync(() => child.kill()); |
| 225 | }); |
| 226 | |
| 227 | /** |
| 228 | * Whether anything answers HTTP on the endpoint. Any response — even an |
| 229 | * error status — counts as serving; only a transport failure means down. |
| 230 | */ |
| 231 | export const isServing = (endpoint: string): Effect.Effect<boolean> => |
| 232 | Effect.tryPromise({ |
| 233 | try: async (signal) => { |
| 234 | await fetch(`${endpoint}/_floci/health`, { signal }); |
| 235 | return true; |
| 236 | }, |
| 237 | catch: () => new FlociError({ message: "unreachable" }), |
| 238 | }).pipe(Effect.orElseSucceed(() => false)); |
| 239 | |
| 240 | /** Fail unless `GET /_floci/health` answers 200. */ |
| 241 | export const checkHealth = ( |
| 242 | endpoint: string, |
| 243 | ): Effect.Effect<void, FlociError> => |
| 244 | Effect.tryPromise({ |
| 245 | try: async (signal) => { |
| 246 | const res = await fetch(`${endpoint}/_floci/health`, { signal }); |
| 247 | if (!res.ok) { |
| 248 | throw new Error(`health returned ${res.status}`); |
| 249 | } |
| 250 | }, |
| 251 | catch: (cause) => |
| 252 | new FlociError({ |
| 253 | message: `floci health check failed: ${String(cause)}`, |
| 254 | cause, |
| 255 | }), |
| 256 | }); |
| 257 | |
| 258 | /** Resolve the image to run: env override, config, pinned default. */ |
| 259 | export const resolveImage = (config?: FlociConfig): Effect.Effect<string> => |
| 260 | Effect.sync( |
| 261 | () => |
| 262 | // `|| undefined`: an empty env var (`ALCHEMY_FLOCI_IMAGE= cmd`) means |
| 263 | // "no override", not "run the image named ''" (an unrunnable docker ref). |
| 264 | (process.env.ALCHEMY_FLOCI_IMAGE || undefined) ?? |
| 265 | config?.image ?? |
| 266 | DEFAULT_FLOCI_IMAGE, |
| 267 | ); |
| 268 | |
| 269 | /** Whether a host TCP port is free (nothing is listening on it). */ |
| 270 | const isHostPortFree = (port: number): Effect.Effect<boolean> => |
| 271 | Effect.callback<boolean>((resume) => { |
| 272 | const server = net.createServer(); |
| 273 | server.once("error", (error: NodeJS.ErrnoException) => { |
| 274 | if (error.code === "EACCES") { |
| 275 | // Binding low ports (<1024) is privileged for THIS process, but |
| 276 | // Docker's helper can still publish them — fall back to a |
| 277 | // connect probe: a refused connection means nothing listens. |
| 278 | const socket = net.connect({ port, host: "127.0.0.1" }); |
| 279 | socket.once("connect", () => { |
| 280 | socket.destroy(); |
| 281 | resume(Effect.succeed(false)); |
| 282 | }); |
| 283 | socket.once("error", () => resume(Effect.succeed(true))); |
| 284 | return; |
| 285 | } |
| 286 | resume(Effect.succeed(false)); |
| 287 | }); |
| 288 | server.once("listening", () => |
| 289 | server.close(() => resume(Effect.succeed(true))), |
| 290 | ); |
| 291 | server.listen(port, "127.0.0.1"); |
| 292 | return Effect.sync(() => server.close()); |
| 293 | }); |
| 294 | |
| 295 | /** |
| 296 | * The host ports the named container publishes, or `undefined` when the |
| 297 | * container does not exist. |
| 298 | */ |
| 299 | const publishedPorts = ( |
| 300 | containerName: string, |
| 301 | ): Effect.Effect<Set<number> | undefined> => |
| 302 | docker([ |
| 303 | "inspect", |
| 304 | "--format", |
| 305 | "{{json .HostConfig.PortBindings}}", |
| 306 | containerName, |
| 307 | ]).pipe( |
| 308 | Effect.map(({ stdout }) => { |
| 309 | let bindings: Record<string, Array<{ HostPort?: string }> | null> | null; |
| 310 | try { |
| 311 | bindings = JSON.parse(stdout.trim()); |
| 312 | } catch { |
| 313 | // Racing a concurrent create/remove of the container can produce |
| 314 | // partial output — treat it as "unknown", not a crash. |
| 315 | return undefined; |
| 316 | } |
| 317 | const ports = new Set<number>(); |
| 318 | for (const spec of Object.values(bindings ?? {})) { |
| 319 | for (const bind of spec ?? []) { |
| 320 | const hostPort = Number(bind.HostPort); |
| 321 | if (Number.isFinite(hostPort)) ports.add(hostPort); |
| 322 | } |
| 323 | } |
| 324 | return ports; |
| 325 | }), |
| 326 | Effect.orElseSucceed(() => undefined), |
| 327 | ); |
| 328 | |
| 329 | /** |
| 330 | * The requested ports the managed container should publish: every port that |
| 331 | * is either already published by the container or still free on the host. |
| 332 | * Taken ports are skipped (with a warning only when the caller asked for |
| 333 | * them explicitly) so a busy host port never blocks the emulator from |
| 334 | * starting. |
| 335 | */ |
| 336 | const resolvePublishablePorts = Effect.fn(function* ( |
| 337 | requested: readonly number[], |
| 338 | published: ReadonlySet<number>, |
| 339 | onTaken?: (port: number) => Effect.Effect<void>, |
| 340 | ) { |
| 341 | const desired: number[] = []; |
| 342 | for (const port of requested) { |
| 343 | if (published.has(port) || (yield* isHostPortFree(port))) { |
| 344 | desired.push(port); |
| 345 | } else if (onTaken !== undefined) { |
| 346 | yield* onTaken(port); |
| 347 | } |
| 348 | } |
| 349 | return desired; |
| 350 | }); |
| 351 | |
| 352 | /** |
| 353 | * Ensure a floci emulator is serving and healthy, starting the managed |
| 354 | * container if needed. Idempotent and cheap when already up (one HTTP |
| 355 | * probe); see the module doc for the resolution order. |
| 356 | */ |
| 357 | /** |
| 358 | * One `ensureFloci` at a time per process. Callers memoize per stack |
| 359 | * build, but separate builds (concurrent tests, parallel dev commands) can |
| 360 | * ensure at the same moment: when the managed container needs recreating, |
| 361 | * two callers would both `docker rm -f` it ("removal of container … is |
| 362 | * already in progress") and then race `docker run` on the name. Under the |
| 363 | * mutex the second caller observes the container the first one started. |
| 364 | */ |
| 365 | const ensureMutex = Semaphore.makeUnsafe(1); |
| 366 | |
| 367 | export const ensureFloci = ( |
| 368 | config?: FlociConfig, |
| 369 | ): Effect.Effect<FlociInstance, FlociError> => |
| 370 | ensureMutex.withPermits(1)( |
| 371 | // The mutex serializes ensures within THIS process; across processes |
| 372 | // (a test runner and a dev CLI booting together) the recreate path can |
| 373 | // still race — one process's `rm -f` can delete the container the |
| 374 | // other just created, and the loser's `docker run` fails (sometimes |
| 375 | // with an empty stderr). Re-running the whole ensure converges: the |
| 376 | // retry re-observes whatever the winner left serving and reuses it. |
| 377 | ensureFlociUnsynchronized(config).pipe( |
| 378 | Effect.retry({ |
| 379 | schedule: Schedule.spaced("2 seconds"), |
| 380 | times: 3, |
| 381 | }), |
| 382 | ), |
| 383 | ); |
| 384 | |
| 385 | const ensureFlociUnsynchronized = ( |
| 386 | config?: FlociConfig, |
| 387 | ): Effect.Effect<FlociInstance, FlociError> => |
| 388 | Effect.gen(function* () { |
| 389 | const port = config?.port ?? DEFAULT_FLOCI_PORT; |
| 390 | const endpoint = `http://localhost:${port}`; |
| 391 | const containerName = config?.containerName ?? DEFAULT_CONTAINER_NAME; |
| 392 | const published = yield* publishedPorts(containerName); |
| 393 | const elbPorts = yield* resolvePublishablePorts( |
| 394 | config?.elbListenerPorts ?? DEFAULT_ELB_LISTENER_PORTS, |
| 395 | published ?? new Set(), |
| 396 | // Default 80/443 are opportunistic (most stacks have no ELB). A taken |
| 397 | // privileged port is the common case on a developer machine, not a |
| 398 | // problem — warn only when the caller asked for those ports. |
| 399 | config?.elbListenerPorts === undefined |
| 400 | ? undefined |
| 401 | : (port) => |
| 402 | Effect.logWarning( |
| 403 | `floci: host port ${port} is taken — emulated load balancer listeners on ${port} will not be reachable from the host`, |
| 404 | ), |
| 405 | ); |
| 406 | const edgePorts = yield* resolvePublishablePorts( |
| 407 | config?.cloudfrontEdgePorts ?? DEFAULT_CLOUDFRONT_EDGE_PORTS, |
| 408 | published ?? new Set(), |
| 409 | ); |
| 410 | |
| 411 | // Converge the managed container's EXPLICITLY-requested ports and its |
| 412 | // image: a container missing ports the caller configured can't serve |
| 413 | // them, and one running a superseded emulator image (a floci release |
| 414 | // bump, or a switch of `ALCHEMY_FLOCI_IMAGE`) would silently miss |
| 415 | // emulator features the providers now rely on — so either is recreated |
| 416 | // (emulated state is ephemeral by design; the next apply reconciles |
| 417 | // it). The OPPORTUNISTIC defaults (80/443, the CloudFront edge range) |
| 418 | // deliberately do NOT trigger recreation: which of them resolve as |
| 419 | // publishable depends on what happens to be free in the observing |
| 420 | // process, so keying recreation on them makes concurrent consumers |
| 421 | // (a test run and a dev CLI) recreate each other's container forever. |
| 422 | // An UNMANAGED server (a dev-mode JVM, a hand-run container) is never |
| 423 | // touched. To keep running a hand-built image under the managed name, |
| 424 | // keep `ALCHEMY_FLOCI_IMAGE` set — the resolution order is the |
| 425 | // contract. |
| 426 | const publishPorts = [...elbPorts, ...edgePorts]; |
| 427 | const requiredPorts = [ |
| 428 | ...(config?.elbListenerPorts ?? []).filter((p) => elbPorts.includes(p)), |
| 429 | ...(config?.cloudfrontEdgePorts ?? []).filter((p) => |
| 430 | edgePorts.includes(p), |
| 431 | ), |
| 432 | ]; |
| 433 | const image = yield* resolveImage(config); |
| 434 | const runningImage = |
| 435 | published !== undefined |
| 436 | ? yield* docker([ |
| 437 | "inspect", |
| 438 | "--format", |
| 439 | "{{.Config.Image}}", |
| 440 | containerName, |
| 441 | ]).pipe( |
| 442 | Effect.map(({ stdout }) => stdout.trim() || undefined), |
| 443 | Effect.orElseSucceed(() => undefined), |
| 444 | ) |
| 445 | : undefined; |
| 446 | const needsRecreate = |
| 447 | published !== undefined && |
| 448 | (requiredPorts.some((p) => !published.has(p)) || |
| 449 | (runningImage !== undefined && runningImage !== image)); |
| 450 | |
| 451 | if (yield* isServing(endpoint)) { |
| 452 | if (!needsRecreate) { |
| 453 | // Something may be serving but still starting — the readiness |
| 454 | // poll tolerates transient unhealthiness. |
| 455 | yield* checkHealth(endpoint).pipe( |
| 456 | Effect.catch(() => waitForHealth(endpoint, config)), |
| 457 | ); |
| 458 | yield* syncCaBundle(endpoint); |
| 459 | return { endpoint, managed: false }; |
| 460 | } |
| 461 | // Only recreate when the server IS our managed container (otherwise |
| 462 | // removing the container would not free the endpoint anyway). |
| 463 | yield* Effect.logInfo( |
| 464 | `floci: recreating ${containerName} (image ${runningImage} -> ${image}, ports [${publishPorts.join(", ")}])`, |
| 465 | ); |
| 466 | yield* docker(["rm", "-f", containerName]); |
| 467 | } else if (needsRecreate) { |
| 468 | yield* Effect.logInfo( |
| 469 | `floci: recreating ${containerName} (image ${runningImage} -> ${image}, ports [${publishPorts.join(", ")}])`, |
| 470 | ); |
| 471 | yield* docker(["rm", "-f", containerName]); |
| 472 | } else { |
| 473 | // Revive a stopped container from a previous session, else run fresh. |
| 474 | const started = yield* docker(["start", containerName]).pipe( |
| 475 | Effect.map(() => true), |
| 476 | Effect.orElseSucceed(() => false), |
| 477 | ); |
| 478 | if (started) { |
| 479 | yield* waitForHealth(endpoint, config); |
| 480 | yield* syncCaBundle(endpoint); |
| 481 | return { endpoint, managed: true, containerName }; |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | yield* Effect.tryPromise({ |
| 486 | try: () => fs.mkdir(FLOCI_TLS_DIR, { recursive: true }), |
| 487 | catch: (cause) => new FlociError({ message: "tls dir", cause }), |
| 488 | }).pipe(Effect.ignore); |
| 489 | const args = [ |
| 490 | "run", |
| 491 | "-d", |
| 492 | "--name", |
| 493 | containerName, |
| 494 | "-p", |
| 495 | `${port}:4566`, |
| 496 | // Docker Desktop injects `host.docker.internal` automatically; native |
| 497 | // Linux Docker does not. The emulator needs it to reach a dev server |
| 498 | // running on the developer's machine (a CloudFront origin pointed at |
| 499 | // `localhost:<port>`, for one). |
| 500 | "--add-host", |
| 501 | "host.docker.internal:host-gateway", |
| 502 | ...publishPorts.flatMap((p) => ["-p", `${p}:${p}`]), |
| 503 | // The emulator can only hand a distribution a port the container |
| 504 | // published, so it gets the resolved list rather than its own default |
| 505 | // range — and the feature is switched off outright when nothing could |
| 506 | // be published, so it never binds a port nothing can reach. |
| 507 | "-e", |
| 508 | edgePorts.length > 0 |
| 509 | ? `FLOCI_SERVICES_CLOUDFRONT_EDGE_PORTS=${edgePorts.join(",")}` |
| 510 | : "FLOCI_SERVICES_CLOUDFRONT_EDGE_PORTS_ENABLED=false", |
| 511 | ...(config?.dockerSocket === false |
| 512 | ? [] |
| 513 | : ["-v", "/var/run/docker.sock:/var/run/docker.sock", "-u", "root"]), |
| 514 | ...(config?.storageDir !== undefined |
| 515 | ? [ |
| 516 | "-v", |
| 517 | `${config.storageDir}:/app/data`, |
| 518 | "-e", |
| 519 | "FLOCI_STORAGE_MODE=hybrid", |
| 520 | ] |
| 521 | : // No full storage mount: still persist the TLS dir so the |
| 522 | // self-signed CA is stable across container recreations (floci |
| 523 | // reuses existing cert files). |
| 524 | ["-v", `${FLOCI_TLS_DIR}:/app/data/tls`]), |
| 525 | ...Object.entries(config?.env ?? {}).flatMap(([key, value]) => [ |
| 526 | "-e", |
| 527 | `${key}=${value}`, |
| 528 | ]), |
| 529 | image, |
| 530 | ]; |
| 531 | yield* docker(args).pipe( |
| 532 | // A concurrent ensureFloci (e.g. the dev sidecar racing the CLI's |
| 533 | // exec child on a machine where floci is not yet running) may have |
| 534 | // created the container between our checks and this run. Docker |
| 535 | // reports that as a name conflict — but not reliably: the losing |
| 536 | // `docker run` has been observed exiting non-zero with an EMPTY |
| 537 | // stderr right after a Docker Desktop restart, so the error text |
| 538 | // cannot be trusted. Ask Docker whether the container exists now and |
| 539 | // reuse it if so; only a run that left nothing behind is a failure. |
| 540 | Effect.catch((error) => |
| 541 | docker(["inspect", "--format", "{{.Id}}", containerName]).pipe( |
| 542 | Effect.flatMap(({ stdout }) => |
| 543 | stdout.trim() ? Effect.void : Effect.fail(error), |
| 544 | ), |
| 545 | Effect.catch(() => Effect.fail(error)), |
| 546 | ), |
| 547 | ), |
| 548 | ); |
| 549 | |
| 550 | yield* waitForHealth(endpoint, config); |
| 551 | yield* syncCaBundle(endpoint); |
| 552 | return { endpoint, managed: true, containerName }; |
| 553 | }); |
| 554 | |
| 555 | const waitForHealth = (endpoint: string, config?: FlociConfig) => |
| 556 | checkHealth(endpoint).pipe( |
| 557 | Effect.retry({ |
| 558 | schedule: Schedule.spaced("1 second"), |
| 559 | times: Math.max( |
| 560 | 1, |
| 561 | Math.ceil( |
| 562 | Duration.toSeconds( |
| 563 | // 300s: a freshly (re)started Docker daemon can take minutes to |
| 564 | // create the first container from the 1.3GB image; giving up |
| 565 | // early parks `alchemy dev` on a FlociError until the next file |
| 566 | // edit, which reads as a wedge. |
| 567 | Duration.fromInputUnsafe(config?.readinessTimeout ?? "300 seconds"), |
| 568 | ), |
| 569 | ), |
| 570 | ), |
| 571 | }), |
| 572 | Effect.mapError( |
| 573 | (error) => |
| 574 | new FlociError({ |
| 575 | message: |
| 576 | `floci did not become healthy at ${endpoint}: ${error.message}. ` + |
| 577 | "Check `docker logs " + |
| 578 | (config?.containerName ?? DEFAULT_CONTAINER_NAME) + |
| 579 | "`.", |
| 580 | cause: error, |
| 581 | }), |
| 582 | ), |
| 583 | ); |
| 584 | |
| 585 | /** Stop (not remove) the managed container. */ |
| 586 | export const stopFloci = ( |
| 587 | containerName: string = DEFAULT_CONTAINER_NAME, |
| 588 | ): Effect.Effect<void, FlociError> => |
| 589 | Effect.asVoid(docker(["stop", containerName])); |
| 590 | |
| 591 | /** Remove the managed container (and its in-memory state). */ |
| 592 | export const destroyFloci = ( |
| 593 | containerName: string = DEFAULT_CONTAINER_NAME, |
| 594 | ): Effect.Effect<void, FlociError> => |
| 595 | Effect.asVoid(docker(["rm", "-f", containerName])); |
| 596 | |
| 597 | /** Tail of the managed container's logs. */ |
| 598 | export const flociLogs = ( |
| 599 | containerName: string = DEFAULT_CONTAINER_NAME, |
| 600 | lines = 200, |
| 601 | ): Effect.Effect<string, FlociError> => |
| 602 | Effect.map( |
| 603 | docker(["logs", "--tail", String(lines), containerName]), |
| 604 | ({ stdout, stderr }) => stdout + stderr, |
| 605 | ); |