| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Subnet } from "@/AWS/EC2"; |
| 3 | import { Cluster } from "@/AWS/ECS/Cluster.ts"; |
| 4 | import { Service } from "@/AWS/ECS/Service.ts"; |
| 5 | import { TaskDefinition } from "@/AWS/ECS/TaskDefinition.ts"; |
| 6 | import { Role } from "@/AWS/IAM/Role.ts"; |
| 7 | import * as Test from "@/Test/Alchemy"; |
| 8 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 9 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 10 | import * as ecs from "@distilled.cloud/aws/ecs"; |
| 11 | import { expect } from "alchemy-test"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import { getDefaultVpc } from "../DefaultVpc.ts"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: AWS.providers() }); |
| 17 | |
| 18 | // Latest ACTIVE revision of a family, or undefined when none exists |
| 19 | // (describeTaskDefinition by family rejects with ClientException once every |
| 20 | // revision is INACTIVE/deleted). |
| 21 | const describeActive = (family: string) => |
| 22 | ecs.describeTaskDefinition({ taskDefinition: family }).pipe( |
| 23 | Effect.map((d) => |
| 24 | d.taskDefinition?.status === "ACTIVE" ? d.taskDefinition : undefined, |
| 25 | ), |
| 26 | Effect.catchTag("ClientException", () => Effect.succeed(undefined)), |
| 27 | ); |
| 28 | |
| 29 | const listExactFamily = (family: string, status: "ACTIVE" | "INACTIVE") => |
| 30 | ecs |
| 31 | .listTaskDefinitions({ familyPrefix: family, status }) |
| 32 | .pipe( |
| 33 | Effect.map((result) => |
| 34 | (result.taskDefinitionArns ?? []).filter((arn) => |
| 35 | arn.includes(`/${family}:`), |
| 36 | ), |
| 37 | ), |
| 38 | ); |
| 39 | |
| 40 | // Lifecycle: register -> no-op redeploy (same revision) -> content change |
| 41 | // (new revision, same family) -> destroy (all revisions deregistered, managed |
| 42 | // log group deleted). |
| 43 | test.provider( |
| 44 | "registers immutable revisions per content change", |
| 45 | (stack) => |
| 46 | Effect.gen(function* () { |
| 47 | yield* stack.destroy(); |
| 48 | |
| 49 | const family = "alchemy-test-ecs-taskdef-life"; |
| 50 | |
| 51 | const deployTaskDefinition = (env: string) => |
| 52 | stack.deploy( |
| 53 | Effect.gen(function* () { |
| 54 | // Fargate rejects the awslogs driver without an execution role — |
| 55 | // also exercises passing an IAM Role resource as the role ref. |
| 56 | const executionRole = yield* Role("LifecycleExecutionRole", { |
| 57 | assumeRolePolicyDocument: { |
| 58 | Version: "2012-10-17", |
| 59 | Statement: [ |
| 60 | { |
| 61 | Effect: "Allow", |
| 62 | Principal: { Service: "ecs-tasks.amazonaws.com" }, |
| 63 | Action: ["sts:AssumeRole"], |
| 64 | }, |
| 65 | ], |
| 66 | }, |
| 67 | managedPolicyArns: [ |
| 68 | "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", |
| 69 | ], |
| 70 | }); |
| 71 | return yield* TaskDefinition("LifecycleTaskDef", { |
| 72 | family, |
| 73 | executionRoleArn: executionRole, |
| 74 | containerDefinitions: [ |
| 75 | { |
| 76 | name: "app", |
| 77 | image: "public.ecr.aws/docker/library/busybox:stable", |
| 78 | essential: true, |
| 79 | command: ["sleep", "3600"], |
| 80 | environment: [{ name: "FOO", value: env }], |
| 81 | portMappings: [{ containerPort: 8080 }], |
| 82 | }, |
| 83 | ], |
| 84 | awslogs: true, |
| 85 | tags: { env: "test" }, |
| 86 | }); |
| 87 | }), |
| 88 | ); |
| 89 | |
| 90 | // Create — registers the first revision for this run. |
| 91 | const created = yield* deployTaskDefinition("one"); |
| 92 | expect(created.family).toBe(family); |
| 93 | expect(created.containerName).toBe("app"); |
| 94 | expect(created.port).toBe(8080); |
| 95 | expect(created.logGroupName).toBe(`/ecs/${family}`); |
| 96 | |
| 97 | // Out-of-band: revision is ACTIVE, awslogs config was injected, tags |
| 98 | // (internal + user) landed on the revision. |
| 99 | const observed = yield* ecs.describeTaskDefinition({ |
| 100 | taskDefinition: created.taskDefinitionArn, |
| 101 | include: ["TAGS"], |
| 102 | }); |
| 103 | expect(observed.taskDefinition?.status).toBe("ACTIVE"); |
| 104 | const container = observed.taskDefinition?.containerDefinitions?.[0]; |
| 105 | expect(container?.logConfiguration?.logDriver).toBe("awslogs"); |
| 106 | expect(container?.logConfiguration?.options?.["awslogs-group"]).toBe( |
| 107 | `/ecs/${family}`, |
| 108 | ); |
| 109 | const tagMap = Object.fromEntries( |
| 110 | (observed.tags ?? []).map((t) => [t.key, t.value]), |
| 111 | ); |
| 112 | expect(tagMap.env).toBe("test"); |
| 113 | expect(tagMap["alchemy::id"]).toBe("LifecycleTaskDef"); |
| 114 | |
| 115 | // The managed log group exists. |
| 116 | const groups = yield* logs.describeLogGroups({ |
| 117 | logGroupNamePrefix: `/ecs/${family}`, |
| 118 | }); |
| 119 | expect( |
| 120 | groups.logGroups?.some((g) => g.logGroupName === `/ecs/${family}`), |
| 121 | ).toBe(true); |
| 122 | |
| 123 | // No-op redeploy — identical content must NOT register a new revision. |
| 124 | const same = yield* deployTaskDefinition("one"); |
| 125 | expect(same.taskDefinitionArn).toBe(created.taskDefinitionArn); |
| 126 | expect(same.revision).toBe(created.revision); |
| 127 | |
| 128 | // Content change — a new revision under the same family. |
| 129 | const changed = yield* deployTaskDefinition("two"); |
| 130 | expect(changed.family).toBe(family); |
| 131 | expect(changed.revision).toBeGreaterThan(created.revision); |
| 132 | expect(changed.taskDefinitionArn).not.toBe(created.taskDefinitionArn); |
| 133 | |
| 134 | // Destroy — every ACTIVE revision of the family is deregistered and the |
| 135 | // managed log group is deleted. |
| 136 | yield* stack.destroy(); |
| 137 | |
| 138 | const remaining = yield* describeActive(family); |
| 139 | expect(remaining).toBeUndefined(); |
| 140 | expect(yield* listExactFamily(family, "ACTIVE")).toEqual([]); |
| 141 | expect(yield* listExactFamily(family, "INACTIVE")).toEqual([]); |
| 142 | |
| 143 | const groupsAfter = yield* logs.describeLogGroups({ |
| 144 | logGroupNamePrefix: `/ecs/${family}`, |
| 145 | }); |
| 146 | expect( |
| 147 | groupsAfter.logGroups?.some((g) => g.logGroupName === `/ecs/${family}`), |
| 148 | ).toBe(false); |
| 149 | }), |
| 150 | { tags: ["provider:aws", "provider:aws:ecs", "provider:aws:iam", "live"] }, |
| 151 | ); |
| 152 | |
| 153 | // Family change replaces the resource: a fresh family is registered and the |
| 154 | // old family's revisions are deregistered by the replacement delete. |
| 155 | test.provider( |
| 156 | "replaces the resource when the family changes", |
| 157 | (stack) => |
| 158 | Effect.gen(function* () { |
| 159 | yield* stack.destroy(); |
| 160 | |
| 161 | const familyA = "alchemy-test-ecs-taskdef-repl-a"; |
| 162 | const familyB = "alchemy-test-ecs-taskdef-repl-b"; |
| 163 | |
| 164 | const deployTaskDefinition = (family: string) => |
| 165 | stack.deploy( |
| 166 | Effect.gen(function* () { |
| 167 | return yield* TaskDefinition("ReplaceTaskDef", { |
| 168 | family, |
| 169 | containerDefinitions: [ |
| 170 | { |
| 171 | name: "app", |
| 172 | image: "public.ecr.aws/docker/library/busybox:stable", |
| 173 | essential: true, |
| 174 | command: ["sleep", "3600"], |
| 175 | }, |
| 176 | ], |
| 177 | }); |
| 178 | }), |
| 179 | ); |
| 180 | |
| 181 | const created = yield* deployTaskDefinition(familyA); |
| 182 | expect(created.family).toBe(familyA); |
| 183 | expect(yield* describeActive(familyA)).toBeDefined(); |
| 184 | |
| 185 | const replaced = yield* deployTaskDefinition(familyB); |
| 186 | expect(replaced.family).toBe(familyB); |
| 187 | expect(replaced.taskDefinitionArn).not.toBe(created.taskDefinitionArn); |
| 188 | expect(yield* describeActive(familyB)).toBeDefined(); |
| 189 | // Old family fully deregistered by the replacement delete. |
| 190 | expect(yield* describeActive(familyA)).toBeUndefined(); |
| 191 | expect(yield* listExactFamily(familyA, "ACTIVE")).toEqual([]); |
| 192 | expect(yield* listExactFamily(familyA, "INACTIVE")).toEqual([]); |
| 193 | |
| 194 | yield* stack.destroy(); |
| 195 | expect(yield* describeActive(familyB)).toBeUndefined(); |
| 196 | expect(yield* listExactFamily(familyB, "ACTIVE")).toEqual([]); |
| 197 | expect(yield* listExactFamily(familyB, "INACTIVE")).toEqual([]); |
| 198 | }), |
| 199 | { tags: ["provider:aws", "provider:aws:ecs", "live"] }, |
| 200 | ); |
| 201 | |
| 202 | // BYO container end-to-end: a public nginx image (no build step) registered |
| 203 | // via TaskDefinition, wired into Service by passing the RESOURCE itself as the |
| 204 | // `task` prop (its taskDefinitionArn/containerName/port attributes satisfy the |
| 205 | // structural contract), launched on Fargate in a public subnet with a public |
| 206 | // IP, and observed reaching runningCount >= 1. |
| 207 | test.provider( |
| 208 | "service runs a BYO task definition on Fargate", |
| 209 | (stack) => |
| 210 | Effect.gen(function* () { |
| 211 | yield* stack.destroy(); |
| 212 | |
| 213 | const azResult = yield* ec2.describeAvailabilityZones({}); |
| 214 | const az = (azResult.AvailabilityZones ?? []).find( |
| 215 | (z) => z.State === "available", |
| 216 | )?.ZoneName!; |
| 217 | const defaultVpc = yield* getDefaultVpc; |
| 218 | |
| 219 | const out = yield* stack.deploy( |
| 220 | Effect.gen(function* () { |
| 221 | // Reuse the default VPC's public routing. This is ECS coverage, so |
| 222 | // allocating another VPC only makes the suite contend for EC2's |
| 223 | // small regional VPC quota during high-concurrency sweeps. |
| 224 | const subnet = yield* Subnet("ByoSubnet", { |
| 225 | vpcId: defaultVpc.vpcId, |
| 226 | cidrBlock: defaultVpc.subnetCidrBlock(239), |
| 227 | availabilityZone: az, |
| 228 | mapPublicIpOnLaunch: true, |
| 229 | }); |
| 230 | |
| 231 | // BYO execution role (tests the IAM Role resource ref on |
| 232 | // executionRoleArn) — needed by the awslogs log driver. |
| 233 | const executionRole = yield* Role("ByoExecutionRole", { |
| 234 | assumeRolePolicyDocument: { |
| 235 | Version: "2012-10-17", |
| 236 | Statement: [ |
| 237 | { |
| 238 | Effect: "Allow", |
| 239 | Principal: { Service: "ecs-tasks.amazonaws.com" }, |
| 240 | Action: ["sts:AssumeRole"], |
| 241 | }, |
| 242 | ], |
| 243 | }, |
| 244 | managedPolicyArns: [ |
| 245 | "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", |
| 246 | ], |
| 247 | }); |
| 248 | |
| 249 | const taskDefinition = yield* TaskDefinition("ByoNginx", { |
| 250 | family: "alchemy-test-ecs-taskdef-byo", |
| 251 | executionRoleArn: executionRole, |
| 252 | awslogs: true, |
| 253 | containerDefinitions: [ |
| 254 | { |
| 255 | name: "nginx", |
| 256 | image: "public.ecr.aws/nginx/nginx:stable", |
| 257 | essential: true, |
| 258 | portMappings: [{ containerPort: 80, protocol: "tcp" }], |
| 259 | }, |
| 260 | ], |
| 261 | }); |
| 262 | |
| 263 | const cluster = yield* Cluster("ByoCluster", { |
| 264 | clusterName: "alchemy-test-ecs-taskdef-byo", |
| 265 | }); |
| 266 | |
| 267 | const service = yield* Service("ByoService", { |
| 268 | cluster, |
| 269 | // Whole TaskDefinition resource as the `task` prop — the BYO |
| 270 | // wiring under test. |
| 271 | task: taskDefinition, |
| 272 | desiredCount: 1, |
| 273 | vpcId: defaultVpc.vpcId, |
| 274 | subnets: [subnet.subnetId], |
| 275 | assignPublicIp: true, |
| 276 | }); |
| 277 | |
| 278 | return { |
| 279 | clusterArn: service.clusterArn.as<string>(), |
| 280 | serviceName: service.serviceName.as<string>(), |
| 281 | serviceTaskDefinitionArn: service.taskDefinitionArn.as<string>(), |
| 282 | taskDefinitionArn: taskDefinition.taskDefinitionArn.as<string>(), |
| 283 | }; |
| 284 | }), |
| 285 | ); |
| 286 | |
| 287 | // The service deploys exactly the BYO revision. |
| 288 | expect(out.serviceTaskDefinitionArn).toBe(out.taskDefinitionArn); |
| 289 | |
| 290 | // The container actually starts: poll (bounded) until runningCount >= 1. |
| 291 | const running = yield* ecs |
| 292 | .describeServices({ |
| 293 | cluster: out.clusterArn, |
| 294 | services: [out.serviceName], |
| 295 | }) |
| 296 | .pipe( |
| 297 | Effect.map((d) => d.services?.[0]?.runningCount ?? 0), |
| 298 | Effect.repeat({ |
| 299 | schedule: Schedule.spaced("8 seconds"), |
| 300 | until: (count) => count >= 1, |
| 301 | times: 20, |
| 302 | }), |
| 303 | ); |
| 304 | expect(running).toBeGreaterThanOrEqual(1); |
| 305 | |
| 306 | yield* stack.destroy(); |
| 307 | |
| 308 | // Out-of-band: the family has no ACTIVE revision left. |
| 309 | expect( |
| 310 | yield* describeActive("alchemy-test-ecs-taskdef-byo"), |
| 311 | ).toBeUndefined(); |
| 312 | |
| 313 | // And the cluster is gone too (deleted clusters report INACTIVE). |
| 314 | const clustersAfter = yield* ecs.describeClusters({ |
| 315 | clusters: ["alchemy-test-ecs-taskdef-byo"], |
| 316 | }); |
| 317 | expect( |
| 318 | (clustersAfter.clusters ?? []).some((c) => c.status === "ACTIVE"), |
| 319 | ).toBe(false); |
| 320 | }), |
| 321 | { |
| 322 | tags: [ |
| 323 | "provider:aws", |
| 324 | "provider:aws:ec2", |
| 325 | "provider:aws:ecs", |
| 326 | "provider:aws:iam", |
| 327 | "live", |
| 328 | ], |
| 329 | timeout: 420_000, |
| 330 | }, |
| 331 | ); |