| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Binding from "../../Binding.ts"; |
| 3 | import * as Output from "../../Output.ts"; |
| 4 | import { isBindingHost } from "../Lambda/Function.ts"; |
| 5 | import type { Cluster } from "./Cluster.ts"; |
| 6 | import type { Service } from "./Service.ts"; |
| 7 | import { 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 | */ |
| 32 | const passRoleActions: string[] = ["iam:PassRole"]; |
| 33 | |
| 34 | /** IAM resource scopes for cluster-bound ECS operations. */ |
| 35 | export type EcsClusterIamResource = |
| 36 | | "cluster" |
| 37 | | "task" |
| 38 | | "service" |
| 39 | | "container-instance"; |
| 40 | |
| 41 | const clusterSubresourcePattern = ( |
| 42 | cluster: Cluster, |
| 43 | kind: Exclude<EcsClusterIamResource, "cluster">, |
| 44 | ) => |
| 45 | Output.map( |
| 46 | cluster.clusterArn, |
| 47 | (arn) => `${arn.replace(":cluster/", `:${kind}/`)}/*`, |
| 48 | ); |
| 49 | |
| 50 | const 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 | */ |
| 68 | export 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 | */ |
| 130 | const revisionlessTaskDefinitionArn = (arn: string) => arn.replace(/:\d+$/, ""); |
| 131 | |
| 132 | /** The task-definition family name from its (possibly revisioned) ARN. */ |
| 133 | const 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 | */ |
| 144 | export 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. */ |
| 205 | export type EcsServiceIamResource = |
| 206 | | "service" |
| 207 | | "service-deployment" |
| 208 | | "service-revision"; |
| 209 | |
| 210 | const 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 | */ |
| 232 | export 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 | }); |