1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as ecs from "@distilled.cloud/aws/ecs";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { deepEqual, isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import { AWSEnvironment } from "../Environment.ts";
14import type { Providers } from "../Providers.ts";
15
16/**
17 * Reference to an IAM role: a raw role ARN or anything exposing a `roleArn`
18 * attribute (e.g. an `AWS.IAM.Role` resource).
19 */
20export type RoleRef = string | { roleArn: string };
21
22export interface TaskDefinitionProps {
23 /**
24 * Task definition family. All revisions registered by this resource share
25 * this family. If omitted, a unique family is generated.
26 *
27 * Changing the family replaces the resource.
28 */
29 family?: string;
30
31 /**
32 * Container definitions for the task (bring-your-own images: ECR
33 * repository URIs, `public.ecr.aws/...`, Docker Hub, etc.).
34 *
35 * The first container is treated as the primary container: its `name` and
36 * first `portMappings[].containerPort` are surfaced as the `containerName`
37 * and `port` attributes so the task definition can be wired directly into
38 * `AWS.ECS.Service`'s `task` prop.
39 */
40 containerDefinitions: ecs.ContainerDefinition[];
41
42 /**
43 * Task-level CPU units. Required by Fargate.
44 * @default 256 when `requiresCompatibilities` includes `FARGATE`
45 */
46 cpu?: number | string;
47
48 /**
49 * Task-level memory (MiB). Required by Fargate.
50 * @default 512 when `requiresCompatibilities` includes `FARGATE`
51 */
52 memory?: number | string;
53
54 /**
55 * IAM role assumed by the containers at runtime (application permissions).
56 * Accepts a role ARN or an `AWS.IAM.Role` resource.
57 */
58 taskRoleArn?: RoleRef;
59
60 /**
61 * IAM role used by the ECS agent to pull images and ship logs. Accepts a
62 * role ARN or an `AWS.IAM.Role` resource. Required by Fargate when private
63 * registries or the `awslogs` log driver are used.
64 */
65 executionRoleArn?: RoleRef;
66
67 /**
68 * Docker network mode.
69 * @default "awsvpc" when `requiresCompatibilities` includes `FARGATE`
70 */
71 networkMode?: ecs.NetworkMode;
72
73 /**
74 * Launch-type compatibilities the task definition must support
75 * (`FARGATE` and/or `EC2`/`EXTERNAL`).
76 * @default ["FARGATE"]
77 */
78 requiresCompatibilities?: ecs.Compatibility[];
79
80 /**
81 * Convenience CloudWatch Logs wiring. When set, Alchemy creates (and owns)
82 * a log group and injects an `awslogs` `logConfiguration` into every
83 * container definition that does not declare its own.
84 *
85 * `true` uses the default group name `/ecs/{family}`; pass an object to
86 * override the group name or stream prefix. The log group is deleted when
87 * the resource is destroyed.
88 */
89 awslogs?:
90 | boolean
91 | {
92 /**
93 * Log group name.
94 * @default `/ecs/${family}`
95 */
96 group?: string;
97 /**
98 * awslogs stream prefix.
99 * @default the task family
100 */
101 streamPrefix?: string;
102 };
103
104 /**
105 * Task-level data volumes (host / docker / EFS / FSx Windows). Containers
106 * reference these via `mountPoints`.
107 */
108 volumes?: ecs.Volume[];
109
110 /**
111 * Task definition placement constraints (`memberOf` expressions). Only
112 * applies to EC2/EXTERNAL launch types.
113 */
114 placementConstraints?: ecs.TaskDefinitionPlacementConstraint[];
115
116 /**
117 * CPU architecture and operating-system family the task runs on, e.g.
118 * `{ cpuArchitecture: "ARM64", operatingSystemFamily: "LINUX" }`.
119 */
120 runtimePlatform?: ecs.RuntimePlatform;
121
122 /**
123 * Amount of ephemeral storage to allocate for the task on Fargate.
124 */
125 ephemeralStorage?: ecs.EphemeralStorage;
126
127 /**
128 * IPC resource namespace to use for the containers in the task.
129 */
130 ipcMode?: ecs.IpcMode;
131
132 /**
133 * Process namespace to use for the containers in the task.
134 */
135 pidMode?: ecs.PidMode;
136
137 /**
138 * App Mesh proxy configuration.
139 */
140 proxyConfiguration?: ecs.ProxyConfiguration;
141
142 /**
143 * Elastic Inference accelerators to attach to the task.
144 */
145 inferenceAccelerators?: ecs.InferenceAccelerator[];
146
147 /**
148 * Whether to enable AWS Fault Injection (FIS) actions on the task.
149 * @default false
150 */
151 enableFaultInjection?: boolean;
152
153 /**
154 * User-defined tags applied to each registered revision.
155 */
156 tags?: Record<string, string>;
157}
158
159export interface TaskDefinition extends Resource<
160 "AWS.ECS.TaskDefinition",
161 TaskDefinitionProps,
162 {
163 /**
164 * ARN of the latest revision registered by this resource.
165 */
166 taskDefinitionArn: string;
167 /**
168 * Task definition family.
169 */
170 family: string;
171 /**
172 * Revision number of {@link taskDefinitionArn}.
173 */
174 revision: number;
175 /**
176 * Name of the primary (first) container — for `AWS.ECS.Service` wiring.
177 */
178 containerName: string;
179 /**
180 * First `containerPort` of the primary container (0 when the container
181 * declares no port mappings) — for `AWS.ECS.Service` wiring.
182 */
183 port: number;
184 /**
185 * Resolved task role ARN, when configured.
186 */
187 taskRoleArn: string | undefined;
188 /**
189 * Resolved execution role ARN, when configured.
190 */
191 executionRoleArn: string | undefined;
192 /**
193 * Name of the Alchemy-managed log group, when `awslogs` is enabled.
194 */
195 logGroupName: string | undefined;
196 /**
197 * ARN of the Alchemy-managed log group, when `awslogs` is enabled.
198 */
199 logGroupArn: string | undefined;
200 },
201 never,
202 Providers
203> {}
204
205/**
206 * A standalone ECS task definition for bring-your-own-container workloads.
207 *
208 * Unlike the Effect-native `AWS.ECS.Task` (which bundles an inline program and
209 * builds/pushes a Docker image), `TaskDefinition` registers user-supplied
210 * `containerDefinitions` — any image URI from ECR, `public.ecr.aws`, or an
211 * external registry — with full control over Fargate/EC2 compatibility,
212 * volumes, runtime platform, and IAM roles.
213 *
214 * Task definitions are immutable revisions under a family:
215 *
216 * - reconcile registers a **new revision only when the definition content
217 * changed** (compared against the observed latest `ACTIVE` revision), so a
218 * no-op redeploy keeps the same revision;
219 * - changing the `family` replaces the resource;
220 * - destroy deregisters and hard-deletes every revision of the family. ECS
221 * may retain referenced revisions in `DELETE_IN_PROGRESS` until their tasks
222 * and services terminate; that is a successful terminal state.
223 *
224 * **Layering — why `TaskDefinition` is deliberately *not* a Platform.** ECS
225 * splits "what runs" from "how it runs": a task definition is the immutable
226 * container spec, while `Task` (runs to completion) and `Service`
227 * (long-running) are
228 * the execution vehicles. The effectful Platform abstraction requires
229 * Alchemy to own the container image and entrypoint so it can bundle the
230 * inline Effect program — that is exactly what `AWS.ECS.Task` does (bundle →
231 * Docker build/push → register definition → serve the program). Making
232 * `TaskDefinition` *also* a Platform would duplicate `Task` while
233 * contradicting this resource's purpose: user-supplied images whose
234 * entrypoint Alchemy must not rewrite. So the effectful path is
235 * `AWS.ECS.Task`; the bring-your-own-container path is `TaskDefinition`.
236 * Both surface `taskDefinitionArn` / `containerName` / `port`, so either
237 * plugs into `AWS.ECS.Service`'s `task` prop unchanged.
238 * ### Creating a Task Definition
239 * **Example:** Public Image on Fargate
240 * ```typescript
241 * const taskDef = yield* TaskDefinition("Nginx", {
242 * containerDefinitions: [
243 * {
244 * name: "nginx",
245 * image: "public.ecr.aws/nginx/nginx:stable",
246 * essential: true,
247 * portMappings: [{ containerPort: 80, protocol: "tcp" }],
248 * },
249 * ],
250 * });
251 * ```
252 *
253 * **Example:** With IAM Roles and CloudWatch Logs
254 * ```typescript
255 * const taskDef = yield* TaskDefinition("Api", {
256 * cpu: 512,
257 * memory: 1024,
258 * taskRoleArn: taskRole, // AWS.IAM.Role resource or raw ARN
259 * executionRoleArn: executionRole, // needed for awslogs / private images
260 * awslogs: true, // creates /ecs/{family} and injects awslogs config
261 * containerDefinitions: [
262 * {
263 * name: "api",
264 * image: image.imageUri,
265 * essential: true,
266 * portMappings: [{ containerPort: 8080 }],
267 * environment: [{ name: "STAGE", value: "prod" }],
268 * },
269 * ],
270 * });
271 * ```
272 *
273 * ### Running with a Service
274 * **Example:** Wire into AWS.ECS.Service
275 * ```typescript
276 * const service = yield* Service("ApiService", {
277 * cluster,
278 * task: taskDef, // exposes taskDefinitionArn / containerName / port
279 * vpcId: vpc.vpcId,
280 * subnets: [subnet.subnetId],
281 * assignPublicIp: true,
282 * });
283 * ```
284 *
285 * ### EC2 Launch Type
286 * **Example:** EC2 Task with a Host Volume
287 * ```typescript
288 * const taskDef = yield* TaskDefinition("Agent", {
289 * requiresCompatibilities: ["EC2"],
290 * networkMode: "bridge",
291 * volumes: [{ name: "docker-sock", host: { sourcePath: "/var/run/docker.sock" } }],
292 * containerDefinitions: [
293 * {
294 * name: "agent",
295 * image: "public.ecr.aws/docker/library/busybox:stable",
296 * memory: 128,
297 * essential: true,
298 * mountPoints: [{ sourceVolume: "docker-sock", containerPath: "/var/run/docker.sock" }],
299 * },
300 * ],
301 * });
302 * ```
303 *
304 * @resource
305 */
306export const TaskDefinition = Resource<TaskDefinition>(
307 "AWS.ECS.TaskDefinition",
308);
309
310// Deeply drop undefined-valued keys and empty arrays so that "not set" and
311// "AWS returned nothing" compare equal.
312const canonicalize = (value: unknown): unknown => {
313 if (Array.isArray(value)) {
314 return value.map(canonicalize);
315 }
316 if (value !== null && typeof value === "object") {
317 const out: Record<string, unknown> = {};
318 for (const [k, v] of Object.entries(value)) {
319 if (v === undefined) continue;
320 if (Array.isArray(v) && v.length === 0) continue;
321 out[k] = canonicalize(v);
322 }
323 return out;
324 }
325 return value;
326};
327
328// Compare the desired request against the observed task definition, treating
329// the desired shape as a projection: keys the desired request does not set are
330// ignored (AWS default-fills many fields — container `cpu: 0`, `essential`,
331// empty collections, `requiresAttributes`, timestamps, ...). Removed props are
332// caught separately by diffing the old desired request against the new one.
333const projectionEquals = (desired: unknown, observed: unknown): boolean => {
334 if (desired === undefined) return true;
335 if (Array.isArray(desired)) {
336 if (!Array.isArray(observed) || observed.length !== desired.length) {
337 return false;
338 }
339 return desired.every((d, i) => projectionEquals(d, observed[i]));
340 }
341 if (desired !== null && typeof desired === "object") {
342 if (
343 observed === null ||
344 typeof observed !== "object" ||
345 Array.isArray(observed)
346 ) {
347 return false;
348 }
349 return Object.entries(desired).every(([k, v]) =>
350 projectionEquals(v, (observed as Record<string, unknown>)[k]),
351 );
352 }
353 return desired === observed;
354};
355
356// Normalize container definitions on both sides before comparing: sort
357// environment entries by name and fill the wire defaults AWS applies to port
358// mappings (protocol "tcp"; in awsvpc mode hostPort mirrors containerPort).
359const normalizeContainers = (
360 defs: ecs.ContainerDefinition[] | undefined,
361 networkMode: ecs.NetworkMode | undefined,
362): ecs.ContainerDefinition[] =>
363 (defs ?? []).map((cd) => ({
364 ...cd,
365 environment:
366 cd.environment !== undefined && cd.environment.length > 0
367 ? [...cd.environment].sort((a, b) =>
368 (a.name ?? "").localeCompare(b.name ?? ""),
369 )
370 : undefined,
371 portMappings: cd.portMappings?.map((pm) => ({
372 ...pm,
373 protocol: pm.protocol ?? "tcp",
374 hostPort:
375 networkMode === "awsvpc"
376 ? (pm.hostPort ?? pm.containerPort)
377 : pm.hostPort,
378 })),
379 }));
380
381export const TaskDefinitionProvider = () =>
382 Provider.effect(
383 TaskDefinition,
384 Effect.gen(function* () {
385 const toFamily = (id: string, props: { family?: string } = {}) =>
386 props.family
387 ? Effect.succeed(props.family)
388 : createPhysicalName({
389 id,
390 maxLength: 255,
391 lowercase: true,
392 });
393
394 const roleArnOf = (ref: RoleRef | undefined): string | undefined =>
395 typeof ref === "string"
396 ? ref
397 : typeof (ref as { roleArn?: unknown } | undefined)?.roleArn ===
398 "string"
399 ? (ref as { roleArn: string }).roleArn
400 : undefined;
401
402 const logGroupNameOf = (
403 family: string,
404 props: TaskDefinitionProps,
405 ): string | undefined =>
406 props.awslogs
407 ? ((typeof props.awslogs === "object"
408 ? props.awslogs.group
409 : undefined) ?? `/ecs/${family}`)
410 : undefined;
411
412 // The desired registerTaskDefinition request (sans tags), fully
413 // defaulted so it can be compared against the observed revision.
414 const desiredRequestOf = (
415 family: string,
416 props: TaskDefinitionProps,
417 region: string,
418 ): Omit<ecs.RegisterTaskDefinitionRequest, "tags"> => {
419 const requiresCompatibilities = props.requiresCompatibilities ?? [
420 "FARGATE",
421 ];
422 const fargate = requiresCompatibilities.includes("FARGATE");
423 const networkMode =
424 props.networkMode ?? (fargate ? "awsvpc" : undefined);
425 const logGroupName = logGroupNameOf(family, props);
426 const streamPrefix =
427 (typeof props.awslogs === "object"
428 ? props.awslogs.streamPrefix
429 : undefined) ?? family;
430 const containerDefinitions = props.containerDefinitions.map((cd) =>
431 logGroupName !== undefined && cd.logConfiguration === undefined
432 ? {
433 ...cd,
434 logConfiguration: {
435 logDriver: "awslogs",
436 options: {
437 "awslogs-group": logGroupName,
438 "awslogs-region": region,
439 "awslogs-stream-prefix": streamPrefix,
440 },
441 } satisfies ecs.LogConfiguration,
442 }
443 : cd,
444 );
445 return {
446 family,
447 taskRoleArn: roleArnOf(props.taskRoleArn),
448 executionRoleArn: roleArnOf(props.executionRoleArn),
449 networkMode,
450 containerDefinitions,
451 volumes: props.volumes,
452 placementConstraints: props.placementConstraints,
453 requiresCompatibilities,
454 cpu:
455 props.cpu !== undefined
456 ? String(props.cpu)
457 : fargate
458 ? "256"
459 : undefined,
460 memory:
461 props.memory !== undefined
462 ? String(props.memory)
463 : fargate
464 ? "512"
465 : undefined,
466 pidMode: props.pidMode,
467 ipcMode: props.ipcMode,
468 proxyConfiguration: props.proxyConfiguration,
469 inferenceAccelerators: props.inferenceAccelerators,
470 ephemeralStorage: props.ephemeralStorage,
471 runtimePlatform: props.runtimePlatform,
472 enableFaultInjection: props.enableFaultInjection,
473 };
474 };
475
476 const matchesObserved = (
477 desired: Omit<ecs.RegisterTaskDefinitionRequest, "tags">,
478 observed: ecs.TaskDefinition,
479 ): boolean =>
480 projectionEquals(
481 canonicalize({
482 ...desired,
483 containerDefinitions: normalizeContainers(
484 desired.containerDefinitions,
485 desired.networkMode,
486 ),
487 }),
488 canonicalize({
489 family: observed.family,
490 taskRoleArn: observed.taskRoleArn,
491 executionRoleArn: observed.executionRoleArn,
492 networkMode: observed.networkMode,
493 containerDefinitions: normalizeContainers(
494 observed.containerDefinitions,
495 observed.networkMode,
496 ),
497 volumes: observed.volumes,
498 placementConstraints: observed.placementConstraints,
499 requiresCompatibilities: observed.requiresCompatibilities,
500 cpu: observed.cpu,
501 memory: observed.memory,
502 pidMode: observed.pidMode,
503 ipcMode: observed.ipcMode,
504 proxyConfiguration: observed.proxyConfiguration,
505 inferenceAccelerators: observed.inferenceAccelerators,
506 ephemeralStorage: observed.ephemeralStorage,
507 runtimePlatform: observed.runtimePlatform,
508 enableFaultInjection: observed.enableFaultInjection,
509 }),
510 );
511
512 const describeLatestActive = (taskDefinition: string) =>
513 ecs.describeTaskDefinition({ taskDefinition }).pipe(
514 Effect.map((described) =>
515 described.taskDefinition?.status === "ACTIVE"
516 ? described.taskDefinition
517 : undefined,
518 ),
519 // "Unable to describe task definition" — no ACTIVE revision exists.
520 Effect.catchTag("ClientException", () => Effect.succeed(undefined)),
521 );
522
523 const attrsOf = (
524 td: ecs.TaskDefinition,
525 logGroup: { logGroupName?: string; logGroupArn?: string } = {},
526 ): TaskDefinition["Attributes"] | undefined => {
527 if (!td.taskDefinitionArn || !td.family || td.revision === undefined) {
528 return undefined;
529 }
530 const container = td.containerDefinitions?.[0];
531 return {
532 taskDefinitionArn: td.taskDefinitionArn,
533 family: td.family,
534 revision: td.revision,
535 containerName: container?.name ?? td.family,
536 port: container?.portMappings?.[0]?.containerPort ?? 0,
537 taskRoleArn: td.taskRoleArn,
538 executionRoleArn: td.executionRoleArn,
539 logGroupName: logGroup.logGroupName,
540 logGroupArn: logGroup.logGroupArn,
541 };
542 };
543
544 const observedTagsOf = Effect.fn(function* (resourceArn: string) {
545 const listed = yield* ecs
546 .listTagsForResource({ resourceArn })
547 .pipe(
548 Effect.catchTag("ClientException", () =>
549 Effect.succeed({ tags: undefined } as { tags?: ecs.Tag[] }),
550 ),
551 );
552 return Object.fromEntries(
553 (listed.tags ?? [])
554 .filter(
555 (t): t is { key: string; value: string } =>
556 typeof t.key === "string" && typeof t.value === "string",
557 )
558 .map((t) => [t.key, t.value]),
559 );
560 });
561
562 const listFamilyArns = Effect.fn(function* (
563 family: string,
564 status: "ACTIVE" | "INACTIVE",
565 ) {
566 const arns = yield* ecs.listTaskDefinitions
567 .pages({ familyPrefix: family, status })
568 .pipe(
569 Stream.runCollect,
570 Effect.map((chunk) =>
571 Array.from(chunk).flatMap(
572 (page) => page.taskDefinitionArns ?? [],
573 ),
574 ),
575 );
576 // familyPrefix is a prefix match — filter to the exact family.
577 return arns.filter(
578 (arn) => arn.split("/").pop()?.split(":")[0] === family,
579 );
580 });
581
582 class TaskDefinitionRevisionNotTerminal extends Data.TaggedError(
583 "TaskDefinitionRevisionNotTerminal",
584 )<{
585 readonly taskDefinitionArn: string;
586 readonly status: ecs.TaskDefinitionStatus | undefined;
587 }> {}
588
589 const deletionObservationSchedule = Schedule.max([
590 Schedule.spaced("1 second"),
591 Schedule.recurs(15),
592 ]);
593
594 const waitUntilRevisionDeregistered = Effect.fn(function* (
595 taskDefinitionArn: string,
596 ) {
597 yield* ecs
598 .describeTaskDefinition({ taskDefinition: taskDefinitionArn })
599 .pipe(
600 Effect.flatMap(({ taskDefinition }) =>
601 taskDefinition?.status !== "ACTIVE"
602 ? Effect.void
603 : Effect.fail(
604 new TaskDefinitionRevisionNotTerminal({
605 taskDefinitionArn,
606 status: taskDefinition.status,
607 }),
608 ),
609 ),
610 Effect.retry({
611 while: (error) =>
612 error._tag === "TaskDefinitionRevisionNotTerminal",
613 schedule: deletionObservationSchedule,
614 }),
615 Effect.catchTag("ClientException", () => Effect.void),
616 );
617 });
618
619 const waitUntilRevisionDeletionStarted = Effect.fn(function* (
620 taskDefinitionArn: string,
621 ) {
622 yield* ecs
623 .describeTaskDefinition({ taskDefinition: taskDefinitionArn })
624 .pipe(
625 Effect.flatMap(({ taskDefinition }) =>
626 taskDefinition?.status === "DELETE_IN_PROGRESS"
627 ? Effect.void
628 : Effect.fail(
629 new TaskDefinitionRevisionNotTerminal({
630 taskDefinitionArn,
631 status: taskDefinition?.status,
632 }),
633 ),
634 ),
635 Effect.retry({
636 while: (error) =>
637 error._tag === "TaskDefinitionRevisionNotTerminal",
638 schedule: deletionObservationSchedule,
639 }),
640 // ECS eventually removes an unreferenced DELETE_IN_PROGRESS
641 // revision. Not-found is therefore also a successful terminal
642 // observation; literal absence is not required because referenced
643 // revisions may legitimately remain DELETE_IN_PROGRESS.
644 Effect.catchTag("ClientException", () => Effect.void),
645 );
646 });
647
648 return {
649 stables: ["family"],
650 diff: Effect.fn(function* ({ id, olds, news }) {
651 if (!isResolved(news)) return;
652 if (
653 (yield* toFamily(id, olds ?? {})) !==
654 (yield* toFamily(id, news ?? {}))
655 ) {
656 return { action: "replace" } as const;
657 }
658 }),
659 read: Effect.fn(function* ({ id, olds, output }) {
660 const family = output?.family ?? (yield* toFamily(id, olds ?? {}));
661 const td = yield* describeLatestActive(family);
662 if (!td?.taskDefinitionArn) {
663 return undefined;
664 }
665 const attrs = attrsOf(td, {
666 logGroupName: output?.logGroupName,
667 logGroupArn: output?.logGroupArn,
668 });
669 if (!attrs) {
670 return undefined;
671 }
672 const tags = yield* observedTagsOf(td.taskDefinitionArn);
673 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
674 }),
675 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
676 const { accountId, region } = yield* AWSEnvironment.current;
677 const family = yield* toFamily(id, news);
678 const desired = desiredRequestOf(family, news, region);
679 const desiredTags = {
680 ...(yield* createInternalTags(id)),
681 ...news.tags,
682 };
683
684 // Alchemy-managed log group (awslogs convenience). Create the
685 // desired group if requested; drop a previously-managed group whose
686 // name no longer matches (renamed or awslogs disabled).
687 const logGroupName = logGroupNameOf(family, news);
688 if (output?.logGroupName && output.logGroupName !== logGroupName) {
689 yield* logs
690 .deleteLogGroup({ logGroupName: output.logGroupName })
691 .pipe(
692 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
693 );
694 }
695 let logGroup: { logGroupName?: string; logGroupArn?: string } = {};
696 if (logGroupName !== undefined) {
697 yield* logs
698 .createLogGroup({ logGroupName, tags: desiredTags })
699 .pipe(
700 Effect.catchTag(
701 "ResourceAlreadyExistsException",
702 () => Effect.void,
703 ),
704 );
705 logGroup = {
706 logGroupName,
707 logGroupArn: `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`,
708 };
709 }
710
711 // Observe — the latest ACTIVE revision of the family is the
712 // authoritative current state (output is only an identity cache).
713 const observed = yield* describeLatestActive(family);
714
715 // A new revision is needed when the observed revision drifted from
716 // the desired definition, or when a prop was *removed* relative to
717 // the previous desired state (removal falls back to an AWS default
718 // that the projection comparison intentionally ignores).
719 const removedProps =
720 olds !== undefined &&
721 !deepEqual(
722 canonicalize(desiredRequestOf(family, olds, region)),
723 canonicalize(desired),
724 );
725 const upToDate =
726 observed !== undefined &&
727 !removedProps &&
728 matchesObserved(desired, observed);
729
730 if (upToDate) {
731 // No-op redeploy — reuse the observed revision; converge tags.
732 const revisionArn = observed.taskDefinitionArn!;
733 const observedTags = yield* observedTagsOf(revisionArn);
734 const { removed, upsert } = diffTags(observedTags, desiredTags);
735 if (upsert.length > 0) {
736 yield* ecs.tagResource({
737 resourceArn: revisionArn,
738 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
739 });
740 }
741 if (removed.length > 0) {
742 yield* ecs.untagResource({
743 resourceArn: revisionArn,
744 tagKeys: removed,
745 });
746 }
747 yield* session.note(revisionArn);
748 return attrsOf(observed, logGroup)!;
749 }
750
751 // Register a new immutable revision. Prior revisions stay ACTIVE
752 // (running tasks/services keep working) and are deregistered when
753 // the resource is deleted.
754 const registered = yield* ecs.registerTaskDefinition({
755 ...desired,
756 tags: Object.entries(desiredTags).map(([key, value]) => ({
757 key,
758 value,
759 })),
760 });
761 const td = registered.taskDefinition;
762 const attrs = td && attrsOf(td, logGroup);
763 if (!attrs) {
764 return yield* Effect.die(
765 new Error("registerTaskDefinition returned no task definition"),
766 );
767 }
768 yield* session.note(attrs.taskDefinitionArn);
769 return attrs;
770 }),
771 // Enumerate the latest ACTIVE revision of every task definition
772 // family in the account/region.
773 list: () =>
774 Effect.gen(function* () {
775 const arns = yield* ecs.listTaskDefinitions
776 .pages({ status: "ACTIVE" })
777 .pipe(
778 Stream.runCollect,
779 Effect.map((chunk) =>
780 Array.from(chunk).flatMap(
781 (page) => page.taskDefinitionArns ?? [],
782 ),
783 ),
784 );
785 const latest = new Map<string, { arn: string; revision: number }>();
786 for (const arn of arns) {
787 const suffix = arn.split("/").pop();
788 if (!suffix) continue;
789 const sep = suffix.lastIndexOf(":");
790 const family = suffix.slice(0, sep);
791 const revision = Number(suffix.slice(sep + 1));
792 const current = latest.get(family);
793 if (!current || revision > current.revision) {
794 latest.set(family, { arn, revision });
795 }
796 }
797 const rows = yield* Effect.forEach(
798 [...latest.values()],
799 ({ arn }) =>
800 ecs.describeTaskDefinition({ taskDefinition: arn }).pipe(
801 Effect.map((described) =>
802 described.taskDefinition
803 ? attrsOf(described.taskDefinition)
804 : undefined,
805 ),
806 Effect.catchTag("ClientException", () =>
807 Effect.succeed(undefined),
808 ),
809 ),
810 { concurrency: 10 },
811 );
812 return rows.filter(
813 (row): row is TaskDefinition["Attributes"] => row !== undefined,
814 );
815 }),
816 delete: Effect.fn(function* ({ output }) {
817 // Deregister every ACTIVE revision of the family (the family is
818 // this resource's physical identity), tolerating already-INACTIVE
819 // races, then hard-delete all revisions. AWS can retain a referenced
820 // revision in DELETE_IN_PROGRESS, so observe that exact terminal
821 // status rather than waiting indefinitely for physical absence.
822 const active = yield* listFamilyArns(output.family, "ACTIVE").pipe(
823 Effect.catchTag("ClientException", () =>
824 Effect.succeed([] as string[]),
825 ),
826 );
827 const alreadyInactive = yield* listFamilyArns(
828 output.family,
829 "INACTIVE",
830 ).pipe(
831 Effect.catchTag("ClientException", () =>
832 Effect.succeed([] as string[]),
833 ),
834 );
835 for (const arn of active) {
836 yield* ecs
837 .deregisterTaskDefinition({ taskDefinition: arn })
838 .pipe(
839 Effect.catchTag(
840 ["ClientException", "InvalidParameterException"],
841 () => Effect.void,
842 ),
843 );
844 }
845 yield* Effect.forEach(active, waitUntilRevisionDeregistered, {
846 concurrency: 10,
847 });
848 const inactive = [
849 ...new Set([
850 ...active,
851 ...alreadyInactive,
852 ...(yield* listFamilyArns(output.family, "INACTIVE").pipe(
853 Effect.catchTag("ClientException", () =>
854 Effect.succeed([] as string[]),
855 ),
856 )),
857 ]),
858 ];
859 for (let i = 0; i < inactive.length; i += 10) {
860 const deleted = yield* ecs
861 .deleteTaskDefinitions({
862 taskDefinitions: inactive.slice(i, i + 10),
863 })
864 .pipe(
865 Effect.catchTag(
866 ["ClientException", "InvalidParameterException"],
867 () => Effect.void,
868 ),
869 );
870 if (deleted && (deleted.failures?.length ?? 0) > 0) {
871 return yield* Effect.die(
872 new Error(
873 `ECS failed to delete task definition revisions: ${deleted.failures
874 ?.map(
875 (failure) =>
876 `${failure.arn ?? "unknown"}: ${failure.reason ?? "unknown"} (${failure.detail ?? "no detail"})`,
877 )
878 .join(", ")}`,
879 ),
880 );
881 }
882 }
883 yield* Effect.forEach(inactive, waitUntilRevisionDeletionStarted, {
884 concurrency: 10,
885 });
886 if (output.logGroupName) {
887 yield* logs
888 .deleteLogGroup({ logGroupName: output.logGroupName })
889 .pipe(
890 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
891 );
892 }
893 }),
894 };
895 }),
896 );