1import * as AWS from "@/AWS";
2import { Subnet } from "@/AWS/EC2";
3import { Cluster } from "@/AWS/ECS/Cluster.ts";
4import { Service } from "@/AWS/ECS/Service.ts";
5import { TaskDefinition } from "@/AWS/ECS/TaskDefinition.ts";
6import { Role } from "@/AWS/IAM/Role.ts";
7import * as Test from "@/Test/Alchemy";
8import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
9import * as ec2 from "@distilled.cloud/aws/ec2";
10import * as ecs from "@distilled.cloud/aws/ecs";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import { getDefaultVpc } from "../DefaultVpc.ts";
15
16const { 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).
21const 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
29const 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).
43test.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.
155test.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.
207test.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);