| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Cluster } from "@/AWS/ECS/Cluster.ts"; |
| 3 | import { Service } from "@/AWS/ECS/Service.ts"; |
| 4 | import { isResourceState, State, type ResourceState } from "@/State"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 7 | import * as ecr from "@distilled.cloud/aws/ecr"; |
| 8 | import * as ecs from "@distilled.cloud/aws/ecs"; |
| 9 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 10 | import * as iam from "@distilled.cloud/aws/iam"; |
| 11 | import { expect } from "alchemy-test"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import { getDefaultVpcNetwork } from "../DefaultVpc.ts"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: AWS.providers() }); |
| 17 | |
| 18 | // The default VPC's per-AZ default subnets (exactly one per AZ). Concurrent |
| 19 | // suite runs create EXTRA subnets in the default VPC, and an ALB rejects two |
| 20 | // subnets in the same AZ — so never use the full subnet list for ALBs. |
| 21 | const defaultAzSubnets = (vpcId: string) => |
| 22 | ec2 |
| 23 | .describeSubnets({ |
| 24 | Filters: [ |
| 25 | { Name: "vpc-id", Values: [vpcId] }, |
| 26 | { Name: "default-for-az", Values: ["true"] }, |
| 27 | ], |
| 28 | }) |
| 29 | .pipe( |
| 30 | Effect.map((r) => |
| 31 | (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])), |
| 32 | ), |
| 33 | ); |
| 34 | |
| 35 | /** |
| 36 | * Delete any leftover out-of-band "legacy" fixtures carrying {@link name}. |
| 37 | * |
| 38 | * The reap test builds its fixtures under a fixed name and relies on |
| 39 | * finalizers to reclaim them, which a hard kill skips. Matching by listing |
| 40 | * (rather than describe-by-name) keeps this free of not-found handling: an |
| 41 | * absent fixture simply doesn't appear. |
| 42 | */ |
| 43 | const reclaimLegacyFixtures = Effect.fn("reclaimLegacyFixtures")(function* ( |
| 44 | name: string, |
| 45 | vpcId: string, |
| 46 | ) { |
| 47 | const lbs = yield* elbv2.describeLoadBalancers({}); |
| 48 | const lbArn = (lbs.LoadBalancers ?? []).find( |
| 49 | (lb) => lb.LoadBalancerName === name, |
| 50 | )?.LoadBalancerArn; |
| 51 | if (lbArn) { |
| 52 | const listeners = yield* elbv2.describeListeners({ |
| 53 | LoadBalancerArn: lbArn, |
| 54 | }); |
| 55 | yield* Effect.forEach( |
| 56 | (listeners.Listeners ?? []).flatMap((l) => |
| 57 | l.ListenerArn ? [l.ListenerArn] : [], |
| 58 | ), |
| 59 | (ListenerArn) => elbv2.deleteListener({ ListenerArn }), |
| 60 | ); |
| 61 | yield* elbv2.deleteLoadBalancer({ LoadBalancerArn: lbArn }); |
| 62 | } |
| 63 | |
| 64 | const tgs = yield* elbv2.describeTargetGroups({}); |
| 65 | const tgArn = (tgs.TargetGroups ?? []).find( |
| 66 | (tg) => tg.TargetGroupName === name, |
| 67 | )?.TargetGroupArn; |
| 68 | if (tgArn) { |
| 69 | yield* elbv2.deleteTargetGroup({ TargetGroupArn: tgArn }); |
| 70 | } |
| 71 | |
| 72 | const sgs = yield* ec2.describeSecurityGroups({ |
| 73 | Filters: [ |
| 74 | { Name: "vpc-id", Values: [vpcId] }, |
| 75 | { Name: "group-name", Values: [name] }, |
| 76 | ], |
| 77 | }); |
| 78 | const sgId = (sgs.SecurityGroups ?? [])[0]?.GroupId; |
| 79 | if (sgId) { |
| 80 | yield* ec2.deleteSecurityGroup({ GroupId: sgId }); |
| 81 | } |
| 82 | }); |
| 83 | |
| 84 | // The image-owning `Service` platform form: `image:` (mirrored into ECR) with |
| 85 | // `loadBalancer: true` — the Service synthesizes its own task definition |
| 86 | // (roles, log group, ECR repository) and wires an ALB + target group + |
| 87 | // listener + owned ingress security group, defaulting networking to the |
| 88 | // account's default VPC. `desiredCount: 0` keeps the test inside the speed |
| 89 | // budget (no Fargate placement, no image pull on the service side — the |
| 90 | // mirror is a local docker pull/tag/push of a tiny busybox). |
| 91 | // |
| 92 | // Requires a local Docker daemon (same as the ECS Bindings fixture). |
| 93 | test.provider( |
| 94 | "image-owning service synthesizes its task definition and ALB", |
| 95 | (stack) => |
| 96 | Effect.gen(function* () { |
| 97 | yield* stack.destroy(); |
| 98 | |
| 99 | const deployed = yield* stack.deploy( |
| 100 | Effect.gen(function* () { |
| 101 | const cluster = yield* Cluster("PlatformSvcCluster", { |
| 102 | clusterName: "alchemy-test-ecs-service-platform", |
| 103 | }); |
| 104 | // Docker Hub busybox: the public.ecr.aws mirror aggressively |
| 105 | // rate-limits anonymous pulls during local builds. |
| 106 | return yield* Service("PlatformEdge", { |
| 107 | cluster, |
| 108 | image: "busybox:stable", |
| 109 | command: ["sh", "-c", "while true; do sleep 30; done"], |
| 110 | port: 80, |
| 111 | desiredCount: 0, |
| 112 | loadBalancer: true, |
| 113 | environmentFiles: [ |
| 114 | { |
| 115 | value: "arn:aws:s3:::alchemy-test-ecs-env-files/edge.env", |
| 116 | type: "s3", |
| 117 | }, |
| 118 | ], |
| 119 | }); |
| 120 | }), |
| 121 | ); |
| 122 | |
| 123 | // Synthesized task definition attributes. |
| 124 | expect(deployed.serviceArn).toBeTruthy(); |
| 125 | expect(deployed.taskDefinitionArn).toBeTruthy(); |
| 126 | expect(deployed.taskFamily).toBeTruthy(); |
| 127 | expect(deployed.repositoryName).toBeTruthy(); |
| 128 | expect(deployed.imageUri).toContain(deployed.repositoryName!); |
| 129 | expect(deployed.code?.hash).toBeTruthy(); |
| 130 | expect(deployed.taskRoleArn).toBeTruthy(); |
| 131 | expect(deployed.executionRoleArn).toBeTruthy(); |
| 132 | expect(deployed.logGroupName).toBeTruthy(); |
| 133 | |
| 134 | // Managed ingress attributes. |
| 135 | expect(deployed.url).toMatch(/^http:\/\//); |
| 136 | expect(deployed.loadBalancerArn).toBeTruthy(); |
| 137 | expect(deployed.targetGroupArn).toBeTruthy(); |
| 138 | expect(deployed.listenerArn).toBeTruthy(); |
| 139 | expect(deployed.securityGroupId).toBeTruthy(); |
| 140 | |
| 141 | // Out-of-band: the registered task definition runs the mirrored image. |
| 142 | const described = yield* ecs.describeTaskDefinition({ |
| 143 | taskDefinition: deployed.taskDefinitionArn, |
| 144 | }); |
| 145 | const container = described.taskDefinition?.containerDefinitions?.[0]; |
| 146 | expect(container?.image).toBe(deployed.imageUri); |
| 147 | expect(container?.command).toEqual([ |
| 148 | "sh", |
| 149 | "-c", |
| 150 | "while true; do sleep 30; done", |
| 151 | ]); |
| 152 | expect(container?.portMappings?.[0]?.containerPort).toBe(80); |
| 153 | // `environmentFiles` lands on the synthesized primary container, with |
| 154 | // the execution role granted read access to the referenced object. |
| 155 | expect(container?.environmentFiles).toEqual([ |
| 156 | { |
| 157 | value: "arn:aws:s3:::alchemy-test-ecs-env-files/edge.env", |
| 158 | type: "s3", |
| 159 | }, |
| 160 | ]); |
| 161 | const envFilesPolicy = yield* iam.getRolePolicy({ |
| 162 | RoleName: deployed.executionRoleArn!.split(":role/")[1]!, |
| 163 | PolicyName: "alchemy-environment-files", |
| 164 | }); |
| 165 | expect(decodeURIComponent(envFilesPolicy.PolicyDocument ?? "")).toContain( |
| 166 | "arn:aws:s3:::alchemy-test-ecs-env-files/edge.env", |
| 167 | ); |
| 168 | |
| 169 | // Out-of-band: the service is wired to the generated target group. |
| 170 | const services = yield* ecs.describeServices({ |
| 171 | cluster: deployed.clusterArn, |
| 172 | services: [deployed.serviceName], |
| 173 | }); |
| 174 | const svc = services.services?.[0]; |
| 175 | expect(svc?.loadBalancers?.[0]?.targetGroupArn).toBe( |
| 176 | deployed.targetGroupArn, |
| 177 | ); |
| 178 | expect(svc?.loadBalancers?.[0]?.containerPort).toBe(80); |
| 179 | |
| 180 | yield* stack.destroy(); |
| 181 | |
| 182 | // Zero-orphan proofs: every owned resource is gone (or terminally |
| 183 | // deleting) after destroy. |
| 184 | const repoGone = yield* ecr |
| 185 | .describeRepositories({ |
| 186 | repositoryNames: [deployed.repositoryName!], |
| 187 | }) |
| 188 | .pipe( |
| 189 | Effect.map(() => false), |
| 190 | Effect.catchTag("RepositoryNotFoundException", () => |
| 191 | Effect.succeed(true), |
| 192 | ), |
| 193 | ); |
| 194 | expect(repoGone).toBe(true); |
| 195 | |
| 196 | const lbGone = yield* elbv2 |
| 197 | .describeLoadBalancers({ |
| 198 | LoadBalancerArns: [deployed.loadBalancerArn!], |
| 199 | }) |
| 200 | .pipe( |
| 201 | Effect.map((r) => (r.LoadBalancers ?? []).length === 0), |
| 202 | Effect.catchTag("LoadBalancerNotFoundException", () => |
| 203 | Effect.succeed(true), |
| 204 | ), |
| 205 | ); |
| 206 | expect(lbGone).toBe(true); |
| 207 | |
| 208 | const sgGone = yield* ec2 |
| 209 | .describeSecurityGroups({ GroupIds: [deployed.securityGroupId!] }) |
| 210 | .pipe( |
| 211 | Effect.map((r) => (r.SecurityGroups ?? []).length === 0), |
| 212 | Effect.catchTag("InvalidGroup.NotFound", () => Effect.succeed(true)), |
| 213 | ); |
| 214 | expect(sgGone).toBe(true); |
| 215 | |
| 216 | // The revision is deregistered (INACTIVE / DELETE_IN_PROGRESS) or |
| 217 | // already hard-deleted. |
| 218 | const taskDefGone = yield* ecs |
| 219 | .describeTaskDefinition({ |
| 220 | taskDefinition: deployed.taskDefinitionArn, |
| 221 | }) |
| 222 | .pipe( |
| 223 | Effect.map((r) => r.taskDefinition?.status !== "ACTIVE"), |
| 224 | Effect.catchTag("ClientException", () => Effect.succeed(true)), |
| 225 | ); |
| 226 | expect(taskDefGone).toBe(true); |
| 227 | |
| 228 | const clusters = yield* ecs.describeClusters({ |
| 229 | clusters: ["alchemy-test-ecs-service-platform"], |
| 230 | }); |
| 231 | expect((clusters.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe( |
| 232 | false, |
| 233 | ); |
| 234 | }), |
| 235 | { |
| 236 | tags: [ |
| 237 | "provider:aws", |
| 238 | "provider:aws:ec2", |
| 239 | "provider:aws:ecr", |
| 240 | "provider:aws:ecs", |
| 241 | "provider:aws:iam", |
| 242 | "live", |
| 243 | ], |
| 244 | timeout: 420_000, |
| 245 | }, |
| 246 | ); |
| 247 | |
| 248 | // Migration reap: state rows written by the pre-composition provider carry |
| 249 | // the inline-created ALB/TG/listener/SG ARNs in the service's own attributes |
| 250 | // with no `ingressKind` marker. The first reconcile under the composed shape |
| 251 | // must DELETE that legacy inline infrastructure (nothing stranded) and stamp |
| 252 | // the composed attrs. We simulate the legacy row exactly the way the #736 |
| 253 | // test simulates a wedged one: deploy under the new shape, create a real |
| 254 | // "legacy" ALB/TG/listener/SG out of band, then rewrite the service's state |
| 255 | // row to reference them (marker stripped, `ingress` prop removed, |
| 256 | // `loadBalancer: true`), and redeploy. |
| 257 | test.provider( |
| 258 | "legacy inline ingress is reaped on first reconcile under the composed shape", |
| 259 | (stack) => |
| 260 | Effect.gen(function* () { |
| 261 | yield* stack.destroy(); |
| 262 | const net = yield* getDefaultVpcNetwork; |
| 263 | |
| 264 | const program = Effect.gen(function* () { |
| 265 | const cluster = yield* Cluster("LegacyReapCluster", { |
| 266 | clusterName: "alchemy-test-ecs-service-legacy-reap", |
| 267 | }); |
| 268 | return yield* Service("LegacyReapEdge", { |
| 269 | cluster, |
| 270 | image: "busybox:stable", |
| 271 | command: ["sh", "-c", "while true; do sleep 30; done"], |
| 272 | port: 80, |
| 273 | desiredCount: 0, |
| 274 | loadBalancer: true, |
| 275 | }); |
| 276 | }); |
| 277 | |
| 278 | const deployed = yield* stack.deploy(program); |
| 279 | expect(deployed.loadBalancerArn).toBeTruthy(); |
| 280 | |
| 281 | // Out-of-band "legacy" inline ingress (never carried any traffic, so |
| 282 | // reap deletes are instant). Finalizers reclaim them if the test dies |
| 283 | // before the reap runs. |
| 284 | const legacyName = "alchemy-test-ecs-svc-legacy"; |
| 285 | // The scaffolding below is created out-of-band under a fixed name and |
| 286 | // reclaimed by finalizers. A hard kill (SIGKILL, machine death) skips |
| 287 | // finalizers, so the previous run's copy survives and the next run dies |
| 288 | // on DuplicateLoadBalancerName before it can test anything. Reclaim any |
| 289 | // same-named leftovers first so the suite is rerunnable after a crash. |
| 290 | // |
| 291 | // This is scaffolding hygiene, NOT adoption of a leaked stack-managed |
| 292 | // resource: these are fixtures the test itself creates, and the reap |
| 293 | // under test is unaffected by whether they pre-existed. |
| 294 | yield* reclaimLegacyFixtures(legacyName, net.vpcId); |
| 295 | const legacySubnets = yield* defaultAzSubnets(net.vpcId); |
| 296 | const legacyLb = yield* elbv2.createLoadBalancer({ |
| 297 | Name: legacyName, |
| 298 | Type: "application", |
| 299 | Scheme: "internal", |
| 300 | Subnets: legacySubnets, |
| 301 | }); |
| 302 | const legacyLbArn = legacyLb.LoadBalancers?.[0]?.LoadBalancerArn!; |
| 303 | yield* Effect.addFinalizer(() => |
| 304 | elbv2 |
| 305 | .deleteLoadBalancer({ LoadBalancerArn: legacyLbArn }) |
| 306 | .pipe(Effect.ignore), |
| 307 | ); |
| 308 | const legacyTg = yield* elbv2.createTargetGroup({ |
| 309 | Name: legacyName, |
| 310 | VpcId: net.vpcId, |
| 311 | TargetType: "ip", |
| 312 | Protocol: "HTTP", |
| 313 | Port: 80, |
| 314 | }); |
| 315 | const legacyTgArn = legacyTg.TargetGroups?.[0]?.TargetGroupArn!; |
| 316 | yield* Effect.addFinalizer(() => |
| 317 | elbv2 |
| 318 | .deleteTargetGroup({ TargetGroupArn: legacyTgArn }) |
| 319 | .pipe(Effect.ignore), |
| 320 | ); |
| 321 | const legacyListener = yield* elbv2.createListener({ |
| 322 | LoadBalancerArn: legacyLbArn, |
| 323 | Port: 80, |
| 324 | Protocol: "HTTP", |
| 325 | DefaultActions: [{ Type: "forward", TargetGroupArn: legacyTgArn }], |
| 326 | }); |
| 327 | const legacyListenerArn = legacyListener.Listeners?.[0]?.ListenerArn!; |
| 328 | yield* Effect.addFinalizer(() => |
| 329 | elbv2 |
| 330 | .deleteListener({ ListenerArn: legacyListenerArn }) |
| 331 | .pipe(Effect.ignore), |
| 332 | ); |
| 333 | const legacySg = yield* ec2.createSecurityGroup({ |
| 334 | GroupName: legacyName, |
| 335 | Description: "legacy inline ingress SG (reap test)", |
| 336 | VpcId: net.vpcId, |
| 337 | }); |
| 338 | const legacySgId = legacySg.GroupId!; |
| 339 | yield* Effect.addFinalizer(() => |
| 340 | ec2.deleteSecurityGroup({ GroupId: legacySgId }).pipe(Effect.ignore), |
| 341 | ); |
| 342 | |
| 343 | // Rewrite the service's state row into the legacy inline shape. |
| 344 | const state = yield* yield* State; |
| 345 | const stage = stack.stage; |
| 346 | const fqns = yield* state.list({ stack: stack.name, stage }); |
| 347 | const rows = yield* Effect.forEach(fqns, (fqn) => |
| 348 | state |
| 349 | .get({ stack: stack.name, stage, fqn }) |
| 350 | .pipe(Effect.map((row) => ({ fqn, row }))), |
| 351 | ); |
| 352 | const serviceRow = rows.find( |
| 353 | (r): r is { fqn: string; row: ResourceState } => |
| 354 | isResourceState(r.row) && r.row.resourceType === "AWS.ECS.Service", |
| 355 | ); |
| 356 | if (!serviceRow) { |
| 357 | return yield* Effect.die( |
| 358 | new Error("no AWS.ECS.Service state row found after deploy"), |
| 359 | ); |
| 360 | } |
| 361 | yield* state.set({ |
| 362 | stack: stack.name, |
| 363 | stage, |
| 364 | fqn: serviceRow.fqn, |
| 365 | value: { |
| 366 | ...serviceRow.row, |
| 367 | attr: { |
| 368 | ...(serviceRow.row.attr as object), |
| 369 | // Legacy rows have the inline ARNs and NO ingressKind marker. |
| 370 | ingressKind: undefined, |
| 371 | loadBalancerArn: legacyLbArn, |
| 372 | targetGroupArn: legacyTgArn, |
| 373 | listenerArn: legacyListenerArn, |
| 374 | securityGroupId: legacySgId, |
| 375 | url: "http://legacy.invalid", |
| 376 | }, |
| 377 | props: { |
| 378 | ...serviceRow.row.props, |
| 379 | ingress: undefined, |
| 380 | loadBalancer: true, |
| 381 | }, |
| 382 | }, |
| 383 | }); |
| 384 | |
| 385 | // Redeploy: reconcile must reap the legacy inline infra and stamp the |
| 386 | // composed attrs. |
| 387 | const redeployed = yield* stack.deploy(program); |
| 388 | expect(redeployed.ingressKind).toBe("owned"); |
| 389 | expect(redeployed.loadBalancerArn).toBeTruthy(); |
| 390 | expect(redeployed.loadBalancerArn).not.toBe(legacyLbArn); |
| 391 | expect(redeployed.targetGroupArn).not.toBe(legacyTgArn); |
| 392 | |
| 393 | // The redeploy registered a NEW task-definition revision — the |
| 394 | // superseded one must be reaped (deregistered + deleted), not left |
| 395 | // ACTIVE forever. This is the exact leak this test used to produce: |
| 396 | // one stranded ACTIVE revision per run. |
| 397 | expect(redeployed.taskDefinitionArn).not.toBe(deployed.taskDefinitionArn); |
| 398 | const supersededReaped = yield* ecs |
| 399 | .describeTaskDefinition({ |
| 400 | taskDefinition: deployed.taskDefinitionArn, |
| 401 | }) |
| 402 | .pipe( |
| 403 | Effect.map((r) => r.taskDefinition?.status !== "ACTIVE"), |
| 404 | Effect.catchTag("ClientException", () => Effect.succeed(true)), |
| 405 | ); |
| 406 | expect(supersededReaped).toBe(true); |
| 407 | |
| 408 | // The legacy inline resources are gone (nothing stranded). The ALB |
| 409 | // deletion is async — poll bounded. |
| 410 | const legacyLbGone = yield* elbv2 |
| 411 | .describeLoadBalancers({ LoadBalancerArns: [legacyLbArn] }) |
| 412 | .pipe( |
| 413 | Effect.map((r) => (r.LoadBalancers ?? []).length === 0), |
| 414 | Effect.catchTag("LoadBalancerNotFoundException", () => |
| 415 | Effect.succeed(true), |
| 416 | ), |
| 417 | Effect.repeat({ |
| 418 | schedule: Schedule.spaced("5 seconds"), |
| 419 | until: (gone) => gone, |
| 420 | times: 12, |
| 421 | }), |
| 422 | ); |
| 423 | expect(legacyLbGone).toBe(true); |
| 424 | const legacyTgGone = yield* elbv2 |
| 425 | .describeTargetGroups({ TargetGroupArns: [legacyTgArn] }) |
| 426 | .pipe( |
| 427 | Effect.map((r) => (r.TargetGroups ?? []).length === 0), |
| 428 | Effect.catchTag("TargetGroupNotFoundException", () => |
| 429 | Effect.succeed(true), |
| 430 | ), |
| 431 | ); |
| 432 | expect(legacyTgGone).toBe(true); |
| 433 | const legacySgGone = yield* ec2 |
| 434 | .describeSecurityGroups({ GroupIds: [legacySgId] }) |
| 435 | .pipe( |
| 436 | Effect.map((r) => (r.SecurityGroups ?? []).length === 0), |
| 437 | Effect.catchTag("InvalidGroup.NotFound", () => Effect.succeed(true)), |
| 438 | ); |
| 439 | expect(legacySgGone).toBe(true); |
| 440 | |
| 441 | yield* stack.destroy(); |
| 442 | |
| 443 | // The composed ingress is gone after destroy too. |
| 444 | const composedLbGone = yield* elbv2 |
| 445 | .describeLoadBalancers({ |
| 446 | LoadBalancerArns: [redeployed.loadBalancerArn!], |
| 447 | }) |
| 448 | .pipe( |
| 449 | Effect.map((r) => (r.LoadBalancers ?? []).length === 0), |
| 450 | Effect.catchTag("LoadBalancerNotFoundException", () => |
| 451 | Effect.succeed(true), |
| 452 | ), |
| 453 | ); |
| 454 | expect(composedLbGone).toBe(true); |
| 455 | |
| 456 | // Zero-orphan proof for the synthesized task definition: NO revision |
| 457 | // of the family survives destroy as ACTIVE (deploy + redeploy |
| 458 | // registered two revisions; both must be gone). |
| 459 | const activeRevisions = yield* ecs |
| 460 | .listTaskDefinitions({ |
| 461 | familyPrefix: redeployed.taskFamily!, |
| 462 | status: "ACTIVE", |
| 463 | }) |
| 464 | .pipe( |
| 465 | Effect.map((r) => |
| 466 | (r.taskDefinitionArns ?? []).filter((arn) => |
| 467 | arn.includes(`/${redeployed.taskFamily!}:`), |
| 468 | ), |
| 469 | ), |
| 470 | Effect.catchTag("ClientException", () => |
| 471 | Effect.succeed([] as string[]), |
| 472 | ), |
| 473 | ); |
| 474 | expect(activeRevisions).toEqual([]); |
| 475 | }), |
| 476 | { |
| 477 | tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"], |
| 478 | timeout: 420_000, |
| 479 | }, |
| 480 | ); |