| 1 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 2 | import * as ecs from "@distilled.cloud/aws/ecs"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 8 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 9 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; |
| 13 | import { AWSEnvironment } from "../Environment.ts"; |
| 14 | import 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 | */ |
| 20 | export type RoleRef = string | { roleArn: string }; |
| 21 | |
| 22 | export 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 | |
| 159 | export 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 | */ |
| 306 | export 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. |
| 312 | const 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. |
| 333 | const 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). |
| 359 | const 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 | |
| 381 | export 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 | ); |