1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as ecr from "@distilled.cloud/aws/ecr";
3import * as ecs from "@distilled.cloud/aws/ecs";
4import * as iam from "@distilled.cloud/aws/iam";
5import { Region } from "@distilled.cloud/aws/Region";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import type { Scope } from "effect/Scope";
9import * as Stream from "effect/Stream";
10import { isResolved } from "../../Diff.ts";
11import { createPhysicalName } from "../../PhysicalName.ts";
12import {
13 Platform,
14 type Main,
15 type PlatformProps,
16 type PlatformServices,
17} from "../../Platform.ts";
18import * as Provider from "../../Provider.ts";
19import { Resource, type ResourceBinding } from "../../Resource.ts";
20import type { RuntimeContext } from "../../RuntimeContext.ts";
21import {
22 createContainerRuntimeContext,
23 type HostRuntimeContext,
24 type ServerHost,
25} from "../../Server/Process.ts";
26import { Stack } from "../../Stack.ts";
27import {
28 createInternalTags,
29 createTagsList,
30 diffTags,
31 hasTags,
32} from "../../Tags.ts";
33import type { Credentials } from "../Credentials.ts";
34import {
35 makeBunBootstrap,
36 makeImageSource,
37 type BundledImageSource,
38 type DockerfileImageSource,
39 type ImageSourceLike,
40 type RegistryImageSource,
41} from "../ECR/ImageSource.ts";
42import { AWSEnvironment } from "../Environment.ts";
43import type { PolicyStatement } from "../IAM/Policy.ts";
44import type { Providers } from "../Providers.ts";
45
46export const isTask = (value: any): value is Task => {
47 return (
48 typeof value === "object" &&
49 value !== null &&
50 "Type" in value &&
51 value.Type === "AWS.ECS.Task"
52 );
53};
54
55export class TaskEnvironment extends Context.Service<
56 TaskEnvironment,
57 Record<string, any>
58>()("AWS.ECS.TaskEnvironment") {}
59
60/**
61 * The binding contract shared by the ECS container platforms (`Task` and the
62 * image-owning `Service`): env vars and IAM policy statements land on the
63 * task role, plus task-level volumes/mount points requested through the
64 * binding channel (e.g. `EFS.Mount`).
65 */
66export interface TaskBindingContract {
67 /** Environment variables injected into the task's containers. */
68 env?: Record<string, any>;
69 /** IAM policy statements attached to the task role. */
70 policyStatements?: PolicyStatement[];
71 /**
72 * Task-level volumes requested through the binding channel (e.g.
73 * `EFS.Mount`). Merged with the resource's own `volumes` prop.
74 */
75 volumes?: ecs.Volume[];
76 /**
77 * Container mount points for binding-requested volumes, applied to the
78 * primary container.
79 */
80 mountPoints?: ecs.MountPoint[];
81}
82
83/**
84 * Task-definition configuration shared by `AWS.ECS.Task` and the
85 * image-owning form of `AWS.ECS.Service` (which synthesizes its own task
86 * definition from the same surface).
87 */
88export interface TaskDefinitionConfig {
89 /**
90 * Task-level cpu configuration for Fargate.
91 * @default 256
92 */
93 cpu?: number;
94 /**
95 * Task-level memory configuration for Fargate.
96 * @default 512
97 */
98 memory?: number;
99 /**
100 * HTTP port exposed by the container.
101 */
102 port?: number;
103 /**
104 * Additional environment variables for the container.
105 */
106 env?: Record<string, any>;
107 /**
108 * Environment files to load into the primary container, e.g. `.env`
109 * objects stored in S3:
110 * `[{ value: "arn:aws:s3:::my-bucket/app.env", type: "s3" }]`.
111 *
112 * Variables from `env` (and the container's own `environment`) take
113 * precedence over values loaded from environment files. The execution
114 * role is automatically granted `s3:GetObject` on the referenced objects
115 * and `s3:GetBucketLocation` on their buckets.
116 */
117 environmentFiles?: ecs.EnvironmentFile[];
118 /**
119 * Command override for the primary container (Docker `CMD`).
120 */
121 command?: string[];
122 /**
123 * Container definition overrides applied after Alchemy's defaults for the
124 * primary container.
125 */
126 container?: Partial<ecs.ContainerDefinition>;
127 /**
128 * Additional sidecar containers appended to the task definition after the
129 * primary container. Each entry is a full, typed
130 * {@link ecs.ContainerDefinition} (image URIs supplied by the user, e.g.
131 * from an `ECR.Image` or an external registry).
132 *
133 * Use this to declare multi-container tasks: log routers (firelens),
134 * proxies (Envoy/App Mesh), metric agents (otel/cloudwatch), or any
135 * companion process that shares the task's network namespace.
136 */
137 sidecars?: ecs.ContainerDefinition[];
138 /**
139 * Task definition network mode.
140 * @default "awsvpc"
141 */
142 networkMode?: ecs.NetworkMode;
143 /**
144 * Launch-type compatibilities the task definition must support.
145 * @default ["FARGATE"]
146 */
147 requiresCompatibilities?: ecs.Compatibility[];
148 /**
149 * Task-level data volumes (host / docker / EFS / FSx Windows / S3 /
150 * configured-at-launch). Containers reference these via `mountPoints`.
151 */
152 volumes?: ecs.Volume[];
153 /**
154 * Task definition placement constraints (`memberOf` expressions). Only
155 * applies to EC2/EXTERNAL launch types.
156 */
157 placementConstraints?: ecs.TaskDefinitionPlacementConstraint[];
158 /**
159 * CPU architecture and operating-system family the task runs on, e.g.
160 * `{ cpuArchitecture: "ARM64", operatingSystemFamily: "LINUX" }`.
161 */
162 runtimePlatform?: ecs.RuntimePlatform;
163 /**
164 * Amount of ephemeral storage (in GiB) to allocate for the task on Fargate.
165 */
166 ephemeralStorage?: ecs.EphemeralStorage;
167 /**
168 * IPC resource namespace to use for the containers in the task.
169 */
170 ipcMode?: ecs.IpcMode;
171 /**
172 * Process namespace to use for the containers in the task.
173 */
174 pidMode?: ecs.PidMode;
175 /**
176 * App Mesh proxy configuration.
177 */
178 proxyConfiguration?: ecs.ProxyConfiguration;
179 /**
180 * Elastic Inference accelerators to attach to the task.
181 */
182 inferenceAccelerators?: ecs.InferenceAccelerator[];
183 /**
184 * Whether to enable AWS Fault Injection (FIS) actions on the task.
185 * @default false
186 */
187 enableFaultInjection?: boolean;
188 /**
189 * Additional task definition overrides applied last (escape hatch for
190 * fields not yet surfaced as first-class props).
191 */
192 taskDefinition?: Partial<
193 Omit<
194 ecs.RegisterTaskDefinitionRequest,
195 | "family"
196 | "containerDefinitions"
197 | "executionRoleArn"
198 | "taskRoleArn"
199 | "cpu"
200 | "memory"
201 >
202 >;
203 /**
204 * Additional managed policy ARNs for the task role.
205 */
206 taskRoleManagedPolicyArns?: string[];
207 /**
208 * Additional managed policy ARNs for the execution role.
209 */
210 executionRoleManagedPolicyArns?: string[];
211}
212
213export interface TaskPropsBase extends PlatformProps, TaskDefinitionConfig {
214 /**
215 * ECS task family. If omitted, a unique family is generated.
216 */
217 taskName?: string;
218 /**
219 * User-defined tags to apply to task-owned resources.
220 */
221 tags?: Record<string, string>;
222}
223
224/**
225 * Bundle an inline Effect program (`main`) into a generated image whose
226 * environment comes from `image`, `dockerfile`, or the default bun base.
227 */
228export interface BundledTaskProps extends TaskPropsBase, BundledImageSource {}
229/**
230 * Build the user's own Dockerfile (`context` + optional `dockerfile` path)
231 * into the task image.
232 */
233export interface DockerfileTaskProps
234 extends TaskPropsBase, DockerfileImageSource {}
235/**
236 * Run a pre-built registry image (`image`), mirrored into ECR.
237 */
238export interface ImageTaskProps extends TaskPropsBase, RegistryImageSource {}
239
240/**
241 * Task props — the image comes from exactly one of three sources, flat on
242 * the props: `main` (bundled Effect program), `context` (user Dockerfile),
243 * or `image` (registry reference).
244 */
245export type TaskProps = BundledTaskProps | DockerfileTaskProps | ImageTaskProps;
246
247export interface Task extends Resource<
248 "AWS.ECS.Task",
249 TaskProps,
250 {
251 /** The ARN of the registered task definition revision. */
252 taskDefinitionArn: string;
253 /** The task definition family name. */
254 taskFamily: string;
255 /** The name of the main container in the task definition. */
256 containerName: string;
257 /** The container port the task listens on. */
258 port: number;
259 /** The full URI of the container image the task runs. */
260 imageUri: string;
261 /** The name of the ECR repository holding the built image. */
262 repositoryName: string;
263 /** The URI of the ECR repository holding the built image. */
264 repositoryUri: string;
265 /** The ARN of the task role assumed by the running containers. */
266 taskRoleArn: string;
267 /** The name of the task role. */
268 taskRoleName: string;
269 /** The ARN of the execution role used to pull images and write logs. */
270 executionRoleArn: string;
271 /** The name of the execution role. */
272 executionRoleName: string;
273 /** The CloudWatch log group the task writes to. */
274 logGroupName: string;
275 /** The ARN of the CloudWatch log group. */
276 logGroupArn: string;
277 /** The content hash of the task's container image. */
278 code: {
279 /** The content hash of the task's container image. */
280 hash: string;
281 };
282 },
283 TaskBindingContract,
284 Providers
285> {}
286
287export type TaskServices = Credentials | Region | ServerHost | AWSEnvironment;
288
289/**
290 * The impl shape for an effectful `Task`: a `run` entry that executes to
291 * completion when the container starts, and/or a `fetch` HTTP handler for
292 * tasks deployed as servers (e.g. referenced by an `ECS.Service`).
293 */
294export type TaskShape =
295 | void
296 | (Exclude<Main<TaskServices>, void> & {
297 /**
298 * Runs to completion when the container starts, after which the
299 * container exits.
300 */
301 run?: Effect.Effect<
302 void,
303 never,
304 TaskServices | PlatformServices | RuntimeContext | Scope
305 >;
306 });
307
308export interface TaskRuntimeContext extends HostRuntimeContext {
309 readonly Type: "AWS.ECS.Task";
310}
311
312// Shared with `Docker.Service`; lives beside the host runtime context.
313export { createContainerRuntimeContext } from "../../Server/Process.ts";
314
315/**
316 * A Fargate task definition with a container image from one of three
317 * sources, declared flat on the props:
318 *
319 * - `main` — bundle an inline Effect program into a generated image
320 * (compose with `image` or an inline `dockerfile` to pick the
321 * environment; defaults to `oven/bun:1`).
322 * - `context` — build your own Dockerfile (`dockerfile` is a path relative
323 * to the cwd, defaulting to `${context}/Dockerfile`).
324 * - `image` — run a pre-built registry image, mirrored into ECR.
325 *
326 * `Task` provisions task + execution IAM roles, a CloudWatch log group, and
327 * an ECR repository holding the built (or mirrored) image, then registers a
328 * Fargate task definition. Each reconcile registers a new immutable
329 * revision. A launched task runs until its process exits — it is the target
330 * of `AWS.ECS.RunTask` / `StopTask` bindings and `AWS.ECS.Schedule`;
331 * effectful impls return `{ run }`, executed to completion when the
332 * container starts.
333 *
334 * Beyond the primary container you can declare task-level configuration
335 * (volumes, runtime platform, ephemeral storage, IPC/PID mode, placement
336 * constraints) and append additional `sidecars` for multi-container tasks.
337 * ### Creating a Task
338 * **Example:** Remote Image
339 * ```typescript
340 * const migrate = yield* Task("DbMigrate", {
341 * image: "public.ecr.aws/docker/library/busybox:stable",
342 * command: ["sh", "-c", "echo done"],
343 * cpu: 256,
344 * memory: 512,
345 * });
346 * ```
347 *
348 * **Example:** Build Your Own Dockerfile
349 * ```typescript
350 * const render = yield* Task("RenderJob", {
351 * context: "./render", // dockerfile defaults to ./render/Dockerfile
352 * dockerfile: "./render/Dockerfile.gpu", // always a PATH
353 * cpu: 1024,
354 * memory: 4096,
355 * });
356 * ```
357 *
358 * **Example:** Inline Effect Program
359 * ```typescript
360 * const drainer = yield* Task(
361 * "QueueDrainer",
362 * { main: import.meta.url, image: "oven/bun:1", cpu: 256, memory: 512 },
363 * Effect.gen(function* () {
364 * const receive = yield* AWS.SQS.ReceiveMessage(queue);
365 * return {
366 * run: Effect.gen(function* () {
367 * // runs to completion, then the container exits
368 * const batch = yield* receive({ MaxNumberOfMessages: 10 });
369 * }),
370 * };
371 * }),
372 * );
373 * ```
374 *
375 * ### Multi-Container Tasks
376 * **Example:** Task with a Sidecar
377 * ```typescript
378 * const task = yield* Task("ApiTask", {
379 * main: import.meta.url,
380 * port: 3000,
381 * sidecars: [
382 * {
383 * name: "otel-collector",
384 * image: "public.ecr.aws/aws-observability/aws-otel-collector:latest",
385 * essential: false,
386 * portMappings: [{ containerPort: 4317, protocol: "tcp" }],
387 * },
388 * ],
389 * });
390 * ```
391 *
392 * ### Bundling & Tree-shaking
393 * `main` is bundled with rolldown at deploy time. Unused code is
394 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
395 * pure so unused parts prune more aggressively. Your app is not
396 * marked pure.
397 *
398 * **Example:** Mark additional packages as pure
399 * Only list packages with no top-level side effects.
400 * ```typescript
401 * {
402 * main: import.meta.url,
403 * build: {
404 * pure: { packages: ["my-lib", "@my-scope/*"] },
405 * },
406 * }
407 * ```
408 *
409 * **Example:** Turn it off
410 * ```typescript
411 * {
412 * main: import.meta.url,
413 * build: { pure: false },
414 * }
415 * ```
416 *
417 * ### Task-Level Configuration
418 * **Example:** ARM64 with EFS Volume and Ephemeral Storage
419 * ```typescript
420 * const task = yield* Task("WorkerTask", {
421 * main: import.meta.url,
422 * runtimePlatform: { cpuArchitecture: "ARM64", operatingSystemFamily: "LINUX" },
423 * ephemeralStorage: { sizeInGiB: 40 },
424 * volumes: [
425 * {
426 * name: "data",
427 * efsVolumeConfiguration: { fileSystemId: fileSystem.fileSystemId },
428 * },
429 * ],
430 * container: {
431 * mountPoints: [{ sourceVolume: "data", containerPath: "/data" }],
432 * },
433 * });
434 * ```
435 *
436 * **Example:** Environment Files from S3
437 * ```typescript
438 * const task = yield* Task("ApiTask", {
439 * main: import.meta.url,
440 * environmentFiles: [
441 * { value: "arn:aws:s3:::my-config-bucket/app.env", type: "s3" },
442 * ],
443 * });
444 * ```
445 *
446 * @resource
447 */
448export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> =
449 Platform("AWS.ECS.Task", {
450 createRuntimeContext: createContainerRuntimeContext("AWS.ECS.Task") as (
451 id: string,
452 ) => TaskRuntimeContext,
453 });
454
455/** Docker build platform matching the task definition's declared runtime. */
456export const taskImagePlatform = (runtimePlatform?: ecs.RuntimePlatform) =>
457 // Build for the architecture the task definition declares (Fargate
458 // defaults to X86_64 when `runtimePlatform` is unset). Without this, an
459 // image built on an ARM64 host (e.g. Apple Silicon) is rejected at task
460 // start with `image Manifest does not contain descriptor matching
461 // platform 'linux/amd64'`.
462 runtimePlatform?.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64";
463
464/** Keep image resolution, drift detection and dev watching on the same inputs. */
465export const taskImageInput = (props: TaskProps) => {
466 const source = props as ImageSourceLike;
467 return {
468 source,
469 platform: taskImagePlatform(props.runtimePlatform),
470 port: props.port,
471 isExternal: props.isExternal,
472 bootstrap: makeBunBootstrap(source.handler ?? "default"),
473 };
474};
475
476/**
477 * Create the IAM role assumed by ECS tasks if it doesn't already exist.
478 * Idempotent: an `EntityAlreadyExistsException` adopts the existing role
479 * only when it carries our internal tags.
480 */
481export const createTaskRoleIfNotExists = Effect.fn(function* ({
482 id,
483 roleName,
484}: {
485 id: string;
486 roleName: string;
487}) {
488 const tags = yield* createInternalTags(id);
489 const role = yield* iam
490 .createRole({
491 RoleName: roleName,
492 AssumeRolePolicyDocument: JSON.stringify({
493 Version: "2012-10-17",
494 Statement: [
495 {
496 Effect: "Allow",
497 Principal: {
498 Service: "ecs-tasks.amazonaws.com",
499 },
500 Action: "sts:AssumeRole",
501 },
502 ],
503 }),
504 Tags: createTagsList(tags),
505 })
506 .pipe(
507 Effect.catchTag("EntityAlreadyExistsException", () =>
508 iam.getRole({ RoleName: roleName }).pipe(
509 Effect.filterOrFail(
510 (existing) => hasTags(tags, existing.Role?.Tags),
511 () =>
512 new Error(
513 `Role '${roleName}' already exists and is not managed by alchemy`,
514 ),
515 ),
516 ),
517 ),
518 );
519 return role.Role!.Arn!;
520});
521
522/**
523 * Ensure the ECS execution role exists with the standard execution policy
524 * (plus any additional managed policies) attached.
525 */
526export const ensureTaskExecutionRole = Effect.fn(function* ({
527 id,
528 roleName,
529 managedPolicyArns,
530}: {
531 id: string;
532 roleName: string;
533 managedPolicyArns?: string[];
534}) {
535 const roleArn = yield* createTaskRoleIfNotExists({ id, roleName });
536 const policies = [
537 "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
538 ...(managedPolicyArns ?? []),
539 ];
540 for (const policyArn of policies) {
541 yield* iam
542 .attachRolePolicy({
543 RoleName: roleName,
544 PolicyArn: policyArn,
545 })
546 .pipe(Effect.catchTag("LimitExceededException", () => Effect.void));
547 }
548 return roleArn;
549});
550
551/** Inline policy granting the execution role read access to `environmentFiles`. */
552const ENVIRONMENT_FILES_POLICY_NAME = "alchemy-environment-files";
553
554/**
555 * Sync the execution role's read access to the S3 `environmentFiles`: put
556 * an inline policy granting `s3:GetObject` on the referenced objects (and
557 * `s3:GetBucketLocation` on their buckets), or delete the policy when no
558 * environment files are configured.
559 */
560export const syncEnvironmentFilesPolicy = Effect.fn(function* ({
561 roleName,
562 environmentFiles,
563}: {
564 roleName: string;
565 environmentFiles: ecs.EnvironmentFile[] | undefined;
566}) {
567 const objectArns = (environmentFiles ?? [])
568 .filter((file) => file.type === "s3")
569 .map((file) => file.value);
570 if (objectArns.length === 0) {
571 yield* iam
572 .deleteRolePolicy({
573 RoleName: roleName,
574 PolicyName: ENVIRONMENT_FILES_POLICY_NAME,
575 })
576 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
577 return;
578 }
579 const bucketArns = [...new Set(objectArns.map((arn) => arn.split("/")[0]))];
580 yield* iam.putRolePolicy({
581 RoleName: roleName,
582 PolicyName: ENVIRONMENT_FILES_POLICY_NAME,
583 PolicyDocument: JSON.stringify({
584 Version: "2012-10-17",
585 Statement: [
586 {
587 Effect: "Allow",
588 Action: ["s3:GetObject"],
589 Resource: objectArns,
590 },
591 {
592 Effect: "Allow",
593 Action: ["s3:GetBucketLocation"],
594 Resource: bucketArns,
595 },
596 ],
597 }),
598 });
599});
600
601/** Ensure the CloudWatch log group the task writes to exists. */
602export const ensureTaskLogGroup = Effect.fn(function* ({
603 id,
604 logGroupName,
605}: {
606 id: string;
607 logGroupName: string;
608}) {
609 const { accountId, region } = yield* AWSEnvironment.current;
610 const tags = yield* createInternalTags(id);
611 yield* logs
612 .createLogGroup({
613 logGroupName,
614 tags,
615 })
616 .pipe(Effect.catchTag("ResourceAlreadyExistsException", () => Effect.void));
617 return `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`;
618});
619
620/**
621 * Apply the binding channel to the task role: collect env vars, put (or
622 * clear) the inline policy from bound policy statements, and dedupe
623 * binding-requested volumes/mount points.
624 */
625export const attachTaskBindings = Effect.fn(function* ({
626 roleName,
627 policyName,
628 bindings,
629}: {
630 roleName: string;
631 policyName: string;
632 bindings: ResourceBinding<TaskBindingContract>[];
633}) {
634 const activeBindings = bindings.filter(
635 (binding: ResourceBinding<TaskBindingContract> & { action?: string }) =>
636 binding.action !== "delete",
637 );
638
639 const env = activeBindings
640 .map((binding) => binding?.data?.env)
641 .reduce((acc, value) => ({ ...acc, ...value }), {});
642
643 const policyStatements = activeBindings.flatMap(
644 (binding) =>
645 binding?.data?.policyStatements?.map((statement) => ({
646 ...statement,
647 Sid: statement.Sid?.replace(/[^A-Za-z0-9]+/gi, ""),
648 })) ?? [],
649 );
650
651 // Volumes/mount points requested through the binding channel (e.g.
652 // `EFS.Mount`) — deduped by volume name / container path; merged
653 // with the `volumes` prop and primary container in `reconcile`.
654 const volumes = [
655 ...new Map(
656 activeBindings
657 .flatMap((binding) => binding?.data?.volumes ?? [])
658 .map((volume) => [volume.name, volume] as const),
659 ).values(),
660 ];
661 const mountPoints = [
662 ...new Map(
663 activeBindings
664 .flatMap((binding) => binding?.data?.mountPoints ?? [])
665 .map((point) => [point.containerPath, point] as const),
666 ).values(),
667 ];
668
669 if (policyStatements.length > 0) {
670 yield* iam.putRolePolicy({
671 RoleName: roleName,
672 PolicyName: policyName,
673 PolicyDocument: JSON.stringify({
674 Version: "2012-10-17",
675 Statement: policyStatements,
676 }),
677 });
678 } else {
679 yield* iam
680 .deleteRolePolicy({
681 RoleName: roleName,
682 PolicyName: policyName,
683 })
684 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
685 }
686
687 return { env, volumes, mountPoints };
688});
689
690/**
691 * Register a new task definition revision from the shared
692 * {@link TaskDefinitionConfig} surface.
693 */
694export const registerTaskDefinitionRevision = Effect.fn(function* ({
695 props,
696 family,
697 imageUri,
698 taskRoleArn,
699 executionRoleArn,
700 logGroupName,
701 tags,
702 bindingVolumes = [],
703 bindingMountPoints = [],
704}: {
705 props: TaskDefinitionConfig;
706 family: string;
707 imageUri: string;
708 taskRoleArn: string;
709 executionRoleArn: string;
710 logGroupName: string;
711 tags: Record<string, string>;
712 /** Task-level volumes requested through the binding channel. */
713 bindingVolumes?: ecs.Volume[];
714 /** Primary-container mount points for binding-requested volumes. */
715 bindingMountPoints?: ecs.MountPoint[];
716}) {
717 const { region } = yield* AWSEnvironment.current;
718 const containerName = props.container?.name ?? family;
719 const primaryContainer: ecs.ContainerDefinition = {
720 essential: true,
721 name: containerName,
722 image: imageUri,
723 command: props.command,
724 portMappings:
725 props.port !== undefined
726 ? [
727 {
728 containerPort: props.port,
729 hostPort: props.port,
730 protocol: "tcp",
731 },
732 ]
733 : undefined,
734 environment: Object.entries(props.env ?? {}).map(([name, value]) => ({
735 name,
736 value: typeof value === "string" ? value : JSON.stringify(value),
737 })),
738 environmentFiles: props.environmentFiles,
739 logConfiguration: {
740 logDriver: "awslogs",
741 options: {
742 "awslogs-group": logGroupName,
743 "awslogs-region": region,
744 "awslogs-stream-prefix": family,
745 },
746 },
747 ...props.container,
748 // Merge binding-requested mount points (e.g. `EFS.Mount`) with any
749 // the user configured on the primary container.
750 ...(bindingMountPoints.length > 0
751 ? {
752 mountPoints: [
753 ...(props.container?.mountPoints ?? []),
754 ...bindingMountPoints,
755 ],
756 }
757 : {}),
758 };
759 const response = yield* ecs.registerTaskDefinition({
760 family,
761 taskRoleArn,
762 executionRoleArn,
763 networkMode: props.networkMode ?? "awsvpc",
764 requiresCompatibilities: props.requiresCompatibilities ?? ["FARGATE"],
765 cpu: String(props.cpu ?? 256),
766 memory: String(props.memory ?? 512),
767 volumes:
768 bindingVolumes.length > 0
769 ? [...(props.volumes ?? []), ...bindingVolumes]
770 : props.volumes,
771 placementConstraints: props.placementConstraints,
772 runtimePlatform: props.runtimePlatform,
773 ephemeralStorage: props.ephemeralStorage,
774 ipcMode: props.ipcMode,
775 pidMode: props.pidMode,
776 proxyConfiguration: props.proxyConfiguration,
777 inferenceAccelerators: props.inferenceAccelerators,
778 enableFaultInjection: props.enableFaultInjection,
779 ...props.taskDefinition,
780 containerDefinitions: [primaryContainer, ...(props.sidecars ?? [])],
781 tags: Object.entries(tags).map(([key, value]) => ({ key, value })),
782 });
783 const taskDefinition = response.taskDefinition;
784 if (!taskDefinition?.taskDefinitionArn) {
785 return yield* Effect.die(
786 new Error("registerTaskDefinition returned no task definition"),
787 );
788 }
789 return taskDefinition;
790});
791
792/**
793 * Sync tags on a task definition revision: diff observed revision tags
794 * against desired and apply the delta.
795 */
796export const syncTaskDefinitionTags = Effect.fn(function* ({
797 revisionArn,
798 tags,
799}: {
800 revisionArn: string;
801 tags: Record<string, string>;
802}) {
803 const observedTags = Object.fromEntries(
804 (
805 (yield* ecs
806 .listTagsForResource({ resourceArn: revisionArn })
807 .pipe(
808 Effect.catchTag("ClientException", () =>
809 Effect.succeed({ tags: undefined } as { tags?: ecs.Tag[] }),
810 ),
811 )).tags ?? []
812 )
813 .filter(
814 (t): t is { key: string; value: string } =>
815 typeof t.key === "string" && typeof t.value === "string",
816 )
817 .map((t) => [t.key, t.value]),
818 );
819 const { removed: removedTags, upsert: upsertTags } = diffTags(
820 observedTags,
821 tags,
822 );
823 if (upsertTags.length > 0) {
824 yield* ecs.tagResource({
825 resourceArn: revisionArn,
826 tags: upsertTags.map((t) => ({ key: t.Key, value: t.Value })),
827 });
828 }
829 if (removedTags.length > 0) {
830 yield* ecs.untagResource({
831 resourceArn: revisionArn,
832 tagKeys: removedTags,
833 });
834 }
835});
836
837/** The family segment of a task definition revision ARN (`…:task-definition/<family>:<revision>`). */
838const taskFamilyOfArn = (arn: string) => arn.split("/").pop()?.split(":")[0];
839
840/**
841 * Reap the task-definition revision superseded by a reconcile: registering
842 * always produces a NEW revision, so without this every reconcile strands
843 * the previous revision ACTIVE forever. Deregister + hard-delete the prior
844 * revision once the new one is registered.
845 *
846 * Guarded to the same family — `previousArn` can reference a foreign task
847 * definition this resource does not own (e.g. an `ECS.Service` switched from
848 * a BYO `task:` reference to the image-owning form), which must be left
849 * untouched. Idempotent: both calls tolerate "already gone", and a revision
850 * still referenced by running tasks parks in `DELETE_IN_PROGRESS` until AWS
851 * finishes the delete.
852 */
853export const reapSupersededTaskDefinitionRevision = Effect.fn(function* ({
854 previousArn,
855 nextArn,
856}: {
857 /** The revision recorded before this reconcile (`output.taskDefinitionArn`). */
858 previousArn: string | undefined;
859 /** The freshly-registered revision ARN. */
860 nextArn: string;
861}) {
862 if (
863 previousArn === undefined ||
864 previousArn === nextArn ||
865 taskFamilyOfArn(previousArn) !== taskFamilyOfArn(nextArn)
866 ) {
867 return;
868 }
869 yield* ecs
870 .deregisterTaskDefinition({ taskDefinition: previousArn })
871 .pipe(Effect.catchTag("ClientException", () => Effect.void));
872 yield* ecs
873 .deleteTaskDefinitions({ taskDefinitions: [previousArn] })
874 .pipe(Effect.catchTag("ClientException", () => Effect.void));
875});
876
877/**
878 * Tear down the infrastructure a task definition owns: every remaining
879 * revision of the family (deregister + hard delete), the ECR repository, the
880 * log group, and the task/execution roles. Idempotent — every step tolerates
881 * "already gone".
882 */
883export const deleteTaskDefinitionInfrastructure = Effect.fn(function* (output: {
884 taskDefinitionArn: string;
885 /**
886 * The family owned by this resource. When present, EVERY remaining ACTIVE
887 * revision is swept — state rows written before reconcile-time revision
888 * reaping can have superseded revisions beyond the recorded one.
889 */
890 taskFamily?: string;
891 repositoryName: string;
892 logGroupName: string;
893 taskRoleName: string;
894 executionRoleName: string;
895}) {
896 const familyArns = output.taskFamily
897 ? yield* ecs.listTaskDefinitions
898 .items({
899 familyPrefix: output.taskFamily,
900 status: "ACTIVE",
901 })
902 .pipe(
903 // `familyPrefix` is a prefix match — filter to the exact family so
904 // a family that happens to prefix another resource's is untouched.
905 // A nonexistent family is an empty list, not an error, so nothing
906 // is caught here: a real list failure must propagate — swallowing
907 // it would silently skip the sweep and leak the revisions.
908 Stream.filter((arn) => taskFamilyOfArn(arn) === output.taskFamily),
909 Stream.runCollect,
910 Effect.map((chunk) => Array.from(chunk)),
911 )
912 : [];
913 const revisionArns = [...new Set([output.taskDefinitionArn, ...familyArns])];
914
915 for (const arn of revisionArns) {
916 yield* ecs
917 .deregisterTaskDefinition({ taskDefinition: arn })
918 .pipe(Effect.catchTag("ClientException", () => Effect.void));
919 }
920
921 // Deregistering only flips the revisions to INACTIVE — they still
922 // exist (and show up in `listTaskDefinitions --status INACTIVE`)
923 // forever. Hard-delete them so destroying a Task leaves zero
924 // task-definition leftovers. `deleteTaskDefinitions` accepts at most
925 // 10 ARNs per call.
926 for (let i = 0; i < revisionArns.length; i += 10) {
927 yield* ecs
928 .deleteTaskDefinitions({
929 taskDefinitions: revisionArns.slice(i, i + 10),
930 })
931 .pipe(Effect.catchTag("ClientException", () => Effect.void));
932 }
933
934 yield* ecr
935 .deleteRepository({
936 repositoryName: output.repositoryName,
937 force: true,
938 })
939 .pipe(Effect.catchTag("RepositoryNotFoundException", () => Effect.void));
940
941 yield* logs
942 .deleteLogGroup({
943 logGroupName: output.logGroupName,
944 })
945 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
946
947 for (const roleName of [output.taskRoleName, output.executionRoleName]) {
948 // Delete inline policies on BOTH roles — the task role carries the
949 // bindings policy, and the execution role can carry inline policies too
950 // (e.g. the Service's secrets-read policy). A role with any inline
951 // policy left rejects `deleteRole` with `DeleteConflictException`.
952 yield* iam.listRolePolicies
953 .items({
954 RoleName: roleName,
955 })
956 .pipe(
957 Stream.mapEffect((policyName) =>
958 iam
959 .deleteRolePolicy({
960 RoleName: roleName,
961 PolicyName: policyName,
962 })
963 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)),
964 ),
965 Stream.runDrain,
966 // The role may already be gone (delete re-run / race) — treat a
967 // missing role as "no policies to delete" so delete is idempotent.
968 Effect.catchTag("NoSuchEntityException", () => Effect.void),
969 );
970
971 yield* iam.listAttachedRolePolicies
972 .items({
973 RoleName: roleName,
974 })
975 .pipe(
976 Stream.mapEffect((policy) =>
977 iam
978 .detachRolePolicy({
979 RoleName: roleName,
980 PolicyArn: policy.PolicyArn!,
981 })
982 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)),
983 ),
984 Stream.runDrain,
985 Effect.catchTag("NoSuchEntityException", () => Effect.void),
986 );
987 yield* iam
988 .deleteRole({
989 RoleName: roleName,
990 })
991 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
992 }
993});
994
995export const TaskProvider = () =>
996 Provider.effect(
997 Task,
998 Effect.gen(function* () {
999 const stack = yield* Stack;
1000 const imageSource = yield* makeImageSource;
1001
1002 const alchemyEnv = {
1003 ALCHEMY_STACK_NAME: stack.name,
1004 ALCHEMY_STAGE: stack.stage,
1005 ALCHEMY_PHASE: "runtime",
1006 };
1007
1008 const toTaskFamily = (id: string, props: { taskName?: string } = {}) =>
1009 props.taskName
1010 ? Effect.succeed(props.taskName)
1011 : createPhysicalName({
1012 id,
1013 maxLength: 255,
1014 lowercase: true,
1015 });
1016
1017 const createRoleName = (id: string, suffix: string) =>
1018 createPhysicalName({
1019 id: `${id}-${suffix}`,
1020 maxLength: 64,
1021 });
1022
1023 const createPolicyName = (id: string, suffix: string) =>
1024 createPhysicalName({
1025 id: `${id}-${suffix}`,
1026 maxLength: 128,
1027 });
1028
1029 const createRepositoryName = (id: string) =>
1030 createPhysicalName({
1031 id: `${id}-repo`,
1032 maxLength: 256,
1033 lowercase: true,
1034 });
1035
1036 const createLogGroupName = (id: string) =>
1037 createPhysicalName({
1038 id: `${id}-logs`,
1039 maxLength: 512,
1040 lowercase: true,
1041 });
1042
1043 // Reconstruct the full `Task` Attributes shape from a described task
1044 // definition. Returns `undefined` for task definitions that don't match
1045 // the shape this provider produces (single container whose image is an
1046 // ECR `<repoUri>:<hash>`, task/execution role ARNs, an awslogs log group)
1047 // so foreign task definitions in the account are skipped by `list()`.
1048 const toListAttributes = (
1049 taskDefinition: ecs.TaskDefinition,
1050 region: string,
1051 accountId: string,
1052 ): Task["Attributes"] | undefined => {
1053 if (!taskDefinition.taskDefinitionArn || !taskDefinition.family) {
1054 return undefined;
1055 }
1056 const container = taskDefinition.containerDefinitions?.[0];
1057 const image = container?.image;
1058 const taskRoleArn = taskDefinition.taskRoleArn;
1059 const executionRoleArn = taskDefinition.executionRoleArn;
1060 const logGroupName =
1061 container?.logConfiguration?.options?.["awslogs-group"];
1062 if (
1063 !container?.name ||
1064 !image ||
1065 !image.includes(":") ||
1066 !taskRoleArn ||
1067 !executionRoleArn ||
1068 !logGroupName
1069 ) {
1070 return undefined;
1071 }
1072 const lastColon = image.lastIndexOf(":");
1073 const repositoryUri = image.slice(0, lastColon);
1074 const hash = image.slice(lastColon + 1);
1075 const repositoryName = repositoryUri.split("/").slice(1).join("/");
1076 const taskRoleName = taskRoleArn.split(":role/")[1] ?? taskRoleArn;
1077 const executionRoleName =
1078 executionRoleArn.split(":role/")[1] ?? executionRoleArn;
1079 return {
1080 taskDefinitionArn: taskDefinition.taskDefinitionArn,
1081 taskFamily: taskDefinition.family,
1082 containerName: container.name,
1083 port: container.portMappings?.[0]?.containerPort ?? 3000,
1084 imageUri: image,
1085 repositoryName,
1086 repositoryUri,
1087 taskRoleArn,
1088 taskRoleName,
1089 executionRoleArn,
1090 executionRoleName,
1091 logGroupName,
1092 logGroupArn: `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`,
1093 code: { hash },
1094 };
1095 };
1096
1097 return {
1098 stables: [
1099 "repositoryName",
1100 "repositoryUri",
1101 "taskRoleArn",
1102 "taskRoleName",
1103 "executionRoleArn",
1104 "executionRoleName",
1105 "logGroupName",
1106 "logGroupArn",
1107 "taskFamily",
1108 ],
1109 diff: Effect.fn(function* ({ id, olds, news, output }) {
1110 if (!isResolved(news)) return;
1111 if (
1112 (yield* toTaskFamily(id, olds ?? {})) !==
1113 (yield* toTaskFamily(id, news ?? {}))
1114 ) {
1115 return { action: "replace" } as const;
1116 }
1117 // Content drift: the props don't change when files under a
1118 // `context` path (or the mirrored `image` ref's meaning) change —
1119 // nor, for `main` sources, when the user's program or the
1120 // generated bootstrap template changes. Hash the source (running
1121 // the bundler for `main`, so the hash covers the bootstrap entry)
1122 // and surface drift as an update; without this a bootstrap or
1123 // code-only change would silently no-op until `--force`.
1124 if (output) {
1125 const hash = yield* imageSource.hash(taskImageInput(news));
1126 if (hash !== undefined && hash !== output.code.hash) {
1127 return { action: "update" } as const;
1128 }
1129 }
1130 }),
1131 read: Effect.fn(function* ({ id, olds, output }) {
1132 const family =
1133 output?.taskFamily ?? (yield* toTaskFamily(id, olds ?? {}));
1134 const described = yield* ecs
1135 .describeTaskDefinition({
1136 taskDefinition: output?.taskDefinitionArn ?? family,
1137 })
1138 .pipe(
1139 Effect.catchTag("ClientException", () =>
1140 Effect.succeed(undefined),
1141 ),
1142 );
1143 const taskDefinition = described?.taskDefinition;
1144 if (!taskDefinition?.taskDefinitionArn) {
1145 return undefined;
1146 }
1147 if (!output) {
1148 return undefined;
1149 }
1150 return {
1151 ...output,
1152 taskDefinitionArn: taskDefinition.taskDefinitionArn,
1153 taskFamily: taskDefinition.family ?? family,
1154 containerName:
1155 taskDefinition.containerDefinitions?.[0]?.name ??
1156 output.containerName,
1157 port:
1158 taskDefinition.containerDefinitions?.[0]?.portMappings?.[0]
1159 ?.containerPort ?? output.port,
1160 };
1161 }),
1162 reconcile: Effect.fn(function* ({
1163 id,
1164 news,
1165 bindings,
1166 output,
1167 session,
1168 }) {
1169 // Prefer the deployed name: regenerating would target a different
1170 // resource if the generator's output for this id ever drifts. (An
1171 // explicit taskName change arrives here as a fresh replacement
1172 // instance with no output.)
1173 const family = output?.taskFamily ?? (yield* toTaskFamily(id, news));
1174 const taskRoleName =
1175 output?.taskRoleName ?? (yield* createRoleName(id, "task-role"));
1176 const executionRoleName =
1177 output?.executionRoleName ??
1178 (yield* createRoleName(id, "execution-role"));
1179 const taskPolicyName = yield* createPolicyName(id, "task-policy");
1180 const repositoryName =
1181 output?.repositoryName ?? (yield* createRepositoryName(id));
1182 const logGroupName =
1183 output?.logGroupName ?? (yield* createLogGroupName(id));
1184 const tags = {
1185 ...(yield* createInternalTags(id)),
1186 ...news.tags,
1187 };
1188
1189 // Ensure roles, repository, and log group. Each helper is
1190 // idempotent (creates on miss, adopts on race) so the same
1191 // sequence runs on initial create, adoption, or update.
1192 const taskRoleArn =
1193 output?.taskRoleArn ??
1194 (yield* createTaskRoleIfNotExists({ id, roleName: taskRoleName }));
1195 const executionRoleArn =
1196 output?.executionRoleArn ??
1197 (yield* ensureTaskExecutionRole({
1198 id,
1199 roleName: executionRoleName,
1200 managedPolicyArns: news.executionRoleManagedPolicyArns,
1201 }));
1202
1203 for (const policyArn of news.taskRoleManagedPolicyArns ?? []) {
1204 yield* iam
1205 .attachRolePolicy({
1206 RoleName: taskRoleName,
1207 PolicyArn: policyArn,
1208 })
1209 .pipe(
1210 Effect.catchTag("LimitExceededException", () => Effect.void),
1211 );
1212 }
1213
1214 // Environment files: the execution role reads the referenced S3
1215 // objects at task start.
1216 yield* syncEnvironmentFilesPolicy({
1217 roleName: executionRoleName,
1218 environmentFiles: news.environmentFiles,
1219 });
1220
1221 const {
1222 env: bindingEnv,
1223 volumes: bindingVolumes,
1224 mountPoints: bindingMountPoints,
1225 } = yield* attachTaskBindings({
1226 roleName: taskRoleName,
1227 policyName: taskPolicyName,
1228 bindings,
1229 });
1230
1231 const logGroupArn =
1232 output?.logGroupArn ??
1233 (yield* ensureTaskLogGroup({
1234 id,
1235 logGroupName,
1236 }));
1237
1238 // Resolve the container image from whichever source the props
1239 // declare (`main` | `context` | `image`), building/mirroring and
1240 // pushing into the task's ECR repository, then register a new
1241 // task definition revision. Task definitions are versioned in
1242 // AWS, so registering a new revision is the unit of "update" —
1243 // the superseded revision is reaped after registration below.
1244 const resolved = yield* imageSource.resolve({
1245 ...taskImageInput(news),
1246 id,
1247 repositoryName,
1248 repositoryUri:
1249 output?.repositoryUri && output.repositoryName === repositoryName
1250 ? output.repositoryUri
1251 : undefined,
1252 tags,
1253 session,
1254 });
1255
1256 const taskDefinition = yield* registerTaskDefinitionRevision({
1257 props: {
1258 ...news,
1259 env: {
1260 ...bindingEnv,
1261 ...alchemyEnv,
1262 ...news.env,
1263 },
1264 },
1265 family,
1266 imageUri: resolved.imageUri,
1267 taskRoleArn,
1268 executionRoleArn,
1269 logGroupName,
1270 tags,
1271 bindingVolumes,
1272 bindingMountPoints,
1273 });
1274
1275 // Sync tags — task definition revisions carry tags at register
1276 // time, but tags are mutable on the revision ARN. Diff the observed
1277 // revision tags against desired so tag-only updates converge.
1278 yield* syncTaskDefinitionTags({
1279 revisionArn: taskDefinition.taskDefinitionArn!,
1280 tags,
1281 });
1282
1283 // The registration above superseded the previously-recorded
1284 // revision — reap it so revisions don't accumulate ACTIVE forever.
1285 yield* reapSupersededTaskDefinitionRevision({
1286 previousArn: output?.taskDefinitionArn,
1287 nextArn: taskDefinition.taskDefinitionArn!,
1288 });
1289
1290 yield* session.note(taskDefinition.taskDefinitionArn!);
1291 return {
1292 taskDefinitionArn: taskDefinition.taskDefinitionArn!,
1293 taskFamily: family,
1294 containerName:
1295 taskDefinition.containerDefinitions?.[0]?.name ?? family,
1296 port: news.port ?? output?.port ?? 3000,
1297 imageUri: resolved.imageUri,
1298 repositoryName: resolved.repositoryName,
1299 repositoryUri: resolved.repositoryUri,
1300 taskRoleArn,
1301 taskRoleName,
1302 executionRoleArn,
1303 executionRoleName,
1304 logGroupName,
1305 logGroupArn,
1306 code: {
1307 hash: resolved.codeHash,
1308 },
1309 };
1310 }),
1311 // Enumerate every ACTIVE task definition in the account/region,
1312 // hydrate each via `describeTaskDefinition`, and reconstruct the full
1313 // Attributes shape. Foreign task definitions that don't match the shape
1314 // this provider produces are skipped (see `toListAttributes`).
1315 list: () =>
1316 Effect.gen(function* () {
1317 const { accountId, region } = yield* AWSEnvironment.current;
1318 const arns = yield* ecs.listTaskDefinitions
1319 .pages({ status: "ACTIVE" })
1320 .pipe(
1321 Stream.runCollect,
1322 Effect.map((chunk) =>
1323 Array.from(chunk).flatMap(
1324 (page) => page.taskDefinitionArns ?? [],
1325 ),
1326 ),
1327 );
1328 const rows = yield* Effect.forEach(
1329 arns,
1330 (arn) =>
1331 ecs.describeTaskDefinition({ taskDefinition: arn }).pipe(
1332 Effect.map((described) =>
1333 described.taskDefinition
1334 ? toListAttributes(
1335 described.taskDefinition,
1336 region,
1337 accountId,
1338 )
1339 : undefined,
1340 ),
1341 Effect.catchTag("ClientException", () =>
1342 Effect.succeed(undefined),
1343 ),
1344 ),
1345 { concurrency: 10 },
1346 );
1347 return rows.filter(
1348 (row): row is Task["Attributes"] => row !== undefined,
1349 );
1350 }),
1351 delete: Effect.fn(function* ({ output }) {
1352 yield* deleteTaskDefinitionInfrastructure(output);
1353 }),
1354 };
1355 }),
1356 );