1import * as Effect from "effect/Effect";
2import * as Binding from "../../Binding.ts";
3import * as Output from "../../Output.ts";
4import { isBindingHost } from "../Lambda/Function.ts";
5import type { Cluster } from "./Cluster.ts";
6import type { Service } from "./Service.ts";
7import { isTask, type Task } from "./Task.ts";
8
9/**
10 * Shared scaffolding for AWS ECS HTTP bindings.
11 *
12 * NOT exported from `index.ts` — every `{Op}Http.ts` in this service is a
13 * thin `Layer.effect(Cap, make…HttpBinding({ … }))` over one of the three
14 * builders below. Everything except the operation, the IAM action list, and
15 * the IAM resource scope is boilerplate.
16 *
17 * ECS scopes cluster-addressed actions against sub-resource ARNs derived
18 * from the cluster ARN — `arn:…:task/{clusterName}/*`,
19 * `arn:…:service/{clusterName}/*`, `arn:…:container-instance/{clusterName}/*`
20 * — or, for list actions with no usable resource type on Fargate, against
21 * `*` conditioned on the bound cluster.
22 */
23
24/**
25 * `iam:PassRole` pre-typed as `string[]` (a declared alternative of
26 * `PolicyStatement.Action`). A fresh literal array at the `host.bind` call
27 * site would be contextually typed against `Input<IamAction[] | string[]>`
28 * across the `Function | Task` host union, forcing normalization of the
29 * ~18k-literal `IamAction` union and tripping TS2590 ("union type too
30 * complex") under TypeScript 7.
31 */
32const passRoleActions: string[] = ["iam:PassRole"];
33
34/** IAM resource scopes for cluster-bound ECS operations. */
35export type EcsClusterIamResource =
36 | "cluster"
37 | "task"
38 | "service"
39 | "container-instance";
40
41const clusterSubresourcePattern = (
42 cluster: Cluster,
43 kind: Exclude<EcsClusterIamResource, "cluster">,
44) =>
45 Output.map(
46 cluster.clusterArn,
47 (arn) => `${arn.replace(":cluster/", `:${kind}/`)}/*`,
48 );
49
50const clusterIamResources = (
51 cluster: Cluster,
52 resources: readonly EcsClusterIamResource[],
53) =>
54 resources.map((kind) =>
55 kind === "cluster"
56 ? Output.interpolate`${cluster.clusterArn}`
57 : clusterSubresourcePattern(cluster, kind),
58 );
59
60/**
61 * Build the impl Effect for a cluster-addressed operation whose request
62 * carries a `cluster` field: the runtime callable injects the bound
63 * {@link Cluster}'s ARN and the deploy-time half grants `actions` on the
64 * requested `resources` scopes (or on `*` conditioned on the bound cluster
65 * when `resources` is `"cluster-condition"` — for list actions that have no
66 * usable resource type on Fargate).
67 */
68export const makeEcsClusterHttpBinding = <
69 I extends { cluster?: string },
70 A,
71 E,
72 R,
73>(options: {
74 /** Fully-qualified binding tag, e.g. `AWS.ECS.DescribeServices`. */
75 tag: string;
76 /** The distilled operation; `cluster` is injected from the bound cluster. */
77 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
78 /** IAM actions granted. */
79 actions: readonly string[];
80 /** IAM resource scopes derived from the cluster ARN. */
81 resources: readonly EcsClusterIamResource[] | "cluster-condition";
82}) =>
83 Effect.gen(function* () {
84 const op = yield* options.operation;
85
86 return Effect.fn(function* (cluster: Cluster) {
87 const ClusterArn = yield* cluster.clusterArn;
88 if (!globalThis.__ALCHEMY_RUNTIME__) {
89 const host = yield* Binding.Host;
90 if (isBindingHost(host) || isTask(host)) {
91 yield* host.bind`Allow(${host}, ${options.tag}(${cluster}))`({
92 policyStatements: [
93 options.resources === "cluster-condition"
94 ? {
95 Effect: "Allow",
96 Action: [...options.actions],
97 Resource: ["*"],
98 Condition: {
99 ArnEquals: { "ecs:cluster": cluster.clusterArn },
100 },
101 }
102 : {
103 Effect: "Allow",
104 Action: [...options.actions],
105 Resource: clusterIamResources(cluster, options.resources),
106 },
107 ],
108 });
109 }
110 }
111 return Effect.fn(`${options.tag}(${cluster.LogicalId})`)(function* (
112 request: Omit<I, "cluster">,
113 ) {
114 return yield* op({
115 ...request,
116 cluster: yield* ClusterArn,
117 } as I);
118 });
119 });
120 });
121
122/**
123 * Strip the revision suffix from a task-definition ARN. The bound
124 * `taskDefinitionArn` attribute pins whatever revision existed when the
125 * HOST was reconciled — for a circularly-bound task that is the pre-create
126 * STUB revision, not the real one registered afterwards. Launching by
127 * family makes ECS resolve the latest ACTIVE revision, which is the
128 * binding's intended semantics (like invoking a Lambda by name).
129 */
130const revisionlessTaskDefinitionArn = (arn: string) => arn.replace(/:\d+$/, "");
131
132/** The task-definition family name from its (possibly revisioned) ARN. */
133const taskDefinitionFamilyOf = (arn: string) =>
134 revisionlessTaskDefinitionArn(arn).split("/").pop()!;
135
136/**
137 * Build the impl Effect for a task-launch operation (`RunTask`/`StartTask`):
138 * the runtime callable injects the bound {@link Cluster}'s ARN as `cluster`
139 * and the bound {@link Task}'s definition family (revision-less ARN) as
140 * `taskDefinition`; the deploy-time half grants `actions` on every revision
141 * of the task definition plus `iam:PassRole` on the task and execution
142 * roles.
143 */
144export const makeEcsTaskLaunchHttpBinding = <
145 I extends { cluster?: string; taskDefinition: string },
146 A,
147 E,
148 R,
149>(options: {
150 /** Fully-qualified binding tag, e.g. `AWS.ECS.RunTask`. */
151 tag: string;
152 /** The distilled operation; `cluster` + `taskDefinition` are injected. */
153 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
154 /** IAM actions granted on the task definition ARN. */
155 actions: readonly string[];
156}) =>
157 Effect.gen(function* () {
158 const op = yield* options.operation;
159
160 return Effect.fn(function* (cluster: Cluster, task: Task) {
161 const ClusterArn = yield* cluster.clusterArn;
162 const TaskDefinitionArn = yield* task.taskDefinitionArn;
163 if (!globalThis.__ALCHEMY_RUNTIME__) {
164 const host = yield* Binding.Host;
165 if (isBindingHost(host) || isTask(host)) {
166 yield* host.bind`Allow(${host}, ${options.tag}(${cluster}, ${task}))`(
167 {
168 policyStatements: [
169 {
170 Effect: "Allow",
171 Action: [...options.actions],
172 // All revisions: the launch resolves the latest ACTIVE
173 // revision, which may be registered after this grant.
174 Resource: [
175 Output.map(
176 task.taskDefinitionArn,
177 (arn) => `${revisionlessTaskDefinitionArn(arn)}:*`,
178 ),
179 ],
180 },
181 {
182 Effect: "Allow",
183 Action: passRoleActions,
184 Resource: [task.taskRoleArn, task.executionRoleArn],
185 },
186 ],
187 },
188 );
189 }
190 }
191 return Effect.fn(
192 `${options.tag}(${cluster.LogicalId}, ${task.LogicalId})`,
193 )(function* (request: Omit<I, "cluster" | "taskDefinition">) {
194 return yield* op({
195 ...request,
196 cluster: yield* ClusterArn,
197 // Family, not the pinned ARN: resolves the latest ACTIVE revision.
198 taskDefinition: taskDefinitionFamilyOf(yield* TaskDefinitionArn),
199 } as I);
200 });
201 });
202 });
203
204/** IAM resource scopes for service-bound ECS operations. */
205export type EcsServiceIamResource =
206 | "service"
207 | "service-deployment"
208 | "service-revision";
209
210const serviceIamResources = (
211 service: Service,
212 resources: readonly EcsServiceIamResource[],
213) =>
214 resources.map((kind) =>
215 kind === "service"
216 ? Output.interpolate`${service.serviceArn}`
217 : Output.map(
218 service.serviceArn,
219 (arn) => `${arn.replace(":service/", `:${kind}/`)}/*`,
220 ),
221 );
222
223/**
224 * Build the impl Effect for a service-addressed operation (deployment
225 * observation and blue/green lifecycle-hook control). The deploy-time half
226 * grants `actions` on the requested scopes derived from the bound
227 * {@link Service}'s ARN. When `inject` is set, the runtime callable injects
228 * the service ARN as `service` and the cluster ARN as `cluster`; otherwise
229 * the request passes through as-is (deployment/revision ARNs are only known
230 * at runtime).
231 */
232export const makeEcsServiceHttpBinding = <I, A, E, R>(options: {
233 /** Fully-qualified binding tag, e.g. `AWS.ECS.StopServiceDeployment`. */
234 tag: string;
235 /** The distilled operation. */
236 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
237 /** IAM actions granted. */
238 actions: readonly string[];
239 /** IAM resource scopes derived from the service ARN. */
240 resources: readonly EcsServiceIamResource[];
241 /** Inject `service` (service ARN) + `cluster` (cluster ARN) into requests. */
242 inject?: boolean;
243}) =>
244 Effect.gen(function* () {
245 const op = yield* options.operation;
246
247 return Effect.fn(function* (service: Service) {
248 const ServiceArn = yield* service.serviceArn;
249 const ClusterArn = yield* service.clusterArn;
250 if (!globalThis.__ALCHEMY_RUNTIME__) {
251 const host = yield* Binding.Host;
252 if (isBindingHost(host) || isTask(host)) {
253 yield* host.bind`Allow(${host}, ${options.tag}(${service}))`({
254 policyStatements: [
255 {
256 Effect: "Allow",
257 Action: [...options.actions],
258 Resource: serviceIamResources(service, options.resources),
259 },
260 ],
261 });
262 }
263 }
264 return Effect.fn(`${options.tag}(${service.LogicalId})`)(function* (
265 request: Omit<I, "service" | "cluster">,
266 ) {
267 return yield* op(
268 (options.inject
269 ? {
270 ...request,
271 service: yield* ServiceArn,
272 cluster: yield* ClusterArn,
273 }
274 : request) as I,
275 );
276 });
277 });
278 });