1import * as Docker from "@/Docker";
2import * as Provider from "@/Provider";
3import {
4 inMemoryState,
5 isResourceState,
6 State,
7 type ResourceState,
8} from "@/State";
9import * as Test from "@/Test/Alchemy";
10import { describe, expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import { findAvailablePort } from "./Runtime.ts";
14
15const { test } = Test.make({
16 providers: Docker.providers(),
17 state: inMemoryState(),
18 adopt: true,
19});
20
21test.provider(
22 "diff replaces a container when its image changes",
23 () =>
24 Effect.gen(function* () {
25 const containerProvider = yield* Provider.findProvider(Docker.Container);
26 const containerDiff = yield* containerProvider.diff!({
27 id: "web",
28 fqn: "web",
29 instanceId: "instance",
30 olds: { name: "web", image: "nginx:alpine" },
31 news: { name: "web", image: "nginx:1.27-alpine" },
32 oldBindings: [],
33 newBindings: [],
34 output: {
35 id: "web",
36 name: "web",
37 status: "created",
38 createdAt: 0,
39 imageRef: "nginx:alpine",
40 ports: {},
41 },
42 });
43 expect(containerDiff).toEqual({ action: "replace", deleteFirst: true });
44 }),
45 { tags: ["provider:docker", "provider:docker:container", "local"] },
46);
47
48test.provider(
49 "diff replaces a container when its Docker context changes",
50 () =>
51 Effect.gen(function* () {
52 const containerProvider = yield* Provider.findProvider(Docker.Container);
53 const containerDiff = yield* containerProvider.diff!({
54 id: "web",
55 fqn: "web",
56 instanceId: "instance",
57 olds: {
58 name: "web",
59 image: "nginx:alpine",
60 context: "default",
61 },
62 news: {
63 name: "web",
64 image: "nginx:alpine",
65 context: "remote-build",
66 },
67 oldBindings: [],
68 newBindings: [],
69 output: {
70 id: "web",
71 name: "web",
72 status: "created",
73 createdAt: 0,
74 imageRef: "nginx:alpine",
75 ports: {},
76 },
77 });
78 expect(containerDiff).toEqual({ action: "replace", deleteFirst: true });
79 }),
80 { tags: ["provider:docker", "provider:docker:container", "local"] },
81);
82
83test.provider(
84 "diff replaces a container when its labels or stop timeout change",
85 () =>
86 Effect.gen(function* () {
87 const containerProvider = yield* Provider.findProvider(Docker.Container);
88 const containerDiff = yield* containerProvider.diff!({
89 id: "web",
90 fqn: "web",
91 instanceId: "instance",
92 olds: {
93 name: "web",
94 image: "nginx:alpine",
95 labels: { "traefik.enable": "true" },
96 stopTimeout: "10 seconds",
97 },
98 news: {
99 name: "web",
100 image: "nginx:alpine",
101 labels: { "traefik.enable": "false" },
102 stopTimeout: "30 seconds",
103 },
104 oldBindings: [],
105 newBindings: [],
106 output: {
107 id: "web",
108 name: "web",
109 status: "created",
110 createdAt: 0,
111 imageRef: "nginx:alpine",
112 ports: {},
113 },
114 });
115 expect(containerDiff).toEqual({ action: "replace", deleteFirst: true });
116 }),
117 { tags: ["provider:docker", "provider:docker:container", "local"] },
118);
119
120describe(
121 "Docker.Container",
122 {
123 tags: ["provider:docker", "provider:docker:container", "local"],
124 concurrent: false,
125 },
126 () => {
127 test.provider("publishes and inspects bound host ports", (stack) =>
128 Effect.gen(function* () {
129 const docker = yield* Docker.Docker;
130 const hostPort = yield* findAvailablePort();
131 // No explicit name: rely on the engine-generated physical name.
132 const container = yield* stack.deploy(
133 Docker.Container("nginx-container", {
134 image: "nginx:alpine",
135 ports: [{ external: hostPort, internal: 80 }],
136 start: true,
137 }),
138 );
139 expect(container.name.length).toBeGreaterThan(0);
140 expect(container.status).toBe("running");
141
142 const runtime = yield* docker.container.inspect(container.name);
143 // Docker always publishes the IPv4 (`0.0.0.0`) binding; whether it also
144 // adds an IPv6 (`::`) binding depends on the daemon's IPv6 config, so
145 // assert the guaranteed IPv4 mapping is present rather than requiring
146 // both.
147 expect(runtime?.NetworkSettings.Ports?.["80/tcp"]).toEqual(
148 expect.arrayContaining([
149 { HostIp: "0.0.0.0", HostPort: `${hostPort}` },
150 ]),
151 );
152 }),
153 );
154
155 test.provider("creates a stopped container when start is false", (stack) =>
156 Effect.gen(function* () {
157 const container = yield* stack.deploy(
158 Docker.Container("stopped-container", {
159 image: "nginx:alpine",
160 start: false,
161 }),
162 );
163 expect(container.status).toBe("created");
164 expect(container.imageRef).toBe("nginx:alpine");
165 }),
166 );
167
168 test.provider("applies container labels and a stop timeout", (stack) =>
169 Effect.gen(function* () {
170 const docker = yield* Docker.Docker;
171 const container = yield* stack.deploy(
172 Docker.Container("traefik-container", {
173 image: "nginx:alpine",
174 labels: {
175 "traefik.enable": "true",
176 "traefik.http.services.web.loadbalancer.server.port": "80",
177 },
178 stopTimeout: "10 minutes",
179 start: true,
180 }),
181 );
182
183 const info = yield* docker.container.inspect(container.name);
184 expect(info.Config.Labels).toEqual(
185 expect.objectContaining({
186 "traefik.enable": "true",
187 "traefik.http.services.web.loadbalancer.server.port": "80",
188 }),
189 );
190 expect(info.Config.StopTimeout).toBe(600);
191 }),
192 );
193
194 test.provider(
195 "updates network aliases without replacing the container",
196 (stack) =>
197 Effect.gen(function* () {
198 const docker = yield* Docker.Docker;
199 // No explicit names: the engine generates stable physical names that
200 // stay constant across the two deploys (same instance id).
201 const deployWithAlias = (alias: string) =>
202 stack.deploy(
203 Effect.gen(function* () {
204 const network = yield* Docker.Network("alias-network");
205 const container = yield* Docker.Container("alias-container", {
206 image: "nginx:alpine",
207 networks: [{ name: network.name, aliases: [alias] }],
208 });
209 return { container, network };
210 }),
211 );
212
213 const first = yield* deployWithAlias("old-alias");
214 const second = yield* deployWithAlias("new-alias");
215 expect(second.container.id).toBe(first.container.id);
216
217 const info = yield* docker.container.inspect(second.container.name);
218 const aliases =
219 info?.NetworkSettings.Networks?.[second.network.name]?.Aliases ??
220 [];
221 expect(aliases).toContain("new-alias");
222 expect(aliases).not.toContain("old-alias");
223 }),
224 { tags: ["provider:docker:network"] },
225 );
226
227 test.provider(
228 "replaces the container when published ports change",
229 (stack) =>
230 Effect.gen(function* () {
231 const firstPort = yield* findAvailablePort();
232 const secondPort = yield* findAvailablePort();
233 const first = yield* stack.deploy(
234 Docker.Container("ported-container", {
235 image: "nginx:alpine",
236 ports: [{ external: firstPort, internal: 80 }],
237 }),
238 );
239 const second = yield* stack.deploy(
240 Docker.Container("ported-container", {
241 image: "nginx:alpine",
242 ports: [{ external: secondPort, internal: 80 }],
243 }),
244 );
245 expect(second.id).not.toBe(first.id);
246 expect(second.ports["80/tcp"]).toBe(secondPort);
247 }),
248 );
249
250 // Rewrite the container's persisted row into the wedged shape an
251 // interrupted deploy leaves behind: `creating`, no attributes, and the
252 // Output-valued `image` prop lost in the round-trip (#736).
253 const wedgeContainerRow = (stack: {
254 readonly name: string;
255 readonly stage: string;
256 }) =>
257 Effect.gen(function* () {
258 const state = yield* yield* State;
259 const stage = stack.stage;
260 const fqns = yield* state.list({ stack: stack.name, stage });
261 const rows = yield* Effect.forEach(fqns, (fqn) =>
262 state
263 .get({ stack: stack.name, stage, fqn })
264 .pipe(Effect.map((row) => ({ fqn, row }))),
265 );
266 const wedged = rows.find(
267 (r): r is { fqn: string; row: ResourceState } =>
268 isResourceState(r.row) && r.row.resourceType === "Docker.Container",
269 );
270 if (!wedged) {
271 return yield* Effect.die(
272 new Error("no Docker.Container state row found after deploy"),
273 );
274 }
275 yield* state.set({
276 stack: stack.name,
277 stage,
278 fqn: wedged.fqn,
279 value: {
280 ...wedged.row,
281 status: "creating",
282 attr: undefined,
283 props: { ...wedged.row.props, image: undefined },
284 },
285 });
286 });
287
288 test.provider(
289 "read recovers a creating-state container whose image prop was lost (#736)",
290 (stack) =>
291 Effect.gen(function* () {
292 yield* stack.destroy();
293 const docker = yield* Docker.Docker;
294
295 const deployContainer = () =>
296 stack.deploy(
297 // No explicit name: the engine-generated physical name is stable
298 // across both deploys, so `read` can find the live container.
299 Docker.Container("read-recovery-container", {
300 image: "nginx:alpine",
301 start: false,
302 }),
303 );
304
305 const created = yield* deployContainer();
306 // Safety net: remove the container if the test dies mid-way.
307 yield* Effect.addFinalizer(() =>
308 docker.container.remove(created.name, true).pipe(Effect.ignore),
309 );
310
311 yield* wedgeContainerRow(stack);
312
313 // Before the fix this crashed in `read` with
314 // `TypeError: undefined is not an object (evaluating 'image.imageRef')`.
315 const recovered = yield* deployContainer();
316 // Same container id — read/reconcile converged on the existing
317 // container instead of creating a duplicate.
318 expect(recovered.id).toBe(created.id);
319 expect(recovered.imageRef).toBe("nginx:alpine");
320
321 yield* stack.destroy();
322 }),
323 { timeout: 240_000 },
324 );
325
326 test.provider(
327 "diff recreates a creating-state container that vanished after its image prop was lost (#736)",
328 (stack) =>
329 Effect.gen(function* () {
330 yield* stack.destroy();
331 const docker = yield* Docker.Docker;
332
333 const deployContainer = () =>
334 stack.deploy(
335 Docker.Container("diff-recovery-container", {
336 image: "nginx:alpine",
337 start: false,
338 }),
339 );
340
341 const created = yield* deployContainer();
342 // Safety net: remove the container if the test dies mid-way.
343 yield* Effect.addFinalizer(() =>
344 docker.container.remove(created.name, true).pipe(Effect.ignore),
345 );
346
347 yield* wedgeContainerRow(stack);
348 // Remove the container out-of-band so recovery `read` misses and the
349 // engine falls through to `diff` with the junk creating-row props.
350 yield* docker.container.remove(created.name, true);
351
352 // Before the fix this crashed in `diff` with
353 // `TypeError: undefined is not an object (evaluating 'image.imageRef')`.
354 const recovered = yield* deployContainer();
355 expect(recovered.id).not.toBe(created.id);
356 expect(recovered.imageRef).toBe("nginx:alpine");
357 expect(recovered.status).toBe("created");
358
359 yield* stack.destroy();
360 }),
361 { timeout: 240_000 },
362 );
363
364 test.provider(
365 "passes environment values into the container (#1117)",
366 (stack) =>
367 Effect.gen(function* () {
368 const docker = yield* Docker.Docker;
369 // Environment values ride the Docker CLI's process environment (the
370 // create args carry name-only `--env KEY` flags to keep secrets off
371 // the command line) — before the fix every entry resolved empty.
372 const container = yield* stack.deploy(
373 Docker.Container("env-container", {
374 image: "nginx:alpine",
375 environment: {
376 PLAIN_VALUE: "plain-value",
377 SECRET_VALUE: Redacted.make("secret-value"),
378 },
379 start: false,
380 }),
381 );
382
383 const info = yield* docker.container.inspect(container.name);
384 expect(info.Config.Env).toEqual(
385 expect.arrayContaining([
386 "PLAIN_VALUE=plain-value",
387 "SECRET_VALUE=secret-value",
388 ]),
389 );
390 }),
391 );
392
393 test.provider(
394 "applies a healthcheck with unit-suffixed durations",
395 (stack) =>
396 Effect.gen(function* () {
397 const docker = yield* Docker.Docker;
398 // `normalizeDuration` used to emit a bare nanosecond count (e.g.
399 // `1000000000`), which `docker container create` rejects with "missing
400 // unit in duration" — so this deploy would fail outright before the fix.
401 const container = yield* stack.deploy(
402 Docker.Container("healthcheck-container", {
403 image: "nginx:alpine",
404 healthcheck: {
405 cmd: "true",
406 interval: "1 second",
407 timeout: "2 seconds",
408 retries: 3,
409 startPeriod: "1 second",
410 },
411 start: true,
412 }),
413 );
414 expect(container.status).toBe("running");
415
416 // Docker reports the configured durations back in nanoseconds — assert
417 // they round-tripped rather than being dropped or truncated.
418 const info = yield* docker.container.inspect(container.name);
419 const health = info?.Config.Healthcheck;
420 expect(health?.Interval).toBe(1_000_000_000);
421 expect(health?.Timeout).toBe(2_000_000_000);
422 expect(health?.Retries).toBe(3);
423 expect(health?.StartPeriod).toBe(1_000_000_000);
424 }),
425 );
426 test.provider(
427 "reports the host port Docker assigned to a random publish (#1388)",
428 (stack) =>
429 Effect.gen(function* () {
430 const docker = yield* Docker.Docker;
431 const container = yield* stack.deploy(
432 Docker.Container("random-port-container", {
433 image: "nginx:alpine",
434 // `external: 0` = "any free host port".
435 ports: [{ external: 0, internal: 80 }],
436 start: true,
437 }),
438 );
439
440 // Before the fix this was 0: the create arg asked for host port 0
441 // literally, and the requested binding was then reported over the
442 // assigned one.
443 const assigned = container.ports["80/tcp"];
444 expect(assigned).toBeGreaterThan(0);
445
446 // …and it is the port the container is actually published on.
447 const runtime = yield* docker.container.inspect(container.name);
448 expect(runtime?.NetworkSettings.Ports?.["80/tcp"]).toEqual(
449 expect.arrayContaining([
450 expect.objectContaining({ HostPort: `${assigned}` }),
451 ]),
452 );
453 }),
454 );
455
456 test.provider("forwards extra hosts to the container (#1387)", (stack) =>
457 Effect.gen(function* () {
458 const docker = yield* Docker.Docker;
459 const container = yield* stack.deploy(
460 Docker.Container("extra-hosts-container", {
461 image: "nginx:alpine",
462 extraHosts: [
463 "host.docker.internal:host-gateway",
464 "db.internal:10.1.2.3",
465 ],
466 start: true,
467 }),
468 );
469
470 const runtime = yield* docker.container.inspect(container.name);
471 expect(runtime?.HostConfig.ExtraHosts).toEqual(
472 expect.arrayContaining([
473 "host.docker.internal:host-gateway",
474 "db.internal:10.1.2.3",
475 ]),
476 );
477 }),
478 );
479
480 test.provider(
481 "disconnects only the networks alchemy connected (#1386)",
482 (stack) =>
483 Effect.gen(function* () {
484 const docker = yield* Docker.Docker;
485 const foreign = "alchemy-test-foreign-network";
486
487 const deploy = (attach: boolean) =>
488 stack.deploy(
489 Effect.gen(function* () {
490 const network = yield* Docker.Network("managed-network");
491 const container = yield* Docker.Container("managed-container", {
492 image: "nginx:alpine",
493 networks: attach ? [{ name: network.name }] : [],
494 });
495 return { container, network };
496 }),
497 );
498
499 const first = yield* deploy(true);
500
501 // A network alchemy never connected the container to — the case a
502 // user, compose file, or another tool creates.
503 yield* docker.network
504 .create({ name: foreign, driver: "bridge" })
505 .pipe(Effect.ignore);
506 yield* Effect.addFinalizer(() =>
507 docker.network.remove(foreign).pipe(Effect.ignore),
508 );
509 yield* docker.network.connect({
510 network: foreign,
511 container: first.container.name,
512 });
513
514 // Drop the managed network from the desired state.
515 const second = yield* deploy(false);
516 expect(second.container.id).toBe(first.container.id);
517
518 const info = yield* docker.container.inspect(second.container.name);
519 const attached = Object.keys(info?.NetworkSettings.Networks ?? {});
520 // Ours goes…
521 expect(attached).not.toContain(first.network.name);
522 // …the foreign one and Docker's own default stay. Before the fix the
523 // reconciler swept every live network and tore off both.
524 expect(attached).toContain(foreign);
525 expect(attached).toContain("bridge");
526 }),
527 { tags: ["provider:docker:network"], timeout: 240_000 },
528 );
529 },
530);