| 1 | import * as Docker from "@/Docker"; |
| 2 | import * as Provider from "@/Provider"; |
| 3 | import { |
| 4 | inMemoryState, |
| 5 | isResourceState, |
| 6 | State, |
| 7 | type ResourceState, |
| 8 | } from "@/State"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import { describe, expect } from "alchemy-test"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Redacted from "effect/Redacted"; |
| 13 | import { findAvailablePort } from "./Runtime.ts"; |
| 14 | |
| 15 | const { test } = Test.make({ |
| 16 | providers: Docker.providers(), |
| 17 | state: inMemoryState(), |
| 18 | adopt: true, |
| 19 | }); |
| 20 | |
| 21 | test.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 | |
| 48 | test.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 | |
| 83 | test.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 | |
| 120 | describe( |
| 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 | ); |