| 1 | import * as AWS from "@/AWS"; |
| 2 | import { AWSEnvironment } from "@/AWS/Environment"; |
| 3 | import { Task } from "@/AWS/ECS/Task.ts"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as ecs from "@distilled.cloud/aws/ecs"; |
| 7 | import * as iam from "@distilled.cloud/aws/iam"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import { reclaimTaskDefinitionFamily } from "./reclaimTaskDefinitionFamily.ts"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: AWS.providers() }); |
| 13 | |
| 14 | // Deploying a full `Task` requires a Docker build + ECR push, which is far too |
| 15 | // heavy for a `list()` test. Instead we register a task definition out-of-band |
| 16 | // in the exact shape the provider produces (single container whose image is an |
| 17 | // ECR `<repoUri>:<hash>`, task/execution role ARNs, an awslogs log group), then |
| 18 | // assert `list()` reconstructs the full Attributes and includes it, and finally |
| 19 | // deregister it. This live-verifies the listTaskDefinitions -> describe -> |
| 20 | // Attributes reconstruction path without the container build overhead. |
| 21 | test.provider( |
| 22 | "list enumerates registered task definitions", |
| 23 | () => |
| 24 | Effect.gen(function* () { |
| 25 | const { accountId, region } = yield* AWSEnvironment.current; |
| 26 | |
| 27 | const family = "alchemy-test-ecs-task-list"; |
| 28 | const repositoryName = "alchemy-test-ecs-task-list-repo"; |
| 29 | const repositoryUri = `${accountId}.dkr.ecr.${region}.amazonaws.com/${repositoryName}`; |
| 30 | const hash = "listtest"; |
| 31 | const imageUri = `${repositoryUri}:${hash}`; |
| 32 | const logGroupName = "/alchemy/test/ecs-task-list"; |
| 33 | const taskRoleName = "alchemy-test-ecs-task-list-task-role"; |
| 34 | const executionRoleName = "alchemy-test-ecs-task-list-execution-role"; |
| 35 | |
| 36 | // Reclaim any revisions a previously-killed run left behind, and |
| 37 | // guarantee full deletion (deregister + delete) on success, failure, |
| 38 | // and interruption. |
| 39 | yield* reclaimTaskDefinitionFamily(family); |
| 40 | yield* Effect.addFinalizer(() => |
| 41 | reclaimTaskDefinitionFamily(family).pipe(Effect.ignore), |
| 42 | ); |
| 43 | |
| 44 | const registered = yield* ecs.registerTaskDefinition({ |
| 45 | family, |
| 46 | taskRoleArn: `arn:aws:iam::${accountId}:role/${taskRoleName}`, |
| 47 | executionRoleArn: `arn:aws:iam::${accountId}:role/${executionRoleName}`, |
| 48 | networkMode: "awsvpc", |
| 49 | requiresCompatibilities: ["FARGATE"], |
| 50 | cpu: "256", |
| 51 | memory: "512", |
| 52 | containerDefinitions: [ |
| 53 | { |
| 54 | name: family, |
| 55 | image: imageUri, |
| 56 | essential: true, |
| 57 | portMappings: [ |
| 58 | { containerPort: 3000, hostPort: 3000, protocol: "tcp" }, |
| 59 | ], |
| 60 | logConfiguration: { |
| 61 | logDriver: "awslogs", |
| 62 | options: { |
| 63 | "awslogs-group": logGroupName, |
| 64 | "awslogs-region": region, |
| 65 | "awslogs-stream-prefix": family, |
| 66 | }, |
| 67 | }, |
| 68 | }, |
| 69 | ], |
| 70 | }); |
| 71 | const arn = registered.taskDefinition?.taskDefinitionArn; |
| 72 | expect(arn).toBeDefined(); |
| 73 | |
| 74 | const provider = yield* Provider.findProvider(Task); |
| 75 | const all = yield* provider.list(); |
| 76 | |
| 77 | const found = all.find((t) => t.taskDefinitionArn === arn); |
| 78 | expect(found).toBeDefined(); |
| 79 | expect(found?.taskFamily).toBe(family); |
| 80 | expect(found?.containerName).toBe(family); |
| 81 | expect(found?.port).toBe(3000); |
| 82 | expect(found?.imageUri).toBe(imageUri); |
| 83 | expect(found?.repositoryName).toBe(repositoryName); |
| 84 | expect(found?.repositoryUri).toBe(repositoryUri); |
| 85 | expect(found?.code.hash).toBe(hash); |
| 86 | expect(found?.logGroupName).toBe(logGroupName); |
| 87 | expect(found?.logGroupArn).toBe( |
| 88 | `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`, |
| 89 | ); |
| 90 | expect(found?.taskRoleName).toBe(taskRoleName); |
| 91 | expect(found?.executionRoleName).toBe(executionRoleName); |
| 92 | |
| 93 | yield* reclaimTaskDefinitionFamily(family); |
| 94 | }), |
| 95 | { tags: ["provider:aws", "provider:aws:ecs", "live"], timeout: 240_000 }, |
| 96 | ); |
| 97 | |
| 98 | // Multi-container + task-level props round-trip. Registering a task definition |
| 99 | // is cheap (no Docker build), so we exercise the full typed surface |
| 100 | // out-of-band: a 2-container task (app + sidecar with dependsOn/portMappings/ |
| 101 | // logConfiguration), task-level ephemeralStorage, runtimePlatform (ARM64), and |
| 102 | // an EFS-less host volume, then describe it back and assert the shapes |
| 103 | // survived, then deregister. This validates that the distilled |
| 104 | // registerTaskDefinition surface we wire in `Task` is correct. |
| 105 | test.provider( |
| 106 | "multi-container task definition round-trips task-level props", |
| 107 | () => |
| 108 | Effect.gen(function* () { |
| 109 | const family = "alchemy-test-ecs-task-multicontainer"; |
| 110 | |
| 111 | // Reclaim any revisions a previously-killed run left behind, and |
| 112 | // guarantee full deletion of BOTH revisions registered below on |
| 113 | // success, failure, and interruption. |
| 114 | yield* reclaimTaskDefinitionFamily(family); |
| 115 | yield* Effect.addFinalizer(() => |
| 116 | reclaimTaskDefinitionFamily(family).pipe(Effect.ignore), |
| 117 | ); |
| 118 | |
| 119 | const registered = yield* ecs.registerTaskDefinition({ |
| 120 | family, |
| 121 | networkMode: "awsvpc", |
| 122 | requiresCompatibilities: ["FARGATE"], |
| 123 | cpu: "256", |
| 124 | memory: "512", |
| 125 | runtimePlatform: { |
| 126 | cpuArchitecture: "ARM64", |
| 127 | operatingSystemFamily: "LINUX", |
| 128 | }, |
| 129 | ephemeralStorage: { sizeInGiB: 25 }, |
| 130 | volumes: [{ name: "scratch", host: {} }], |
| 131 | containerDefinitions: [ |
| 132 | { |
| 133 | name: "app", |
| 134 | image: "public.ecr.aws/nginx/nginx:stable", |
| 135 | essential: true, |
| 136 | portMappings: [{ containerPort: 80, protocol: "tcp" }], |
| 137 | mountPoints: [ |
| 138 | { sourceVolume: "scratch", containerPath: "/scratch" }, |
| 139 | ], |
| 140 | dependsOn: [{ containerName: "sidecar", condition: "START" }], |
| 141 | }, |
| 142 | { |
| 143 | name: "sidecar", |
| 144 | image: "public.ecr.aws/docker/library/busybox:latest", |
| 145 | essential: false, |
| 146 | command: ["sh", "-c", "while true; do sleep 30; done"], |
| 147 | }, |
| 148 | ], |
| 149 | }); |
| 150 | const td = registered.taskDefinition; |
| 151 | const arn = td?.taskDefinitionArn; |
| 152 | expect(arn).toBeDefined(); |
| 153 | |
| 154 | const described = yield* ecs.describeTaskDefinition({ |
| 155 | taskDefinition: arn!, |
| 156 | }); |
| 157 | const def = described.taskDefinition; |
| 158 | expect(def?.containerDefinitions?.length).toBe(2); |
| 159 | expect(def?.containerDefinitions?.map((c) => c.name)).toEqual([ |
| 160 | "app", |
| 161 | "sidecar", |
| 162 | ]); |
| 163 | expect( |
| 164 | def?.containerDefinitions?.[0]?.dependsOn?.[0]?.containerName, |
| 165 | ).toBe("sidecar"); |
| 166 | expect(def?.runtimePlatform?.cpuArchitecture).toBe("ARM64"); |
| 167 | expect(def?.ephemeralStorage?.sizeInGiB).toBe(25); |
| 168 | expect(def?.volumes?.[0]?.name).toBe("scratch"); |
| 169 | |
| 170 | // Update one container (new image) → new revision number. |
| 171 | const updated = yield* ecs.registerTaskDefinition({ |
| 172 | family, |
| 173 | networkMode: "awsvpc", |
| 174 | requiresCompatibilities: ["FARGATE"], |
| 175 | cpu: "256", |
| 176 | memory: "512", |
| 177 | containerDefinitions: [ |
| 178 | { |
| 179 | name: "app", |
| 180 | image: "public.ecr.aws/nginx/nginx:latest", |
| 181 | essential: true, |
| 182 | portMappings: [{ containerPort: 80, protocol: "tcp" }], |
| 183 | }, |
| 184 | ], |
| 185 | }); |
| 186 | expect(updated.taskDefinition?.revision).toBeGreaterThan(td!.revision!); |
| 187 | |
| 188 | yield* reclaimTaskDefinitionFamily(family); |
| 189 | }), |
| 190 | { tags: ["provider:aws", "provider:aws:ecs", "live"], timeout: 120_000 }, |
| 191 | ); |
| 192 | |
| 193 | // Revision hygiene: every reconcile registers a NEW task-definition |
| 194 | // revision, so the superseded revision must be reaped (deregistered + hard |
| 195 | // deleted) on the spot, and destroy must sweep every remaining revision of |
| 196 | // the family. Uses the registry-image form (`image:` mirrored into ECR via a |
| 197 | // local docker pull/tag/push of a tiny busybox) — no Fargate placement, no |
| 198 | // bundling. Requires a local Docker daemon (same as the Service platform |
| 199 | // tests). |
| 200 | test.provider( |
| 201 | "superseded task-definition revisions are reaped on reconcile and destroy", |
| 202 | (stack) => |
| 203 | Effect.gen(function* () { |
| 204 | yield* stack.destroy(); |
| 205 | |
| 206 | const program = (marker: string) => |
| 207 | Effect.gen(function* () { |
| 208 | return yield* Task("RevisionReapTask", { |
| 209 | // Docker Hub busybox: the public.ecr.aws mirror aggressively |
| 210 | // rate-limits anonymous pulls during local builds. |
| 211 | image: "busybox:stable", |
| 212 | command: ["sh", "-c", "true"], |
| 213 | cpu: 256, |
| 214 | memory: 512, |
| 215 | env: { MARKER: marker }, |
| 216 | }); |
| 217 | }); |
| 218 | |
| 219 | const deployed = yield* stack.deploy(program("one")); |
| 220 | expect(deployed.taskDefinitionArn).toBeTruthy(); |
| 221 | |
| 222 | // Redeploy with a changed env var → a new revision in the same family. |
| 223 | const redeployed = yield* stack.deploy(program("two")); |
| 224 | expect(redeployed.taskFamily).toBe(deployed.taskFamily); |
| 225 | expect(redeployed.taskDefinitionArn).not.toBe(deployed.taskDefinitionArn); |
| 226 | |
| 227 | const newRevision = yield* ecs.describeTaskDefinition({ |
| 228 | taskDefinition: redeployed.taskDefinitionArn, |
| 229 | }); |
| 230 | expect(newRevision.taskDefinition?.status).toBe("ACTIVE"); |
| 231 | expect( |
| 232 | newRevision.taskDefinition?.containerDefinitions?.[0]?.environment?.find( |
| 233 | (e) => e.name === "MARKER", |
| 234 | )?.value, |
| 235 | ).toBe("two"); |
| 236 | |
| 237 | // The superseded revision was reaped by the redeploy, not left ACTIVE. |
| 238 | const supersededReaped = yield* ecs |
| 239 | .describeTaskDefinition({ |
| 240 | taskDefinition: deployed.taskDefinitionArn, |
| 241 | }) |
| 242 | .pipe( |
| 243 | Effect.map((r) => r.taskDefinition?.status !== "ACTIVE"), |
| 244 | Effect.catchTag("ClientException", () => Effect.succeed(true)), |
| 245 | ); |
| 246 | expect(supersededReaped).toBe(true); |
| 247 | |
| 248 | yield* stack.destroy(); |
| 249 | |
| 250 | // Zero-orphan proof: no revision of the family survives destroy as |
| 251 | // ACTIVE. |
| 252 | const activeRevisions = yield* ecs |
| 253 | .listTaskDefinitions({ |
| 254 | familyPrefix: deployed.taskFamily, |
| 255 | status: "ACTIVE", |
| 256 | }) |
| 257 | .pipe( |
| 258 | Effect.map((r) => |
| 259 | (r.taskDefinitionArns ?? []).filter((arn) => |
| 260 | arn.includes(`/${deployed.taskFamily}:`), |
| 261 | ), |
| 262 | ), |
| 263 | ); |
| 264 | expect(activeRevisions).toEqual([]); |
| 265 | }), |
| 266 | { tags: ["provider:aws", "provider:aws:ecs", "live"], timeout: 240_000 }, |
| 267 | ); |
| 268 | |
| 269 | // `environmentFiles` parity: the prop lands on the primary container of the |
| 270 | // registered task definition, and the execution role is granted read access |
| 271 | // to the referenced S3 objects via the `alchemy-environment-files` inline |
| 272 | // policy (registration does not require the S3 object to exist). Requires a |
| 273 | // local Docker daemon (registry-image mirror, cached busybox). |
| 274 | test.provider( |
| 275 | "environmentFiles land on the primary container with execution-role read access", |
| 276 | (stack) => |
| 277 | Effect.gen(function* () { |
| 278 | yield* stack.destroy(); |
| 279 | |
| 280 | const envFileArn = "arn:aws:s3:::alchemy-test-ecs-env-files/app.env"; |
| 281 | const deployed = yield* stack.deploy( |
| 282 | Effect.gen(function* () { |
| 283 | return yield* Task("EnvFilesTask", { |
| 284 | image: "busybox:stable", |
| 285 | command: ["sh", "-c", "true"], |
| 286 | cpu: 256, |
| 287 | memory: 512, |
| 288 | environmentFiles: [{ value: envFileArn, type: "s3" }], |
| 289 | }); |
| 290 | }), |
| 291 | ); |
| 292 | |
| 293 | // Out-of-band: the registered revision carries the environment file. |
| 294 | const described = yield* ecs.describeTaskDefinition({ |
| 295 | taskDefinition: deployed.taskDefinitionArn, |
| 296 | }); |
| 297 | expect( |
| 298 | described.taskDefinition?.containerDefinitions?.[0]?.environmentFiles, |
| 299 | ).toEqual([{ value: envFileArn, type: "s3" }]); |
| 300 | |
| 301 | // Out-of-band: the execution role can read the referenced object. |
| 302 | const policy = yield* iam.getRolePolicy({ |
| 303 | RoleName: deployed.executionRoleName, |
| 304 | PolicyName: "alchemy-environment-files", |
| 305 | }); |
| 306 | const document = decodeURIComponent(policy.PolicyDocument ?? ""); |
| 307 | expect(document).toContain("s3:GetObject"); |
| 308 | expect(document).toContain(envFileArn); |
| 309 | expect(document).toContain("arn:aws:s3:::alchemy-test-ecs-env-files"); |
| 310 | |
| 311 | yield* stack.destroy(); |
| 312 | |
| 313 | // Destroy sweeps the role (inline policy included) and the family. |
| 314 | const roleGone = yield* iam |
| 315 | .getRole({ RoleName: deployed.executionRoleName }) |
| 316 | .pipe( |
| 317 | Effect.map(() => false), |
| 318 | Effect.catchTag("NoSuchEntityException", () => Effect.succeed(true)), |
| 319 | ); |
| 320 | expect(roleGone).toBe(true); |
| 321 | }), |
| 322 | { |
| 323 | tags: ["provider:aws", "provider:aws:ecs", "provider:aws:iam", "live"], |
| 324 | timeout: 240_000, |
| 325 | }, |
| 326 | ); |