1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import { Task } from "@/AWS/ECS/Task.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as ecs from "@distilled.cloud/aws/ecs";
7import * as iam from "@distilled.cloud/aws/iam";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { reclaimTaskDefinitionFamily } from "./reclaimTaskDefinitionFamily.ts";
11
12const { 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.
21test.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.
105test.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).
200test.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).
274test.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);