1/**
2 * Hot reload for user-supplied Dockerfile/context Cloudflare Containers
3 * under `alchemy dev`.
4 *
5 * These files are NOT imported by the stack, so `bun --watch` never re-runs
6 * the exec child for them — the reload rides two engine pieces this suite
7 * pins:
8 *
9 * 1. the LOCAL provider's diff recomputes the image hash fresh on every
10 * plan (the RPC sidecar's ArtifactStore outlives runs, so an unevicted
11 * memo would compare the FIRST run's hash forever and report noop);
12 * 2. the local worker runner fs-watches each Build-variant container's
13 * context and restarts the instance — which IS the docker rebuild —
14 * when the content fingerprint changes, with no deploy in between.
15 *
16 * Requires Docker; skipped when the daemon is unavailable.
17 */
18import * as Cloudflare from "@/Cloudflare";
19import * as Test from "@/Test/Alchemy";
20import { describe, expect } from "alchemy-test";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
24import { MinimumLogLevel } from "effect/References";
25import * as Schedule from "effect/Schedule";
26import * as HttpClient from "effect/http/HttpClient";
27import { spawnSync } from "node:child_process";
28import {
29 RELOAD_CONTAINER_PORT,
30 RELOAD_CONTEXT_DIR,
31} from "./fixtures/reload/container.ts";
32import ReloadContainerWorker from "./fixtures/reload/worker.ts";
33
34const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
35
36const logLevel = Effect.provideService(
37 MinimumLogLevel,
38 process.env.DEBUG ? "Debug" : "Info",
39);
40
41const dockerAvailable = (() => {
42 try {
43 return (
44 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 })
45 .status === 0
46 );
47 } catch {
48 return false;
49 }
50})();
51
52const dockerfile = (marker: string) =>
53 `FROM busybox:stable
54COPY index.html /www/index.html
55ENV BAKED_MARKER=${marker}
56EXPOSE ${RELOAD_CONTAINER_PORT}
57CMD ["sh", "-c", "echo -n \\"$BAKED_MARKER\\" > /www/baked.txt && exec httpd -f -p ${RELOAD_CONTAINER_PORT} -h /www"]
58`;
59
60/** Poll the worker's proxy route until the file body matches. */
61const pollText = Effect.fn(function* (options: {
62 url: string;
63 path: string;
64 expected: string;
65 times?: number;
66}) {
67 const client = yield* HttpClient.HttpClient;
68 const body = yield* client.get(`${options.url}${options.path}`).pipe(
69 Effect.flatMap((response) => response.text),
70 Effect.retry({
71 while: (): boolean => true,
72 schedule: Schedule.spaced("2 seconds"),
73 times: options.times ?? 90,
74 }),
75 Effect.repeat({
76 schedule: Schedule.spaced("2 seconds"),
77 until: (b): boolean => b.trim() === options.expected,
78 times: options.times ?? 90,
79 }),
80 );
81 expect(body.trim()).toBe(options.expected);
82});
83
84describe.sequential(
85 "LocalContainerReload",
86 {
87 tags: [
88 "provider:cloudflare",
89 "provider:cloudflare:container",
90 "provider:cloudflare:worker",
91 "local",
92 ],
93 },
94 () => {
95 test.provider.skipIf(!dockerAvailable)(
96 "context/Dockerfile edits rebuild the running container — with and without a deploy",
97 (stack) =>
98 Effect.gen(function* () {
99 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;
101
102 yield* stack.destroy();
103
104 // Materialize the build context at the fixture's fixed path.
105 yield* fs.makeDirectory(RELOAD_CONTEXT_DIR, { recursive: true });
106 yield* fs.writeFileString(
107 path.join(RELOAD_CONTEXT_DIR, "Dockerfile"),
108 dockerfile("baked-v1"),
109 );
110 yield* fs.writeFileString(
111 path.join(RELOAD_CONTEXT_DIR, "index.html"),
112 "content-v1\n",
113 );
114
115 const deploy = Effect.gen(function* () {
116 const worker = yield* ReloadContainerWorker;
117 return { worker };
118 });
119
120 const first = yield* stack.deploy(deploy);
121 expect(first.worker.url).toMatch(/^http:\/\/localhost:\d+/);
122 const url = first.worker.url!;
123
124 // First contact builds the image and boots the container.
125 yield* pollText({ url, path: "/index.html", expected: "content-v1" });
126 yield* pollText({ url, path: "/baked.txt", expected: "baked-v1" });
127
128 // ── 1. content change + REDEPLOY: the diff must see it (regression:
129 // the sidecar-lifetime artifact memo made every later plan compare
130 // the first run's hash and noop forever) ──
131 yield* fs.writeFileString(
132 path.join(RELOAD_CONTEXT_DIR, "index.html"),
133 "content-v2\n",
134 );
135 yield* stack.deploy(deploy);
136 yield* pollText({ url, path: "/index.html", expected: "content-v2" });
137
138 // ── 2. content change, NO deploy: the worker runner's context
139 // watcher must rebuild + restart on its own ──
140 yield* fs.writeFileString(
141 path.join(RELOAD_CONTEXT_DIR, "index.html"),
142 "content-v3\n",
143 );
144 yield* pollText({ url, path: "/index.html", expected: "content-v3" });
145
146 // ── 3. the DOCKERFILE itself, NO deploy ──
147 yield* fs.writeFileString(
148 path.join(RELOAD_CONTEXT_DIR, "Dockerfile"),
149 dockerfile("baked-v2"),
150 );
151 yield* pollText({ url, path: "/baked.txt", expected: "baked-v2" });
152 // The content file survived the Dockerfile rebuild.
153 yield* pollText({ url, path: "/index.html", expected: "content-v3" });
154
155 yield* stack.destroy();
156 }).pipe(logLevel),
157 { timeout: 600_000 },
158 );
159 },
160);