1import * as AWS from "@/AWS";
2import { Subnet } from "@/AWS/EC2/Subnet.ts";
3import { Cluster } from "@/AWS/ECS/Cluster.ts";
4import { Service, ServiceDidNotStabilize } from "@/AWS/ECS/Service.ts";
5import * as Provider from "@/Provider";
6import { isResourceState, State, type ResourceState } from "@/State";
7import * as Test from "@/Test/Alchemy";
8import * as ec2 from "@distilled.cloud/aws/ec2";
9import * as ecs from "@distilled.cloud/aws/ecs";
10import * as iam from "@distilled.cloud/aws/iam";
11import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Result from "effect/Result";
15import * as Schedule from "effect/Schedule";
16import { getDefaultVpc } from "../DefaultVpc.ts";
17import { reclaimTaskDefinitionFamily } from "./reclaimTaskDefinitionFamily.ts";
18
19const { test } = Test.make({ providers: AWS.providers() });
20
21// Canonical `list()` test (AWS account/region collection, fanned out per
22// cluster): deploy a real cluster + service, resolve the provider from context
23// via the typed `findProvider`, call `list()`, and assert the deployed service
24// appears in the exhaustively-paginated result
25// (listClusters -> listServices -> describeServices hydration).
26//
27// To stay inside the speed budget we avoid building/pushing a Docker image
28// (the canonical `AWS.ECS.Task` resource) and instead register a minimal task
29// definition against a public image and run the service at `desiredCount: 0`,
30// so `createService` returns immediately without waiting for Fargate task
31// placement. Networking is a stack-owned subnet in the standing default VPC
32// (no NAT/IGW needed since no task is ever launched).
33test.provider(
34 "list enumerates the deployed service",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38
39 // Reclaim any revisions a previously-killed run left behind, and
40 // guarantee full deletion (deregister + delete) on success, failure,
41 // and interruption.
42 const family = "alchemy-test-ecs-service-list";
43 yield* reclaimTaskDefinitionFamily(family);
44 yield* Effect.addFinalizer(() =>
45 reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
46 );
47
48 // Register a minimal Fargate task definition pointing at a public image.
49 const registered = yield* ecs.registerTaskDefinition({
50 family,
51 networkMode: "awsvpc",
52 requiresCompatibilities: ["FARGATE"],
53 cpu: "256",
54 memory: "512",
55 containerDefinitions: [
56 {
57 name: "app",
58 image: "public.ecr.aws/nginx/nginx:stable",
59 essential: true,
60 portMappings: [{ containerPort: 80, protocol: "tcp" }],
61 },
62 ],
63 });
64 const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn;
65 if (!taskDefinitionArn) {
66 return yield* Effect.die(
67 new Error("registerTaskDefinition returned no task definition ARN"),
68 );
69 }
70 const defaultVpc = yield* getDefaultVpc;
71
72 const service = yield* stack.deploy(
73 Effect.gen(function* () {
74 const subnet = yield* Subnet("ListServiceSubnet", {
75 vpcId: defaultVpc.vpcId,
76 cidrBlock: defaultVpc.subnetCidrBlock(234),
77 });
78 const cluster = yield* Cluster("ListServiceCluster", {
79 clusterName: "alchemy-test-ecs-service-list",
80 });
81 return yield* Service("ListService", {
82 cluster,
83 task: {
84 taskDefinitionArn,
85 containerName: "app",
86 port: 80,
87 },
88 desiredCount: 0,
89 vpcId: defaultVpc.vpcId,
90 subnets: [subnet.subnetId],
91 });
92 }),
93 );
94
95 const provider = yield* Provider.findProvider(Service);
96 const all = yield* provider.list();
97
98 expect(all.some((s) => s.serviceArn === service.serviceArn)).toBe(true);
99 const found = all.find((s) => s.serviceArn === service.serviceArn);
100 expect(found?.serviceName).toEqual(service.serviceName);
101 expect(found?.clusterArn).toEqual(service.clusterArn);
102
103 yield* stack.destroy();
104
105 yield* reclaimTaskDefinitionFamily(family);
106
107 // Out-of-band gone-proof: the cluster (deleted after its service) is
108 // INACTIVE or absent, so nothing this test created is left ACTIVE.
109 const after = yield* ecs.describeClusters({
110 clusters: ["alchemy-test-ecs-service-list"],
111 });
112 expect((after.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
113 false,
114 );
115 }),
116 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"] },
117);
118
119// In-place reconcile coverage: create a service at desiredCount 0 (so
120// createService returns immediately without waiting on Fargate placement),
121// then update the network configuration (assignPublicIp), desiredCount, the
122// deployment circuit breaker, and tags — and assert the service was updated in
123// place (SAME serviceArn, no replacement). This exercises the per-aspect
124// updateService + tag-sync path and the narrowed `diff` (these fields must NOT
125// force a replace).
126test.provider(
127 "service applies network / desiredCount / deployment / tag changes in place",
128 (stack) =>
129 Effect.gen(function* () {
130 yield* stack.destroy();
131
132 const family = "alchemy-test-ecs-service-inplace";
133 yield* reclaimTaskDefinitionFamily(family);
134 yield* Effect.addFinalizer(() =>
135 reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
136 );
137
138 const registered = yield* ecs.registerTaskDefinition({
139 family,
140 networkMode: "awsvpc",
141 requiresCompatibilities: ["FARGATE"],
142 cpu: "256",
143 memory: "512",
144 containerDefinitions: [
145 {
146 name: "app",
147 image: "public.ecr.aws/nginx/nginx:stable",
148 essential: true,
149 portMappings: [{ containerPort: 80, protocol: "tcp" }],
150 },
151 ],
152 });
153 const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
154 const defaultVpc = yield* getDefaultVpc;
155
156 const deployService = (props: {
157 desiredCount: number;
158 assignPublicIp: boolean;
159 tags: Record<string, string>;
160 circuitBreaker: boolean;
161 }) =>
162 stack.deploy(
163 Effect.gen(function* () {
164 const subnet = yield* Subnet("InPlaceSubnet", {
165 vpcId: defaultVpc.vpcId,
166 cidrBlock: defaultVpc.subnetCidrBlock(235),
167 });
168 const cluster = yield* Cluster("InPlaceCluster", {
169 clusterName: "alchemy-test-ecs-service-inplace",
170 });
171 return yield* Service("InPlaceService", {
172 cluster,
173 task: { taskDefinitionArn, containerName: "app", port: 80 },
174 desiredCount: props.desiredCount,
175 vpcId: defaultVpc.vpcId,
176 subnets: [subnet.subnetId],
177 assignPublicIp: props.assignPublicIp,
178 tags: props.tags,
179 deploymentConfiguration: {
180 minimumHealthyPercent: 100,
181 maximumPercent: 200,
182 deploymentCircuitBreaker: {
183 enable: props.circuitBreaker,
184 rollback: props.circuitBreaker,
185 },
186 },
187 });
188 }),
189 );
190
191 const created = yield* deployService({
192 desiredCount: 0,
193 assignPublicIp: false,
194 tags: { env: "test", keep: "v1" },
195 circuitBreaker: false,
196 });
197
198 const updated = yield* deployService({
199 desiredCount: 0,
200 assignPublicIp: true,
201 tags: { env: "test", added: "new" },
202 circuitBreaker: true,
203 });
204
205 // No replacement — identity is stable.
206 expect(updated.serviceArn).toEqual(created.serviceArn);
207
208 // Verify out-of-band that updateService applied the changes.
209 const described = yield* ecs.describeServices({
210 cluster: updated.clusterArn,
211 services: [updated.serviceName],
212 include: ["TAGS"],
213 });
214 const svc = described.services?.[0];
215 expect(svc?.serviceArn).toEqual(created.serviceArn);
216 expect(svc?.taskDefinition).toEqual(taskDefinitionArn);
217 expect(svc?.runningCount).toBe(0);
218 expect(svc?.pendingCount).toBe(0);
219 expect(svc?.deployments).toHaveLength(1);
220 expect(svc?.deployments?.[0]?.status).toBe("PRIMARY");
221 expect(svc?.deployments?.[0]?.rolloutState).toBe("COMPLETED");
222 expect(
223 svc?.deploymentConfiguration?.deploymentCircuitBreaker?.enable,
224 ).toBe(true);
225 expect(
226 svc?.networkConfiguration?.awsvpcConfiguration?.assignPublicIp,
227 ).toBe("ENABLED");
228
229 // Tag reconcile: `keep` removed, `added` present, `env` retained.
230 const tagMap = Object.fromEntries(
231 (svc?.tags ?? []).map((t) => [t.key, t.value]),
232 );
233 expect(tagMap.added).toBe("new");
234 expect(tagMap.env).toBe("test");
235 expect(tagMap.keep).toBeUndefined();
236
237 yield* stack.destroy();
238 yield* reclaimTaskDefinitionFamily(family);
239
240 // Out-of-band gone-proof: the cluster (deleted after its service) is
241 // INACTIVE or absent.
242 const after = yield* ecs.describeClusters({
243 clusters: ["alchemy-test-ecs-service-inplace"],
244 });
245 expect((after.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
246 false,
247 );
248 }),
249 {
250 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
251 timeout: 240_000,
252 },
253);
254
255test.provider(
256 "a stale failed deployment does not fail the replacement deployment",
257 (stack) =>
258 Effect.gen(function* () {
259 const family = "alchemy-test-ecs-service-failed-rollout";
260 const stableImage = "public.ecr.aws/nginx/nginx:stable";
261 const failingImage = "public.ecr.aws/docker/library/alpine:3.20";
262
263 const registerTaskDefinition = (image: string, command?: string[]) =>
264 ecs.registerTaskDefinition({
265 family,
266 networkMode: "awsvpc",
267 requiresCompatibilities: ["FARGATE"],
268 cpu: "256",
269 memory: "512",
270 containerDefinitions: [
271 {
272 name: "app",
273 image,
274 command,
275 essential: true,
276 portMappings: [{ containerPort: 80, protocol: "tcp" }],
277 },
278 ],
279 });
280
281 const deployService = (
282 taskDefinitionArn: string,
283 generation: string,
284 desiredCount: number,
285 ) =>
286 stack.deploy(
287 Effect.gen(function* () {
288 const defaultVpc = yield* getDefaultVpc;
289 const subnet = yield* Subnet("FailedRolloutSubnet", {
290 vpcId: defaultVpc.vpcId,
291 cidrBlock: defaultVpc.subnetCidrBlock(246),
292 });
293 const cluster = yield* Cluster("FailedRolloutCluster", {
294 clusterName: family,
295 });
296 return yield* Service("FailedRolloutService", {
297 cluster,
298 task: { taskDefinitionArn, containerName: "app", port: 80 },
299 desiredCount,
300 vpcId: defaultVpc.vpcId,
301 subnets: [subnet.subnetId],
302 assignPublicIp: true,
303 tags: { generation },
304 deploymentStabilizationTimeout: "8 minutes",
305 deploymentConfiguration: {
306 minimumHealthyPercent: 0,
307 maximumPercent: 200,
308 deploymentCircuitBreaker: { enable: true, rollback: false },
309 },
310 });
311 }),
312 );
313
314 // Register cleanup before creating out-of-band task definitions.
315 yield* stack.destroy();
316 yield* reclaimTaskDefinitionFamily(family);
317 yield* Effect.addFinalizer(() =>
318 Effect.gen(function* () {
319 yield* stack.destroy().pipe(Effect.ignore);
320 yield* reclaimTaskDefinitionFamily(family).pipe(Effect.ignore);
321 }),
322 );
323
324 const initialTask = yield* registerTaskDefinition(stableImage);
325 const failingTask = yield* registerTaskDefinition(failingImage, [
326 "sh",
327 "-c",
328 "exit 1",
329 ]);
330 const recoveryTask = yield* registerTaskDefinition(stableImage);
331 const initialTaskArn = initialTask.taskDefinition?.taskDefinitionArn!;
332 const failingTaskArn = failingTask.taskDefinition?.taskDefinitionArn!;
333 const recoveryTaskArn = recoveryTask.taskDefinition?.taskDefinitionArn!;
334
335 const created = yield* deployService(initialTaskArn, "initial", 0);
336 const describeService = ecs.describeServices({
337 cluster: created.clusterArn,
338 services: [created.serviceName],
339 });
340 const initialSnapshot = yield* describeService;
341
342 const failed = yield* deployService(failingTaskArn, "failed", 1).pipe(
343 Effect.result,
344 );
345 expect(Result.isFailure(failed)).toBe(true);
346 if (Result.isFailure(failed)) {
347 expect(failed.failure).toBeInstanceOf(ServiceDidNotStabilize);
348 if (failed.failure instanceof ServiceDidNotStabilize) {
349 expect(failed.failure.message).toContain(
350 "reported a failed deployment",
351 );
352 expect(failed.failure.expectedTaskDefinitionArn).toBe(failingTaskArn);
353 }
354 }
355 const failedSnapshot = yield* describeService;
356 const failedDeployment = failedSnapshot.services?.[0]?.deployments?.find(
357 (deployment) => deployment.status === "PRIMARY",
358 );
359 expect(failedDeployment?.id).toBeDefined();
360 expect(failedDeployment?.taskDefinition).toBe(failingTaskArn);
361 expect(failedDeployment?.rolloutState).toBe("FAILED");
362 const failedDeploymentId = failedDeployment!.id!;
363
364 const [recovered, overlapping] = yield* Effect.all(
365 [
366 deployService(recoveryTaskArn, "recovered", 1),
367 describeService.pipe(
368 Effect.repeat({
369 schedule: Schedule.spaced("1 second"),
370 times: 120,
371 until: (response) => {
372 const deployments = response.services?.[0]?.deployments ?? [];
373 const primary = deployments.find((d) => d.status === "PRIMARY");
374 return (
375 primary?.taskDefinition === recoveryTaskArn &&
376 primary.id !== failedDeploymentId &&
377 (primary.rolloutState === "COMPLETED" ||
378 deployments.some(
379 (d) =>
380 d.id === failedDeploymentId &&
381 d.rolloutState === "FAILED",
382 ))
383 );
384 },
385 }),
386 ),
387 ],
388 { concurrency: 2 },
389 );
390 expect(recovered.serviceArn).toBe(created.serviceArn);
391 expect(recovered.taskDefinitionArn).toBe(recoveryTaskArn);
392 const overlappingDeployments =
393 overlapping.services?.[0]?.deployments ?? [];
394 expect(
395 overlappingDeployments.some(
396 (d) => d.id === failedDeploymentId && d.rolloutState === "FAILED",
397 ),
398 ).toBe(true);
399 const recoveryDeploymentId = overlappingDeployments.find(
400 (d) => d.status === "PRIMARY",
401 )?.id;
402 expect(recoveryDeploymentId).toBeDefined();
403 expect(recoveryDeploymentId).not.toBe(failedDeploymentId);
404
405 const recoveredSnapshot = yield* describeService;
406 const recoveredService = recoveredSnapshot.services?.[0];
407 expect(recoveredService?.createdAt).toEqual(
408 initialSnapshot.services?.[0]?.createdAt,
409 );
410 expect(recoveredService?.deployments).toHaveLength(1);
411 expect(recoveredService?.deployments?.[0]?.id).toBe(recoveryDeploymentId);
412 expect(recoveredService?.deployments?.[0]?.rolloutState).toBe(
413 "COMPLETED",
414 );
415 expect(recoveredService?.runningCount).toBe(1);
416 expect(recoveredService?.pendingCount).toBe(0);
417
418 yield* deployService(recoveryTaskArn, "forced-same-revision", 1);
419 const forcedSnapshot = yield* describeService;
420 const forcedService = forcedSnapshot.services?.[0];
421 expect(forcedService?.deployments).toHaveLength(1);
422 expect(forcedService?.deployments?.[0]?.id).not.toBe(
423 recoveryDeploymentId,
424 );
425 expect(forcedService?.deployments?.[0]?.taskDefinition).toBe(
426 recoveryTaskArn,
427 );
428 expect(forcedService?.deployments?.[0]?.rolloutState).toBe("COMPLETED");
429 expect(forcedService?.runningCount).toBe(1);
430 expect(forcedService?.pendingCount).toBe(0);
431
432 yield* stack.destroy();
433 yield* reclaimTaskDefinitionFamily(family);
434
435 const after = yield* ecs.describeClusters({ clusters: [family] });
436 expect((after.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
437 false,
438 );
439 }),
440 {
441 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
442 timeout: 900_000,
443 },
444);
445
446// Manual (user-supplied) load balancer: create an ALB + target group OUT OF
447// BAND, pass it explicitly via `loadBalancers` with `public: false`, and assert
448// (a) no Alchemy-managed ALB was created (no `url`/`loadBalancerArn` on the
449// attributes) and (b) the service is wired to the supplied target group.
450//
451// ECS `createService` rejects a target group that is not associated with a load
452// balancer (`InvalidParameterException`), so we provision a real (but minimal)
453// ALB + listener + target group out of band across two AZ subnets, wire the
454// service to that target group with `public: false`, and assert no
455// Alchemy-managed ALB was created.
456test.provider(
457 "service wires a user-supplied target group without creating an ALB",
458 (stack) =>
459 Effect.gen(function* () {
460 // Clean up any out-of-band ELBv2 leftovers from a prior interrupted run
461 // BEFORE destroying the stack: a stale ALB holds ENIs in the
462 // stack-owned subnets and prevents their deletion. It also keeps the
463 // fresh ALB, target group, and listener consistently wired (a stale,
464 // unassociated target group would make `createService` reject with
465 // `InvalidParameterException`).
466 const existingLbs = yield* elbv2
467 .describeLoadBalancers({ Names: ["alchemy-test-ecs-manuallb"] })
468 .pipe(Effect.catch(() => Effect.succeed({ LoadBalancers: [] })));
469 for (const lb of existingLbs.LoadBalancers ?? []) {
470 const ls = yield* elbv2
471 .describeListeners({ LoadBalancerArn: lb.LoadBalancerArn! })
472 .pipe(Effect.catch(() => Effect.succeed({ Listeners: [] })));
473 for (const l of ls.Listeners ?? []) {
474 yield* elbv2
475 .deleteListener({ ListenerArn: l.ListenerArn! })
476 .pipe(Effect.catch(() => Effect.void));
477 }
478 yield* elbv2
479 .deleteLoadBalancer({ LoadBalancerArn: lb.LoadBalancerArn! })
480 .pipe(Effect.catch(() => Effect.void));
481 }
482 if ((existingLbs.LoadBalancers ?? []).length > 0) {
483 yield* Effect.sleep("8 seconds");
484 }
485 const existingTgs = yield* elbv2
486 .describeTargetGroups({ Names: ["alchemy-test-ecs-manuallb"] })
487 .pipe(Effect.catch(() => Effect.succeed({ TargetGroups: [] })));
488 for (const tg of existingTgs.TargetGroups ?? []) {
489 yield* elbv2
490 .deleteTargetGroup({ TargetGroupArn: tg.TargetGroupArn! })
491 .pipe(
492 Effect.retry({
493 while: (e) => e._tag === "ResourceInUseException",
494 schedule: Schedule.max([
495 Schedule.spaced("3 seconds"),
496 Schedule.recurs(5),
497 ]),
498 }),
499 Effect.catch(() => Effect.void),
500 );
501 }
502
503 yield* stack.destroy();
504
505 const family = "alchemy-test-ecs-service-manuallb";
506 yield* reclaimTaskDefinitionFamily(family);
507 yield* Effect.addFinalizer(() =>
508 reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
509 );
510
511 const registered = yield* ecs.registerTaskDefinition({
512 family,
513 networkMode: "awsvpc",
514 requiresCompatibilities: ["FARGATE"],
515 cpu: "256",
516 memory: "512",
517 containerDefinitions: [
518 {
519 name: "app",
520 image: "public.ecr.aws/nginx/nginx:stable",
521 essential: true,
522 portMappings: [{ containerPort: 80, protocol: "tcp" }],
523 },
524 ],
525 });
526 const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
527 const defaultVpc = yield* getDefaultVpc;
528
529 // Resolve two available AZs in the active region — hardcoding zone
530 // names breaks as soon as the profile targets a different region.
531 const azResult = yield* ec2.describeAvailabilityZones({});
532 const availableAzs = (azResult.AvailabilityZones ?? []).filter(
533 (az) => az.State === "available",
534 );
535 const az1 = availableAzs[0]?.ZoneName!;
536 const az2 = availableAzs[1]?.ZoneName!;
537
538 // Deploy networking first so we can resolve concrete ids for the
539 // out-of-band ELBv2 resources.
540 const net = yield* stack.deploy(
541 Effect.gen(function* () {
542 const subnetA = yield* Subnet("ManualLbSubnetA", {
543 vpcId: defaultVpc.vpcId,
544 cidrBlock: defaultVpc.subnetCidrBlock(236),
545 availabilityZone: az1,
546 });
547 const subnetB = yield* Subnet("ManualLbSubnetB", {
548 vpcId: defaultVpc.vpcId,
549 cidrBlock: defaultVpc.subnetCidrBlock(237),
550 availabilityZone: az2,
551 });
552 const cluster = yield* Cluster("ManualLbCluster", {
553 clusterName: "alchemy-test-ecs-service-manuallb",
554 });
555 return {
556 vpcId: defaultVpc.vpcId,
557 subnetAId: subnetA.subnetId.as<string>(),
558 subnetBId: subnetB.subnetId.as<string>(),
559 clusterArn: cluster.clusterArn.as<string>(),
560 };
561 }),
562 );
563
564 // Create a real (internal) ALB + target group + listener out of band so
565 // the target group is associated with a load balancer.
566 const loadBalancer = yield* elbv2.createLoadBalancer({
567 Name: "alchemy-test-ecs-manuallb",
568 Type: "application",
569 Scheme: "internal",
570 Subnets: [net.subnetAId, net.subnetBId],
571 });
572 const loadBalancerArn = loadBalancer.LoadBalancers?.[0]?.LoadBalancerArn!;
573 // Safety-net finalizers (run LIFO on scope close): listener -> TG -> ALB,
574 // so the out-of-band ELBv2 resources are reclaimed even if the body fails.
575 yield* Effect.addFinalizer(() =>
576 elbv2
577 .deleteLoadBalancer({ LoadBalancerArn: loadBalancerArn })
578 .pipe(Effect.ignore),
579 );
580
581 const targetGroup = yield* elbv2.createTargetGroup({
582 Name: "alchemy-test-ecs-manuallb",
583 VpcId: net.vpcId,
584 TargetType: "ip",
585 Protocol: "HTTP",
586 Port: 80,
587 });
588 const targetGroupArn = targetGroup.TargetGroups?.[0]?.TargetGroupArn!;
589 yield* Effect.addFinalizer(() =>
590 elbv2
591 .deleteTargetGroup({ TargetGroupArn: targetGroupArn })
592 .pipe(Effect.ignore),
593 );
594
595 const listener = yield* elbv2.createListener({
596 LoadBalancerArn: loadBalancerArn,
597 Port: 80,
598 Protocol: "HTTP",
599 DefaultActions: [{ Type: "forward", TargetGroupArn: targetGroupArn }],
600 });
601 const listenerArn = listener.Listeners?.[0]?.ListenerArn!;
602 yield* Effect.addFinalizer(() =>
603 elbv2.deleteListener({ ListenerArn: listenerArn }).pipe(Effect.ignore),
604 );
605
606 // Re-declare the same networking (idempotent — same logical ids) plus the
607 // Service wired to the user-supplied target group.
608 const service = yield* stack.deploy(
609 Effect.gen(function* () {
610 const subnetA = yield* Subnet("ManualLbSubnetA", {
611 vpcId: defaultVpc.vpcId,
612 cidrBlock: defaultVpc.subnetCidrBlock(236),
613 availabilityZone: az1,
614 });
615 const subnetB = yield* Subnet("ManualLbSubnetB", {
616 vpcId: defaultVpc.vpcId,
617 cidrBlock: defaultVpc.subnetCidrBlock(237),
618 availabilityZone: az2,
619 });
620 const cluster = yield* Cluster("ManualLbCluster", {
621 clusterName: "alchemy-test-ecs-service-manuallb",
622 });
623 return yield* Service("ManualLbService", {
624 cluster,
625 task: { taskDefinitionArn, containerName: "app", port: 80 },
626 desiredCount: 0,
627 public: false,
628 vpcId: defaultVpc.vpcId,
629 subnets: [subnetA.subnetId, subnetB.subnetId],
630 loadBalancers: [
631 { targetGroupArn, containerName: "app", containerPort: 80 },
632 ],
633 // Duration.Input audit coverage: only valid on services with a
634 // load balancer — assert the wire value round-trips as seconds.
635 healthCheckGracePeriod: "45 seconds",
636 });
637 }),
638 );
639
640 // No Alchemy-managed ALB.
641 expect(service.loadBalancerArn).toBeUndefined();
642 expect(service.url).toBeUndefined();
643
644 const described = yield* ecs.describeServices({
645 cluster: service.clusterArn,
646 services: [service.serviceName],
647 });
648 const lbs = described.services?.[0]?.loadBalancers ?? [];
649 expect(lbs.length).toBe(1);
650 expect(lbs[0]?.containerName).toBe("app");
651 expect(lbs[0]?.targetGroupArn).toBe(targetGroupArn);
652
653 // Duration.Input round-trip: `"45 seconds"` landed on the wire as 45.
654 expect(described.services?.[0]?.healthCheckGracePeriodSeconds).toBe(45);
655
656 // Delete the service first (it references the target group), then tear
657 // down the out-of-band ELBv2 resources, then the rest of the stack.
658 yield* ecs
659 .deleteService({
660 cluster: service.clusterArn,
661 service: service.serviceName,
662 force: true,
663 })
664 .pipe(Effect.catch(() => Effect.void));
665 yield* elbv2
666 .deleteListener({ ListenerArn: listenerArn })
667 .pipe(Effect.catch(() => Effect.void));
668 yield* elbv2
669 .deleteLoadBalancer({ LoadBalancerArn: loadBalancerArn })
670 .pipe(Effect.catch(() => Effect.void));
671 yield* elbv2
672 .deleteTargetGroup({ TargetGroupArn: targetGroupArn })
673 .pipe(Effect.catch(() => Effect.void));
674
675 yield* stack.destroy();
676 yield* reclaimTaskDefinitionFamily(family);
677 }),
678 {
679 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
680 timeout: 240_000,
681 },
682);
683
684// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
685//
686// An interrupted first deploy persists the service as `status: "creating"`
687// with no attributes — and the Output-valued props (`cluster` passed as a
688// resource object, `task`, `vpcId`, `subnets`) do not survive the state
689// round-trip: they deserialize as `undefined`. Plan's recovery branch then
690// calls `provider.read` with those junk props, which crashed in
691// `clusterArnOf(undefined)` and wedged the stack (plan/deploy/destroy all
692// build a plan, so there was no CLI escape hatch).
693//
694// Simulate exactly that state row after a real deploy and assert the next
695// deploy recovers: `read` reports "not found", the engine re-drives the
696// create, and reconcile converges on the half-created service by name —
697// SAME serviceArn, no replacement.
698test.provider(
699 "recovers a half-created service whose creating-state lost Output-valued props (#736)",
700 (stack) =>
701 Effect.gen(function* () {
702 yield* stack.destroy();
703
704 const family = "alchemy-test-ecs-service-wedged";
705 yield* reclaimTaskDefinitionFamily(family);
706 yield* Effect.addFinalizer(() =>
707 reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
708 );
709
710 const registered = yield* ecs.registerTaskDefinition({
711 family,
712 networkMode: "awsvpc",
713 requiresCompatibilities: ["FARGATE"],
714 cpu: "256",
715 memory: "512",
716 containerDefinitions: [
717 {
718 name: "app",
719 image: "public.ecr.aws/nginx/nginx:stable",
720 essential: true,
721 portMappings: [{ containerPort: 80, protocol: "tcp" }],
722 },
723 ],
724 });
725 const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
726 const defaultVpc = yield* getDefaultVpc;
727
728 const deployService = () =>
729 stack.deploy(
730 Effect.gen(function* () {
731 const subnet = yield* Subnet("WedgedSubnet", {
732 vpcId: defaultVpc.vpcId,
733 cidrBlock: defaultVpc.subnetCidrBlock(238),
734 });
735 const cluster = yield* Cluster("WedgedCluster", {
736 clusterName: "alchemy-test-ecs-service-wedged",
737 });
738 return yield* Service("WedgedService", {
739 // Whole resource object, NOT a string ARN — the #736 shape.
740 cluster,
741 task: { taskDefinitionArn, containerName: "app", port: 80 },
742 desiredCount: 0,
743 vpcId: defaultVpc.vpcId,
744 subnets: [subnet.subnetId],
745 });
746 }),
747 );
748
749 const created = yield* deployService();
750
751 // Rewrite the service's persisted row into the wedged shape an
752 // interrupted deploy leaves behind: `creating`, no attributes, and
753 // every Output-valued prop lost in the round-trip.
754 const state = yield* yield* State;
755 const stage = stack.stage;
756 const fqns = yield* state.list({ stack: stack.name, stage });
757 const rows = yield* Effect.forEach(fqns, (fqn) =>
758 state
759 .get({ stack: stack.name, stage, fqn })
760 .pipe(Effect.map((row) => ({ fqn, row }))),
761 );
762 const wedged = rows.find(
763 (r): r is { fqn: string; row: ResourceState } =>
764 isResourceState(r.row) && r.row.resourceType === "AWS.ECS.Service",
765 );
766 if (!wedged) {
767 return yield* Effect.die(
768 new Error("no AWS.ECS.Service state row found after deploy"),
769 );
770 }
771 yield* state.set({
772 stack: stack.name,
773 stage,
774 fqn: wedged.fqn,
775 value: {
776 ...wedged.row,
777 status: "creating",
778 attr: undefined,
779 props: {
780 ...wedged.row.props,
781 cluster: undefined,
782 task: undefined,
783 vpcId: undefined,
784 subnets: undefined,
785 },
786 },
787 });
788
789 // Before the fix this crashed in plan with
790 // `TypeError: undefined is not an object (evaluating 'cluster.clusterArn')`.
791 const recovered = yield* deployService();
792 expect(recovered.serviceArn).toEqual(created.serviceArn);
793 expect(recovered.clusterArn).toEqual(created.clusterArn);
794
795 yield* stack.destroy();
796 yield* reclaimTaskDefinitionFamily(family);
797 }),
798 {
799 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
800 timeout: 240_000,
801 },
802);
803
804// Regression: the image-owning `ServiceProps` form inherits
805// `taskRoleManagedPolicyArns` from `TaskDefinitionConfig`, but reconcile
806// used to honor only its sibling `executionRoleManagedPolicyArns` — services
807// deployed with task roles silently missing their declared permissions.
808// Deploy an image-form service (desiredCount 0, no ingress) declaring an
809// AWS-managed policy, verify out of band that the policy is attached to the
810// task role, redeploy to prove the attach is idempotent, and verify destroy
811// detaches the policy and deletes the role.
812test.provider(
813 "attaches taskRoleManagedPolicyArns to the task role (image form)",
814 (stack) =>
815 Effect.gen(function* () {
816 yield* stack.destroy();
817
818 const managedPolicyArn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess";
819
820 const deployService = () =>
821 stack.deploy(
822 Effect.gen(function* () {
823 const cluster = yield* Cluster("TaskRolePolicyCluster", {
824 clusterName: "alchemy-test-ecs-service-task-role-policy",
825 });
826 return yield* Service("TaskRolePolicyService", {
827 cluster,
828 image: "busybox:stable",
829 command: ["sh", "-c", "while true; do sleep 30; done"],
830 port: 80,
831 desiredCount: 0,
832 taskRoleManagedPolicyArns: [managedPolicyArn],
833 });
834 }),
835 );
836
837 const created = yield* deployService();
838 const taskRoleName = created.taskRoleName;
839 if (!taskRoleName) {
840 return yield* Effect.die(
841 new Error("image-form service returned no taskRoleName"),
842 );
843 }
844
845 const listAttachedArns = iam
846 .listAttachedRolePolicies({ RoleName: taskRoleName })
847 .pipe(
848 Effect.map((r) => (r.AttachedPolicies ?? []).map((p) => p.PolicyArn)),
849 );
850
851 expect(yield* listAttachedArns).toContain(managedPolicyArn);
852
853 // Second deploy re-runs the attach against an already-attached ARN —
854 // `attachRolePolicy` is idempotent, so reconcile must not fail.
855 const updated = yield* deployService();
856 expect(updated.serviceArn).toEqual(created.serviceArn);
857 expect(yield* listAttachedArns).toContain(managedPolicyArn);
858
859 yield* stack.destroy();
860
861 // Gone-proof: destroy detached the managed policy and deleted the
862 // task role.
863 const roleGone = yield* iam.getRole({ RoleName: taskRoleName }).pipe(
864 Effect.map(() => false),
865 Effect.catchTag("NoSuchEntityException", () => Effect.succeed(true)),
866 );
867 expect(roleGone).toBe(true);
868 }),
869 {
870 tags: ["provider:aws", "provider:aws:ecs", "provider:aws:iam", "live"],
871 timeout: 240_000,
872 },
873);