1/**
2 * Mode-scoped local dev for ECS: `Test.make({ dev: true })` routes the
3 * dualized `ECS.Cluster` / `ECS.Task` (and `ECS.TaskDefinition` /
4 * `ECS.Service`) to the floci emulator, where tasks run as REAL docker
5 * containers.
6 *
7 * Proof structure (mirrors DataPlane.local.test.ts):
8 * - attrs carry the dummy account (`:000000000000:` ARNs) and the
9 * `.localhost:` ECR repository URI — the live cloud can never mint
10 * these;
11 * - persisted state rows are stamped `providerMode: "local"`;
12 * - out-of-band raw HTTP against the emulator gateway runs the deployed
13 * task definition (RunTask) and observes it RUNNING as a real docker
14 * container (`docker ps` shows `floci-ecs-<taskId>-…`);
15 * - the container round-trips HTTP: floci publishes the LITERAL host
16 * port for bridge-mode port mappings (awsvpc tasks are expose-only
17 * when floci itself runs in a container), so the fixture uses
18 * `networkMode: "bridge"` and the test fetches
19 * `http://localhost:<port>` for the marker;
20 * - `stack.destroy()` drains the cluster (the running task is stopped
21 * and its container removed) and deletes the task definition, ECR
22 * repository, log group, and IAM roles — all verified gone.
23 *
24 * Requires Docker (floci runs as a container); skipped when unavailable.
25 */
26import * as AWS from "@/AWS";
27import { State, type ResourceState } from "@/State";
28import { Stack } from "@/Stack";
29import * as Test from "@/Test/Alchemy";
30import { describe, expect } from "alchemy-test";
31import * as Data from "effect/Data";
32import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
34import * as Path from "effect/Path";
35import * as Schedule from "effect/Schedule";
36import * as HttpClient from "effect/http/HttpClient";
37import { spawnSync } from "node:child_process";
38import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
39import EcsDevMainTask from "./fixtures/ecs-dev/main-task.ts";
40import { dockerAvailable, rawAwsJson } from "./fixtures/raw.ts";
41
42const { test } = Test.make({ providers: AWS.providers(), dev: true });
43
44const contextDir = `${import.meta.dirname}/fixtures/ecs-dev`;
45
46/** Must match the port baked into fixtures/ecs-dev/Dockerfile. */
47const CONTEXT_PORT = 17356;
48/** Must match the `port` on fixtures/ecs-dev/main-task.ts. */
49const MAIN_PORT = 17357;
50/** Must match the port baked into fixtures/ecs-reload/Dockerfile. */
51const RELOAD_PORT = 17358;
52/** Must match the port baked into fixtures/ecs-svc/Dockerfile. */
53const SVC_PORT = 17359;
54/** Must match the default port in fixtures/ecs-reload-main/server.ts. */
55const MAIN_RELOAD_PORT = 17360;
56/** Must match the port baked into fixtures/ecs-env/Dockerfile. */
57const ENV_PORT = 17361;
58/** Must match the port baked into fixtures/ecs-ext/Dockerfile.ecs. */
59const EXT_PORT = 17363;
60
61const FLOCI_REGION = "us-east-1";
62
63const getState = Effect.fn(function* (fqn: string) {
64 const state = yield* yield* State;
65 const stk = yield* Stack;
66 return (yield* state.get({
67 stack: stk.name,
68 stage: stk.stage,
69 fqn,
70 })) as ResourceState | undefined;
71});
72
73class RunTaskRejected extends Data.TaggedError("RunTaskRejected")<{
74 readonly status: number;
75}> {}
76
77/** Raw ECS operation against the emulator gateway (out-of-band). */
78const rawEcs = (action: string, body: Record<string, unknown>) =>
79 rawAwsJson({
80 service: "ecs",
81 region: FLOCI_REGION,
82 target: `AmazonEC2ContainerServiceV20141113.${action}`,
83 contentType: "application/x-amz-json-1.1",
84 body,
85 });
86
87/** Names of the docker containers currently running on the host daemon. */
88const runningContainerNames = Effect.sync(
89 () =>
90 spawnSync("docker", ["ps", "--format", "{{.Names}}"], {
91 encoding: "utf8",
92 timeout: 15_000,
93 }).stdout ?? "",
94);
95
96/** `arn:aws:ecs:…:task/<cluster>/<taskId>` → `<taskId>`. */
97const taskIdOfArn = (arn: string) => arn.split("/").pop()!;
98
99/**
100 * The emulator runs task containers on THIS machine — build for the host
101 * architecture so the image runs natively instead of under qemu.
102 */
103const hostRuntimePlatform = {
104 cpuArchitecture:
105 process.arch === "arm64" ? ("ARM64" as const) : ("X86_64" as const),
106 operatingSystemFamily: "LINUX" as const,
107};
108
109/**
110 * Deploy → RunTask → assert RUNNING container + HTTP marker → destroy →
111 * assert everything (including the docker container) is gone. Shared by the
112 * context-Dockerfile and bundled-main cases.
113 */
114const runTaskRoundTrip = Effect.fn(function* (options: {
115 clusterName: string;
116 taskDefinitionArn: string;
117 port: number;
118 marker: string;
119}) {
120 const client = yield* HttpClient.HttpClient;
121
122 // Out-of-band: run the deployed task definition on the deployed cluster
123 // through the raw gateway API. floci launches a REAL docker container.
124 // Full-suite load can 400 the first RunTask; retry until the emulator
125 // accepts it rather than failing the whole file.
126 const runResponse = yield* rawEcs("RunTask", {
127 cluster: options.clusterName,
128 taskDefinition: options.taskDefinitionArn,
129 count: 1,
130 launchType: "EC2",
131 }).pipe(
132 Effect.flatMap((response) =>
133 response.status === 200
134 ? Effect.succeed(response)
135 : Effect.fail(new RunTaskRejected({ status: response.status })),
136 ),
137 Effect.retry({
138 while: (e) => e._tag === "RunTaskRejected",
139 times: 8,
140 schedule: Schedule.exponential("500 millis"),
141 }),
142 );
143 const run = (yield* runResponse.json) as {
144 tasks?: { taskArn?: string; lastStatus?: string }[];
145 failures?: unknown[];
146 };
147 expect(run.failures ?? []).toEqual([]);
148 const taskArn = run.tasks?.[0]?.taskArn;
149 expect(taskArn).toBeTruthy();
150 const taskId = taskIdOfArn(taskArn!);
151
152 // DescribeTasks observes the task RUNNING…
153 const described = yield* rawEcs("DescribeTasks", {
154 cluster: options.clusterName,
155 tasks: [taskArn],
156 });
157 expect(described.status).toBe(200);
158 const tasks = (yield* described.json) as {
159 tasks?: { lastStatus?: string }[];
160 };
161 expect(tasks.tasks?.[0]?.lastStatus).toBe("RUNNING");
162
163 // …and the task is a REAL container on the host docker daemon
164 // (`floci-ecs-<taskId>-<containerName>`).
165 expect(yield* runningContainerNames).toContain(`floci-ecs-${taskId}`);
166
167 // Round-trip HTTP to the served marker. Bridge-mode port mappings are
168 // published literally on the docker host, so the container is reachable
169 // at localhost:<port>. (floci's DescribeTasks networkBindings mis-report
170 // the hostPort as the containerPort, so the literal port — equal by
171 // construction in the Task's port mapping — is the reliable address.)
172 const body = yield* client.get(`http://localhost:${options.port}/`).pipe(
173 Effect.retry({
174 schedule: Schedule.exponential("500 millis"),
175 times: 10,
176 }),
177 Effect.flatMap((response) => response.text),
178 );
179 expect(body).toContain(options.marker);
180
181 return { taskId };
182});
183
184/**
185 * Bounded poll of `http://localhost:<port>/` until the marker serves.
186 * Connection failures (the swap window between the old container stopping
187 * and the replacement serving) are retried; a stale marker repeats.
188 */
189const pollMarker = Effect.fn(function* (options: {
190 port: number;
191 marker: string;
192 times?: number;
193 /** Path to poll (default `/`). */
194 path?: string;
195}) {
196 const client = yield* HttpClient.HttpClient;
197 const times = options.times ?? 60;
198 const body = yield* client
199 .get(`http://localhost:${options.port}${options.path ?? "/"}`)
200 .pipe(
201 Effect.flatMap((response) => response.text),
202 Effect.retry({
203 while: (): boolean => true,
204 schedule: Schedule.spaced("2 seconds"),
205 times,
206 }),
207 Effect.repeat({
208 schedule: Schedule.spaced("2 seconds"),
209 until: (b): boolean => b.includes(options.marker),
210 times,
211 }),
212 );
213 expect(body).toContain(options.marker);
214});
215
216/**
217 * Post-destroy (hot-reload variants, where the swap replaced the original
218 * task): observe until no running container belongs to the family's primary
219 * container (`floci-ecs-<taskId>-<containerName>`), then assert the cluster
220 * and task definition are gone from the emulator.
221 */
222const assertFamilyTornDown = Effect.fn(function* (options: {
223 clusterName: string;
224 taskDefinitionArn: string;
225 containerName: string;
226}) {
227 yield* runningContainerNames.pipe(
228 Effect.flatMap((names) =>
229 names
230 .split("\n")
231 .some(
232 (name) =>
233 name.startsWith("floci-ecs-") &&
234 name.endsWith(`-${options.containerName}`),
235 )
236 ? Effect.fail(new Error("family container still running"))
237 : Effect.void,
238 ),
239 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 20 }),
240 );
241
242 const clusters = (yield* (yield* rawEcs("DescribeClusters", {
243 clusters: [options.clusterName],
244 })).json) as { clusters?: { status?: string }[] };
245 const active = (clusters.clusters ?? []).filter(
246 (cluster) => cluster.status !== "INACTIVE",
247 );
248 expect(active).toEqual([]);
249
250 const definition = yield* rawEcs("DescribeTaskDefinition", {
251 taskDefinition: options.taskDefinitionArn,
252 });
253 expect(definition.status).toBe(400); // ClientException: unable to describe
254});
255
256/** Post-destroy: task container gone, cluster gone, task definition gone. */
257const assertTornDown = Effect.fn(function* (options: {
258 clusterName: string;
259 taskDefinitionArn: string;
260 taskId: string;
261}) {
262 // The running task's container was stopped and removed by the cluster
263 // delete (observe until the daemon agrees — stop is asynchronous).
264 yield* runningContainerNames.pipe(
265 Effect.flatMap((names) =>
266 names.includes(`floci-ecs-${options.taskId}`)
267 ? Effect.fail(new Error("task container still running"))
268 : Effect.void,
269 ),
270 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 15 }),
271 );
272
273 // The cluster is gone (or INACTIVE) in the emulator.
274 const clusters = (yield* (yield* rawEcs("DescribeClusters", {
275 clusters: [options.clusterName],
276 })).json) as { clusters?: { status?: string }[] };
277 const active = (clusters.clusters ?? []).filter(
278 (cluster) => cluster.status !== "INACTIVE",
279 );
280 expect(active).toEqual([]);
281
282 // The task definition revision is gone.
283 const definition = yield* rawEcs("DescribeTaskDefinition", {
284 taskDefinition: options.taskDefinitionArn,
285 });
286 expect(definition.status).toBe(400); // ClientException: unable to describe
287});
288
289// Sequential on purpose: each test runs a `docker buildx build`, and
290// concurrent builds multiply pressure on Docker Desktop's shared
291// `docker-credential-desktop` helper, which can wedge machine-wide under
292// concurrent access (the same helper-race class documented on
293// `Docker.image.push`). One build at a time keeps this file off that path.
294describe.sequential(
295 "EcsDev",
296 { tags: ["provider:aws", "provider:aws:ecs", "local"] },
297 () => {
298 test.provider.skipIf(!dockerAvailable)(
299 "dev mode runs a context-Dockerfile ECS task as a real local container",
300 (stack) =>
301 Effect.gen(function* () {
302 yield* stack.destroy();
303
304 const outputs = yield* stack.deploy(
305 Effect.gen(function* () {
306 // Propless instantiation on purpose: pins the `news ?? {}`
307 // normalization in the Cluster reconciler (every ClusterProps
308 // field is optional, so `Cluster("Id")` is legal).
309 const cluster = yield* AWS.ECS.Cluster("EcsDevCluster");
310 const task = yield* AWS.ECS.Task("EcsDevTask", {
311 context: contextDir,
312 port: CONTEXT_PORT,
313 cpu: 256,
314 memory: 512,
315 // Bridge mode publishes the literal host port — the only
316 // host-reachable mode when floci runs inside a container.
317 networkMode: "bridge",
318 requiresCompatibilities: ["EC2"],
319 runtimePlatform: hostRuntimePlatform,
320 });
321 return { cluster, task };
322 }),
323 );
324
325 // --- Emulator-shaped identity: the live cloud can never mint these.
326 expect(outputs.cluster.clusterArn).toContain(":000000000000:");
327 expect(outputs.task.taskDefinitionArn).toContain(":000000000000:");
328 expect(outputs.task.taskRoleArn).toContain("::000000000000:");
329 expect(outputs.task.executionRoleArn).toContain("::000000000000:");
330 // The image was pushed to floci's loopback ECR registry.
331 expect(outputs.task.repositoryUri).toContain(".localhost:");
332 expect(outputs.task.imageUri).toContain(".localhost:");
333
334 // --- Stamped-mode proof.
335 for (const fqn of ["EcsDevCluster", "EcsDevTask"]) {
336 const row = yield* getState(fqn);
337 expect(`${fqn}:${row?.status}`).toBe(`${fqn}:created`);
338 expect(`${fqn}:${row?.providerMode}`).toBe(`${fqn}:local`);
339 }
340
341 // --- Run the task: real container, HTTP marker round-trip.
342 const { taskId } = yield* runTaskRoundTrip({
343 clusterName: outputs.cluster.clusterName,
344 taskDefinitionArn: outputs.task.taskDefinitionArn,
345 port: CONTEXT_PORT,
346 marker: "ecs-dev-local-marker",
347 });
348
349 // --- Destroy: the cluster delete stops the running task (container
350 // removed), and the task delete sweeps the definition + repository.
351 yield* stack.destroy();
352
353 yield* assertTornDown({
354 clusterName: outputs.cluster.clusterName,
355 taskDefinitionArn: outputs.task.taskDefinitionArn,
356 taskId,
357 });
358
359 // The ECR repository holding the built image is gone from the
360 // emulator's registry too.
361 const repositories = yield* rawAwsJson({
362 service: "ecr",
363 region: FLOCI_REGION,
364 target: "AmazonEC2ContainerRegistry_V20150921.DescribeRepositories",
365 contentType: "application/x-amz-json-1.1",
366 body: { repositoryNames: [outputs.task.repositoryName] },
367 });
368 expect(repositories.status).toBe(400); // RepositoryNotFoundException
369 }),
370 { timeout: 300_000 },
371 );
372
373 test.provider.skipIf(!dockerAvailable)(
374 "dev mode bundles a main-program ECS task and pushes through the local ECR registry",
375 (stack) =>
376 Effect.gen(function* () {
377 yield* stack.destroy();
378
379 const outputs = yield* stack.deploy(
380 Effect.gen(function* () {
381 const cluster = yield* AWS.ECS.Cluster("EcsDevMainCluster", {});
382 const task = yield* EcsDevMainTask;
383 return { cluster, task };
384 }),
385 );
386
387 expect(outputs.cluster.clusterArn).toContain(":000000000000:");
388 expect(outputs.task.taskDefinitionArn).toContain(":000000000000:");
389 expect(outputs.task.repositoryUri).toContain(".localhost:");
390
391 for (const fqn of ["EcsDevMainCluster", "EcsDevMainTask"]) {
392 const row = yield* getState(fqn);
393 expect(`${fqn}:${row?.status}`).toBe(`${fqn}:created`);
394 expect(`${fqn}:${row?.providerMode}`).toBe(`${fqn}:local`);
395 }
396
397 const { taskId } = yield* runTaskRoundTrip({
398 clusterName: outputs.cluster.clusterName,
399 taskDefinitionArn: outputs.task.taskDefinitionArn,
400 port: MAIN_PORT,
401 marker: "ecs-dev-main-marker",
402 });
403
404 yield* stack.destroy();
405
406 yield* assertTornDown({
407 clusterName: outputs.cluster.clusterName,
408 taskDefinitionArn: outputs.task.taskDefinitionArn,
409 taskId,
410 });
411 }),
412 { timeout: 300_000 },
413 );
414
415 test.provider.skipIf(!dockerAvailable)(
416 "hot reloads a context-Dockerfile task without a deploy",
417 (stack) =>
418 Effect.gen(function* () {
419 const fs = yield* FileSystem.FileSystem;
420 const path = yield* Path.Path;
421 yield* stack.destroy();
422
423 // Clone the fixture so the hot-reload rewrite never touches the
424 // repo tree.
425 const clone = yield* cloneFixture(
426 `${import.meta.dirname}/fixtures/ecs-reload`,
427 { prefix: "ecs-reload-" },
428 );
429
430 const outputs = yield* stack.deploy(
431 Effect.gen(function* () {
432 const cluster = yield* AWS.ECS.Cluster("EcsReloadCluster");
433 const task = yield* AWS.ECS.Task("EcsReloadTask", {
434 context: clone,
435 port: RELOAD_PORT,
436 cpu: 256,
437 memory: 512,
438 networkMode: "bridge",
439 requiresCompatibilities: ["EC2"],
440 runtimePlatform: hostRuntimePlatform,
441 });
442 return { cluster, task };
443 }),
444 );
445 expect(outputs.task.taskDefinitionArn).toContain(":000000000000:");
446 expect(outputs.task.repositoryUri).toContain(".localhost:");
447
448 // Run the deployed revision out-of-band; marker v1 serves.
449 yield* runTaskRoundTrip({
450 clusterName: outputs.cluster.clusterName,
451 taskDefinitionArn: outputs.task.taskDefinitionArn,
452 port: RELOAD_PORT,
453 marker: "ecs-reload-v1",
454 });
455
456 // Hot reload: rewrite the CLONED context — no deploy in between.
457 // The sidecar watch loop rebuilds the image, pushes the new
458 // content-hash tag, registers a new task definition revision, and
459 // restarts the running standalone task on it.
460 const swapStartedAt = Date.now();
461 yield* fs.writeFileString(
462 path.join(clone, "index.html"),
463 "ecs-reload-v2\n",
464 );
465 yield* pollMarker({
466 port: RELOAD_PORT,
467 marker: "ecs-reload-v2",
468 times: 90,
469 });
470 yield* Effect.log(
471 `context task hot reload observed in ${Date.now() - swapStartedAt}ms`,
472 );
473
474 yield* stack.destroy();
475 yield* assertFamilyTornDown({
476 clusterName: outputs.cluster.clusterName,
477 taskDefinitionArn: outputs.task.taskDefinitionArn,
478 containerName: outputs.task.containerName,
479 });
480 }),
481 { timeout: 300_000 },
482 );
483
484 test.provider.skipIf(!dockerAvailable)(
485 "hot reloads a bundled-main task without a deploy",
486 (stack) =>
487 Effect.gen(function* () {
488 const fs = yield* FileSystem.FileSystem;
489 const path = yield* Path.Path;
490 yield* stack.destroy();
491
492 const clone = yield* cloneFixture(
493 `${import.meta.dirname}/fixtures/ecs-reload-main`,
494 { prefix: "ecs-reload-main-" },
495 );
496 const mainPath = path.join(clone, "server.ts");
497
498 const program = Effect.gen(function* () {
499 const cluster = yield* AWS.ECS.Cluster("EcsReloadMainCluster");
500 // Declared WITHOUT an inline impl — the platform marks it
501 // external and the bundle runs as-is (a plain Bun server).
502 const task = yield* AWS.ECS.Task("EcsReloadMainTask", {
503 main: mainPath,
504 image: "oven/bun:1",
505 port: MAIN_RELOAD_PORT,
506 cpu: 256,
507 memory: 512,
508 networkMode: "bridge",
509 requiresCompatibilities: ["EC2"],
510 runtimePlatform: hostRuntimePlatform,
511 });
512 return { cluster, task };
513 });
514 const outputs = yield* stack.deploy(program);
515 expect(outputs.task.taskDefinitionArn).toContain(":000000000000:");
516 expect(outputs.task.repositoryUri).toContain(".localhost:");
517
518 yield* runTaskRoundTrip({
519 clusterName: outputs.cluster.clusterName,
520 taskDefinitionArn: outputs.task.taskDefinitionArn,
521 port: MAIN_RELOAD_PORT,
522 marker: "ecs-reload-main-v1",
523 });
524
525 // Hot reload: rewrite the CLONED program source. The sidecar's
526 // `Bundle.watch` (the deploy's exact rolldown module graph)
527 // triggers the rebuild → push → re-register → restart.
528 const source = yield* fs.readFileString(mainPath);
529 const swapStartedAt = Date.now();
530 yield* fs.writeFileString(
531 mainPath,
532 source.replace("ecs-reload-main-v1", "ecs-reload-main-v2"),
533 );
534 yield* pollMarker({
535 port: MAIN_RELOAD_PORT,
536 marker: "ecs-reload-main-v2",
537 times: 90,
538 });
539 yield* Effect.log(
540 `bundled-main task hot reload observed in ${Date.now() - swapStartedAt}ms`,
541 );
542
543 // A REPLAN after the watcher's swap must surface it. Persisted state
544 // still carries v1's code hash (the watcher updates the emulator and
545 // the sidecar's in-memory attrs, not the state store), so the dev
546 // diff plans an update — not a noop — and the fresh attrs flow to
547 // stack outputs. The reported bug had this replan noop, so
548 // `task.code.hash` never advanced and Actions keyed on it stayed
549 // skipped after a transitive-import change.
550 const replanned = yield* stack.deploy(program);
551 expect(replanned.task.code.hash).not.toBe(outputs.task.code.hash);
552 // And a second replan with nothing new IS a noop-equivalent: the
553 // attrs are settled, the hash stable.
554 const settled = yield* stack.deploy(program);
555 expect(settled.task.code.hash).toBe(replanned.task.code.hash);
556
557 yield* stack.destroy();
558 yield* assertFamilyTornDown({
559 clusterName: outputs.cluster.clusterName,
560 taskDefinitionArn: outputs.task.taskDefinitionArn,
561 containerName: outputs.task.containerName,
562 });
563 }),
564 { timeout: 300_000 },
565 );
566
567 test.provider.skipIf(!dockerAvailable)(
568 "dev mode runs an image-owning ECS Service with hot reload",
569 (stack) =>
570 Effect.gen(function* () {
571 const fs = yield* FileSystem.FileSystem;
572 const path = yield* Path.Path;
573 yield* stack.destroy();
574
575 const clone = yield* cloneFixture(
576 `${import.meta.dirname}/fixtures/ecs-svc`,
577 { prefix: "ecs-svc-" },
578 );
579
580 const outputs = yield* stack.deploy(
581 Effect.gen(function* () {
582 const cluster = yield* AWS.ECS.Cluster("EcsDevSvcCluster");
583 const service = yield* AWS.ECS.Service("EcsDevService", {
584 cluster,
585 context: clone,
586 port: SVC_PORT,
587 cpu: 256,
588 memory: 512,
589 // Bridge mode publishes the literal host port — the only
590 // host-reachable mode when floci runs inside a container
591 // (managed ALB ingress is out of scope in dev: floci binds
592 // listener sockets inside its own container, and the managed
593 // `alchemy-floci` container only exposes the gateway port).
594 networkMode: "bridge",
595 requiresCompatibilities: ["EC2"],
596 launchType: "EC2",
597 desiredCount: 1,
598 runtimePlatform: hostRuntimePlatform,
599 deploymentStabilizationTimeout: "3 minutes",
600 });
601 return { cluster, service };
602 }),
603 );
604
605 // Emulator-shaped identity + stamped-mode proof.
606 expect(outputs.service.serviceArn).toContain(":000000000000:");
607 expect(outputs.service.taskDefinitionArn).toContain(":000000000000:");
608 expect(outputs.service.repositoryUri).toContain(".localhost:");
609 expect(outputs.service.status).toBe("ACTIVE");
610 for (const fqn of ["EcsDevSvcCluster", "EcsDevService"]) {
611 const row = yield* getState(fqn);
612 expect(`${fqn}:${row?.status}`).toBe(`${fqn}:created`);
613 expect(`${fqn}:${row?.providerMode}`).toBe(`${fqn}:local`);
614 }
615
616 // The floci service scheduler launched the task itself — a REAL
617 // container serving the marker (no manual RunTask).
618 yield* pollMarker({
619 port: SVC_PORT,
620 marker: "ecs-svc-v1",
621 times: 60,
622 });
623 expect(yield* runningContainerNames).toContain(
624 `-${outputs.service.containerName!}`,
625 );
626
627 // Hot reload: rewrite the CLONED context — no deploy in between.
628 // The sidecar watcher rebuilds + pushes the new content-hash tag,
629 // re-reconciles (updateService onto the new revision), and stops
630 // the old-revision task; the floci scheduler relaunches it.
631 const swapStartedAt = Date.now();
632 yield* fs.writeFileString(
633 path.join(clone, "index.html"),
634 "ecs-svc-v2\n",
635 );
636 yield* pollMarker({
637 port: SVC_PORT,
638 marker: "ecs-svc-v2",
639 times: 90,
640 });
641 yield* Effect.log(
642 `service hot reload observed in ${Date.now() - swapStartedAt}ms`,
643 );
644
645 // Destroy drains the service (desiredCount 0 → tasks stopped),
646 // deletes it, then sweeps the task-definition infrastructure.
647 yield* stack.destroy();
648 yield* assertFamilyTornDown({
649 clusterName: outputs.cluster.clusterName,
650 taskDefinitionArn: outputs.service.taskDefinitionArn,
651 containerName: outputs.service.containerName!,
652 });
653 const services = (yield* (yield* rawEcs("DescribeServices", {
654 cluster: outputs.cluster.clusterName,
655 services: [outputs.service.serviceName],
656 })).json) as { services?: { status?: string }[] };
657 const activeServices = (services.services ?? []).filter(
658 (service) => service.status === "ACTIVE",
659 );
660 expect(activeServices).toEqual([]);
661 }),
662 { timeout: 300_000 },
663 );
664
665 /**
666 * A PROP-driven update — no file event — must roll the service's running
667 * containers onto the new task-definition revision. Regression: restart
668 * logic lived only in the file-watch trigger, so an engine reconcile
669 * (env change, inline-dockerfile edit) registered a new revision and
670 * `updateService`d onto it while the container kept serving the old one
671 * until the next source edit (`onReconciled` in DevWatchProvider).
672 */
673 test.provider.skipIf(!dockerAvailable)(
674 "rolls a service task on a prop-only env update",
675 (stack) =>
676 Effect.gen(function* () {
677 yield* stack.destroy();
678
679 const clone = yield* cloneFixture(
680 `${import.meta.dirname}/fixtures/ecs-env`,
681 { prefix: "ecs-env-" },
682 );
683
684 const declare = (env: string) =>
685 Effect.gen(function* () {
686 const cluster = yield* AWS.ECS.Cluster("EcsEnvCluster");
687 const service = yield* AWS.ECS.Service("EcsEnvService", {
688 cluster,
689 context: clone,
690 port: ENV_PORT,
691 cpu: 256,
692 memory: 512,
693 networkMode: "bridge",
694 requiresCompatibilities: ["EC2"],
695 launchType: "EC2",
696 desiredCount: 1,
697 runtimePlatform: hostRuntimePlatform,
698 deploymentStabilizationTimeout: "3 minutes",
699 env: { ROLL_ENV: env },
700 });
701 return { cluster, service };
702 });
703
704 const outputs = yield* stack.deploy(declare("roll-env-v1"));
705 yield* pollMarker({
706 port: ENV_PORT,
707 marker: "roll-env-v1",
708 path: "/env.txt",
709 times: 90,
710 });
711
712 // The prop change: same fixture bytes, only the env differs. The
713 // engine registers a new revision; the running container must roll.
714 const swapStartedAt = Date.now();
715 const updated = yield* stack.deploy(declare("roll-env-v2"));
716 expect(updated.service.taskDefinitionArn).not.toBe(
717 outputs.service.taskDefinitionArn,
718 );
719 yield* pollMarker({
720 port: ENV_PORT,
721 marker: "roll-env-v2",
722 path: "/env.txt",
723 times: 90,
724 });
725 yield* Effect.log(
726 `prop-only service roll observed in ${Date.now() - swapStartedAt}ms`,
727 );
728
729 yield* stack.destroy();
730 yield* assertFamilyTornDown({
731 clusterName: outputs.cluster.clusterName,
732 taskDefinitionArn: updated.service.taskDefinitionArn,
733 containerName: updated.service.containerName!,
734 });
735 }),
736 { timeout: 600_000 },
737 );
738 /**
739 * A `dockerfile` PATH that lives OUTSIDE the build context: the watcher
740 * must watch the Dockerfile's own directory in addition to the context
741 * (the `imageSourceTrigger` outside-context branch — previously never
742 * exercised by any test), and an edit to the Dockerfile itself must
743 * rebuild + roll with no deploy.
744 */
745 test.provider.skipIf(!dockerAvailable)(
746 "hot reloads a service whose Dockerfile lives outside the build context",
747 (stack) =>
748 Effect.gen(function* () {
749 const fs = yield* FileSystem.FileSystem;
750 const path = yield* Path.Path;
751
752 yield* stack.destroy();
753
754 const clone = yield* cloneFixture(
755 `${import.meta.dirname}/fixtures/ecs-ext`,
756 { prefix: "ecs-ext-" },
757 );
758
759 const outputs = yield* stack.deploy(
760 Effect.gen(function* () {
761 const cluster = yield* AWS.ECS.Cluster("EcsExtCluster");
762 const service = yield* AWS.ECS.Service("EcsExtService", {
763 cluster,
764 context: path.join(clone, "site"),
765 dockerfile: path.join(clone, "Dockerfile.ecs"),
766 port: EXT_PORT,
767 cpu: 256,
768 memory: 512,
769 networkMode: "bridge",
770 requiresCompatibilities: ["EC2"],
771 launchType: "EC2",
772 desiredCount: 1,
773 runtimePlatform: hostRuntimePlatform,
774 deploymentStabilizationTimeout: "3 minutes",
775 });
776 return { cluster, service };
777 }),
778 );
779
780 yield* pollMarker({
781 port: EXT_PORT,
782 marker: "ecs-ext-content-v1",
783 times: 90,
784 });
785 yield* pollMarker({
786 port: EXT_PORT,
787 marker: "ecs-ext-baked-v1",
788 path: "/baked.txt",
789 times: 60,
790 });
791
792 // Edit the OUT-OF-CONTEXT Dockerfile — no deploy.
793 const swapStartedAt = Date.now();
794 const dockerfile = yield* fs.readFileString(
795 path.join(clone, "Dockerfile.ecs"),
796 );
797 yield* fs.writeFileString(
798 path.join(clone, "Dockerfile.ecs"),
799 dockerfile.replace("ecs-ext-baked-v1", "ecs-ext-baked-v2"),
800 );
801 yield* pollMarker({
802 port: EXT_PORT,
803 marker: "ecs-ext-baked-v2",
804 path: "/baked.txt",
805 times: 90,
806 });
807 yield* Effect.log(
808 `out-of-context Dockerfile reload observed in ${Date.now() - swapStartedAt}ms`,
809 );
810
811 // A context file edit still reloads too.
812 yield* fs.writeFileString(
813 path.join(clone, "site", "index.html"),
814 "ecs-ext-content-v2\n",
815 );
816 yield* pollMarker({
817 port: EXT_PORT,
818 marker: "ecs-ext-content-v2",
819 times: 90,
820 });
821
822 yield* stack.destroy();
823 yield* assertFamilyTornDown({
824 clusterName: outputs.cluster.clusterName,
825 taskDefinitionArn: outputs.service.taskDefinitionArn,
826 containerName: outputs.service.containerName!,
827 });
828 }),
829 { timeout: 600_000 },
830 );
831 },
832); // describe.sequential("EcsDev")