| 1 | /** |
| 2 | * The `aws-eks` {@link ClusterAdapter}: everything platform-specific about |
| 3 | * running `Kubernetes.*` workloads on Amazon EKS. |
| 4 | * |
| 5 | * - **connect** — SigV4 bearer tokens (STS presign) against the cluster |
| 6 | * endpoint, re-describing the cluster when the connection doesn't carry |
| 7 | * endpoint/CA (or they've gone stale). |
| 8 | * - **identity** — EKS Pod Identity: a generated IAM role + |
| 9 | * `PodIdentityAssociation`, with `policyStatements` host bindings landed |
| 10 | * as the role's inline policy. |
| 11 | * - **registry** — a per-workload ECR repository; `main` programs are |
| 12 | * bundled, `context` Dockerfiles built, and `image` refs mirrored into |
| 13 | * it. |
| 14 | * - **bootstrap** — generated container entries wiring the AWS credential |
| 15 | * chain so Pod Identity's container-credentials endpoint resolves inside |
| 16 | * the pod. |
| 17 | * - **loadBalancerDefaults** — EKS Auto Mode's `loadBalancerClass` and the |
| 18 | * internet-facing NLB scheme. |
| 19 | * |
| 20 | * Registered by `AWS.providers()`; resolved dynamically by the |
| 21 | * `Kubernetes.*` providers via the connection's `auth.kind`. |
| 22 | */ |
| 23 | import { Credentials } from "@distilled.cloud/aws/Credentials"; |
| 24 | import { Region, type RegionName } from "@distilled.cloud/aws/Region"; |
| 25 | import * as ecr from "@distilled.cloud/aws/ecr"; |
| 26 | import * as eks from "@distilled.cloud/aws/eks"; |
| 27 | import * as SigV4 from "@distilled.cloud/aws/SigV4"; |
| 28 | import * as Effect from "effect/Effect"; |
| 29 | import * as FileSystem from "effect/FileSystem"; |
| 30 | import * as Layer from "effect/Layer"; |
| 31 | import * as Path from "effect/Path"; |
| 32 | import * as Redacted from "effect/Redacted"; |
| 33 | import type { HttpClient } from "effect/http/HttpClient"; |
| 34 | import { |
| 35 | ClusterAdapter, |
| 36 | ClusterNotFoundError, |
| 37 | type ClusterAdapterService, |
| 38 | type ClusterTransport, |
| 39 | type ImageRegistryResolveOptions, |
| 40 | type WorkloadIdentityReconcileOptions, |
| 41 | } from "../../Kubernetes/ClusterAdapter.ts"; |
| 42 | import type { Connection } from "../../Kubernetes/Connection.ts"; |
| 43 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 44 | import { Self } from "../../Self.ts"; |
| 45 | import { AWSEnvironment } from "../Environment.ts"; |
| 46 | import { makeImageSource, type ImageSourceLike } from "../ECR/ImageSource.ts"; |
| 47 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 48 | import { |
| 49 | attachPolicyStatements, |
| 50 | deleteAssociation, |
| 51 | deletePodRole, |
| 52 | ensureAssociation, |
| 53 | ensurePodRole, |
| 54 | } from "./internal/podIdentity.ts"; |
| 55 | |
| 56 | declare module "../../Kubernetes/Connection.ts" { |
| 57 | interface AuthRegistry { |
| 58 | /** |
| 59 | * Authenticate against an Amazon EKS cluster with SigV4 bearer tokens |
| 60 | * minted from the ambient AWS credentials. Contributed by |
| 61 | * `AWS.providers()` — `AWS.EKS.Cluster` attributes expose a ready-made |
| 62 | * `connection` carrying this descriptor. |
| 63 | */ |
| 64 | "aws-eks": { |
| 65 | /** The EKS cluster name. */ |
| 66 | clusterName: string; |
| 67 | /** |
| 68 | * The cluster's region. |
| 69 | * @default the ambient AWS region |
| 70 | */ |
| 71 | region?: string; |
| 72 | }; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | declare module "../../Kubernetes/ClusterAdapter.ts" { |
| 77 | interface IdentityStateRegistry { |
| 78 | /** EKS Pod Identity: generated IAM role + association. */ |
| 79 | "aws-pod-identity": { |
| 80 | /** The ARN of the IAM role pods assume via Pod Identity. */ |
| 81 | roleArn: string; |
| 82 | /** The name of the IAM role pods assume via Pod Identity. */ |
| 83 | roleName: string; |
| 84 | /** The ARN of the Pod Identity association binding the role. */ |
| 85 | associationArn: string; |
| 86 | /** The ID of the Pod Identity association binding the role. */ |
| 87 | associationId: string; |
| 88 | }; |
| 89 | } |
| 90 | interface RegistryStateRegistry { |
| 91 | /** The per-workload ECR repository holding the image. */ |
| 92 | "aws-ecr": { |
| 93 | /** The name of the ECR repository holding the image. */ |
| 94 | repositoryName: string; |
| 95 | /** The URI of the ECR repository holding the image. */ |
| 96 | repositoryUri: string; |
| 97 | }; |
| 98 | } |
| 99 | interface WorkloadIdentityOptions { |
| 100 | /** |
| 101 | * Managed policy ARNs attached to the generated pod-identity role in |
| 102 | * addition to the inline policy synthesized from bindings (EKS only). |
| 103 | */ |
| 104 | managedPolicyArns?: string[]; |
| 105 | } |
| 106 | interface WorkloadBindingContract { |
| 107 | /** |
| 108 | * IAM policy statements landed on the workload's pod-identity role |
| 109 | * (EKS only) — the same host-binding channel as `AWS.Lambda.Function` |
| 110 | * and `AWS.ECS.Task`. |
| 111 | */ |
| 112 | policyStatements?: PolicyStatement[]; |
| 113 | } |
| 114 | interface WorkloadServicesRegistry { |
| 115 | /** AWS credential-chain services ambient inside EKS workload pods. */ |
| 116 | aws: Credentials | Region | AWSEnvironment; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /** Services the adapter's methods close over at layer build. */ |
| 121 | type EksAdapterDeps = |
| 122 | | Credentials |
| 123 | | HttpClient |
| 124 | | Region |
| 125 | | AWSEnvironment |
| 126 | | FileSystem.FileSystem |
| 127 | | Path.Path; |
| 128 | |
| 129 | /** Mint an EKS SigV4 bearer token (STS `GetCallerIdentity` presign). */ |
| 130 | const mintEksToken = Effect.fn(function* (clusterName: string, region: string) { |
| 131 | const credentials = yield* yield* Credentials; |
| 132 | |
| 133 | const presigned = yield* SigV4.sign({ |
| 134 | method: "GET", |
| 135 | url: `https://sts.${region}.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, |
| 136 | headers: { "x-k8s-aws-id": clusterName }, |
| 137 | accessKeyId: Redacted.value(credentials.accessKeyId), |
| 138 | secretAccessKey: credentials.secretAccessKey, |
| 139 | sessionToken: credentials.sessionToken, |
| 140 | service: "sts", |
| 141 | region, |
| 142 | signQuery: true, |
| 143 | allHeaders: true, |
| 144 | }); |
| 145 | |
| 146 | return `k8s-aws-v1.${Buffer.from(presigned.url).toString("base64url")}`; |
| 147 | }); |
| 148 | |
| 149 | /** |
| 150 | * Build a {@link ClusterTransport} for an EKS cluster from known |
| 151 | * endpoint/CA. Captures the ambient AWS services so the per-request token |
| 152 | * mint is self-contained — used by the adapter and by the |
| 153 | * `AWS.EKS.Cluster` provider's own kubernetes-object binding channel. |
| 154 | */ |
| 155 | export const makeEksTransport = Effect.fn(function* (options: { |
| 156 | clusterName: string; |
| 157 | region?: string | undefined; |
| 158 | endpoint: string; |
| 159 | certificateAuthorityData: string; |
| 160 | }) { |
| 161 | const context = yield* Effect.context<Credentials | AWSEnvironment>(); |
| 162 | const region = options.region ?? (yield* AWSEnvironment.current).region; |
| 163 | return { |
| 164 | endpoint: options.endpoint, |
| 165 | certificateAuthorityData: options.certificateAuthorityData, |
| 166 | headers: mintEksToken(options.clusterName, region).pipe( |
| 167 | Effect.map((token) => ({ Authorization: `Bearer ${token}` })), |
| 168 | Effect.provideContext(context), |
| 169 | ), |
| 170 | } satisfies ClusterTransport; |
| 171 | }); |
| 172 | |
| 173 | /** |
| 174 | * The `Kubernetes.Connection` of an EKS cluster — stamped on |
| 175 | * `AWS.EKS.Cluster` attributes so the cluster resource can be passed |
| 176 | * directly as any `Kubernetes.*` workload's `cluster`. |
| 177 | */ |
| 178 | export const eksConnectionOf = (options: { |
| 179 | clusterName: string; |
| 180 | region: string; |
| 181 | endpoint?: string | undefined; |
| 182 | certificateAuthorityData?: string | undefined; |
| 183 | }): Connection => ({ |
| 184 | endpoint: options.endpoint, |
| 185 | certificateAuthorityData: options.certificateAuthorityData, |
| 186 | auth: { |
| 187 | kind: "aws-eks", |
| 188 | clusterName: options.clusterName, |
| 189 | region: options.region, |
| 190 | }, |
| 191 | }); |
| 192 | |
| 193 | const narrowEksAuth = (connection: Connection) => |
| 194 | connection.auth.kind === "aws-eks" |
| 195 | ? Effect.succeed(connection.auth) |
| 196 | : Effect.die( |
| 197 | new Error( |
| 198 | `aws-eks adapter received auth kind '${connection.auth.kind}'`, |
| 199 | ), |
| 200 | ); |
| 201 | |
| 202 | /** |
| 203 | * Provide a per-connection region override so a cluster in another region |
| 204 | * than the ambient one describes correctly. |
| 205 | */ |
| 206 | const withRegion = (region: string | undefined) => { |
| 207 | return <A, E, R>(self: Effect.Effect<A, E, R>) => |
| 208 | region === undefined |
| 209 | ? self |
| 210 | : Effect.provideService( |
| 211 | self, |
| 212 | Region, |
| 213 | Effect.succeed(region as RegionName), |
| 214 | ); |
| 215 | }; |
| 216 | |
| 217 | const createRoleName = (id: string) => |
| 218 | createPhysicalName({ id: `${id}-pod-role`, maxLength: 64 }); |
| 219 | |
| 220 | const createPolicyName = (id: string) => |
| 221 | createPhysicalName({ id: `${id}-pod-policy`, maxLength: 128 }); |
| 222 | |
| 223 | const createRepositoryName = (id: string) => |
| 224 | createPhysicalName({ |
| 225 | id: `${id}-repo`, |
| 226 | maxLength: 256, |
| 227 | lowercase: true, |
| 228 | }); |
| 229 | |
| 230 | /** |
| 231 | * Generated container entry for an Effect-native EKS server: resolves the |
| 232 | * program's registered runners and serves the returned `{ fetch }` handler |
| 233 | * on `PORT`. Credentials use the full chain so EKS Pod Identity's |
| 234 | * container-credentials endpoint resolves inside the pod. |
| 235 | */ |
| 236 | export const makeEksServerBootstrap = |
| 237 | (handler: string) => |
| 238 | (importPath: string): string => |
| 239 | ` |
| 240 | import { BunServices } from "@effect/platform-bun"; |
| 241 | import { BunHttpServer } from "alchemy/Http"; |
| 242 | import { Stack } from "alchemy/Stack"; |
| 243 | import { Stage } from "alchemy/Stage"; |
| 244 | import { makeEntrypointLayer, reifyBoundConfigProvider } from "alchemy/Runtime"; |
| 245 | import { provideProcessTelemetry } from "alchemy/Telemetry"; |
| 246 | import * as Context from "effect/Context"; |
| 247 | import * as Config from "effect/Config"; |
| 248 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 249 | import * as Credentials from "@distilled.cloud/aws/Credentials"; |
| 250 | import * as Effect from "effect/Effect"; |
| 251 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 252 | import * as Layer from "effect/Layer"; |
| 253 | import * as Logger from "effect/Logger"; |
| 254 | import * as Region from "@distilled.cloud/aws/Region"; |
| 255 | |
| 256 | import { ${handler} as entrypoint } from ${JSON.stringify(importPath)}; |
| 257 | |
| 258 | // Normalize the entrypoint export: an inline-effect class default export is |
| 259 | // an Effect resolving the platform instance, while the tagged form |
| 260 | // (X.make(props, impl)) exports a Layer providing the Self tag. Both fold |
| 261 | // into a Layer via makeEntrypointLayer (same pattern as the ECS/Lambda/ |
| 262 | // Cloudflare Container bridges). |
| 263 | const tag = Context.Service("${Self.key}"); |
| 264 | const layer = makeEntrypointLayer(tag, entrypoint); |
| 265 | |
| 266 | const platform = Layer.mergeAll( |
| 267 | BunServices.layer, |
| 268 | FetchHttpClient.layer, |
| 269 | Logger.layer([Logger.consolePretty()]), |
| 270 | ); |
| 271 | |
| 272 | // Resolve the bundled program (the runners registered via host.run / serve) |
| 273 | // and run it with a Bun HTTP server bound to PORT, so a returned { fetch } |
| 274 | // handler is served and host.run loops stay alive. Credentials use the full |
| 275 | // chain so EKS Pod Identity's container-credentials endpoint |
| 276 | // (AWS_CONTAINER_CREDENTIALS_FULL_URI + token file) resolves inside the pod. |
| 277 | const program = tag.pipe( |
| 278 | // Process-lifetime telemetry: built once into the root scope; exporters |
| 279 | // batch on their intervals and flush when the scope closes on graceful |
| 280 | // shutdown. |
| 281 | Effect.flatMap((host) => |
| 282 | host.RuntimeContext.exports.pipe( |
| 283 | Effect.flatMap((exports) => exports.program), |
| 284 | provideProcessTelemetry(host.RuntimeContext), |
| 285 | ), |
| 286 | ), |
| 287 | Effect.provide( |
| 288 | layer.pipe(Layer.provideMerge(Layer.mergeAll( |
| 289 | Layer.effect( |
| 290 | Stack, |
| 291 | Effect.all([ |
| 292 | Config.String("ALCHEMY_STACK_NAME"), |
| 293 | Config.String("ALCHEMY_STAGE") |
| 294 | ]).pipe( |
| 295 | Effect.map(([name, stage]) => ({ |
| 296 | name, |
| 297 | stage, |
| 298 | bindings: {}, |
| 299 | resources: {} |
| 300 | })) |
| 301 | ) |
| 302 | ), |
| 303 | // Module-scope declarations shared with the Stack may read the stage. |
| 304 | Layer.effect(Stage, Config.String("ALCHEMY_STAGE")), |
| 305 | )), |
| 306 | Layer.provideMerge(Credentials.fromChain()), |
| 307 | Layer.provideMerge(Region.fromEnv()), |
| 308 | Layer.provideMerge(BunHttpServer()), |
| 309 | Layer.provideMerge(platform), |
| 310 | Layer.provideMerge( |
| 311 | Layer.succeed( |
| 312 | ConfigProvider.ConfigProvider, |
| 313 | reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env) |
| 314 | ) |
| 315 | ), |
| 316 | ) |
| 317 | ), |
| 318 | Effect.scoped |
| 319 | ); |
| 320 | |
| 321 | console.log(\`EKS Deployment bootstrap starting on port \${process.env.PORT ?? 3000}...\`); |
| 322 | await Effect.runPromise(program).catch((err) => { |
| 323 | console.error("EKS Deployment bootstrap failed:", err); |
| 324 | process.exit(1); |
| 325 | }); |
| 326 | `; |
| 327 | |
| 328 | /** |
| 329 | * Generated container entry for an Effect-native EKS Job: resolves the |
| 330 | * program's `run` effect, executes it to completion, and exits. No HTTP |
| 331 | * server is started. Credentials use the full chain so EKS Pod Identity's |
| 332 | * container-credentials endpoint resolves inside the pod. |
| 333 | */ |
| 334 | export const makeEksJobBootstrap = |
| 335 | (handler: string) => |
| 336 | (importPath: string): string => |
| 337 | ` |
| 338 | import { BunServices } from "@effect/platform-bun"; |
| 339 | import { Stack } from "alchemy/Stack"; |
| 340 | import { Stage } from "alchemy/Stage"; |
| 341 | import { makeEntrypointLayer, reifyBoundConfigProvider } from "alchemy/Runtime"; |
| 342 | import { provideProcessTelemetry } from "alchemy/Telemetry"; |
| 343 | import * as Context from "effect/Context"; |
| 344 | import * as Config from "effect/Config"; |
| 345 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 346 | import * as Credentials from "@distilled.cloud/aws/Credentials"; |
| 347 | import * as Effect from "effect/Effect"; |
| 348 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 349 | import * as Layer from "effect/Layer"; |
| 350 | import * as Logger from "effect/Logger"; |
| 351 | import * as Region from "@distilled.cloud/aws/Region"; |
| 352 | |
| 353 | import { ${handler} as entrypoint } from ${JSON.stringify(importPath)}; |
| 354 | |
| 355 | // Normalize the entrypoint export (see the server bootstrap). |
| 356 | const tag = Context.Service("${Self.key}"); |
| 357 | const layer = makeEntrypointLayer(tag, entrypoint); |
| 358 | |
| 359 | const platform = Layer.mergeAll( |
| 360 | BunServices.layer, |
| 361 | FetchHttpClient.layer, |
| 362 | Logger.layer([Logger.consolePretty()]), |
| 363 | ); |
| 364 | |
| 365 | // Resolve the bundled program's registered one-shot runners (the shape's |
| 366 | // \`run\` effect and any host.run work) and execute them to completion. |
| 367 | const program = tag.pipe( |
| 368 | // Process-lifetime telemetry: built once into the root scope; exporters |
| 369 | // batch on their intervals and flush when the scope closes as the |
| 370 | // one-shot program completes. |
| 371 | Effect.flatMap((host) => |
| 372 | host.RuntimeContext.exports.pipe( |
| 373 | Effect.flatMap((exports) => exports.program), |
| 374 | provideProcessTelemetry(host.RuntimeContext), |
| 375 | ), |
| 376 | ), |
| 377 | Effect.provide( |
| 378 | layer.pipe(Layer.provideMerge(Layer.mergeAll( |
| 379 | Layer.effect( |
| 380 | Stack, |
| 381 | Effect.all([ |
| 382 | Config.String("ALCHEMY_STACK_NAME"), |
| 383 | Config.String("ALCHEMY_STAGE") |
| 384 | ]).pipe( |
| 385 | Effect.map(([name, stage]) => ({ |
| 386 | name, |
| 387 | stage, |
| 388 | bindings: {}, |
| 389 | resources: {} |
| 390 | })) |
| 391 | ) |
| 392 | ), |
| 393 | // Module-scope declarations shared with the Stack may read the stage. |
| 394 | Layer.effect(Stage, Config.String("ALCHEMY_STAGE")), |
| 395 | )), |
| 396 | Layer.provideMerge(Credentials.fromChain()), |
| 397 | Layer.provideMerge(Region.fromEnv()), |
| 398 | Layer.provideMerge(platform), |
| 399 | Layer.provideMerge( |
| 400 | Layer.succeed( |
| 401 | ConfigProvider.ConfigProvider, |
| 402 | reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env) |
| 403 | ) |
| 404 | ), |
| 405 | ) |
| 406 | ), |
| 407 | Effect.scoped |
| 408 | ); |
| 409 | |
| 410 | console.log("EKS Job bootstrap starting..."); |
| 411 | await Effect.runPromise(program).catch((err) => { |
| 412 | console.error("EKS Job bootstrap failed:", err); |
| 413 | process.exit(1); |
| 414 | }); |
| 415 | // Run-to-completion semantics: exit 0 explicitly so lingering handles |
| 416 | // (sockets, timers) never keep the pod alive after the work is done. |
| 417 | process.exit(0); |
| 418 | `; |
| 419 | |
| 420 | /** |
| 421 | * The `aws-eks` cluster adapter layer. Provided (merged) by |
| 422 | * `AWS.providers()` so the `Kubernetes.*` workload providers can resolve |
| 423 | * it from the stack context. |
| 424 | */ |
| 425 | export const EksKubernetesAdapter = () => |
| 426 | Layer.effect( |
| 427 | ClusterAdapter("aws-eks"), |
| 428 | Effect.gen(function* () { |
| 429 | const imageSource = yield* makeImageSource; |
| 430 | const context = yield* Effect.context<EksAdapterDeps>(); |
| 431 | |
| 432 | // Discharge the layer-captured AWS services; per-resource engine |
| 433 | // services (InstanceId/Stack/Stage) stay ambient — they are provided |
| 434 | // by the invoking lifecycle operation, never captured here. |
| 435 | const withAws = |
| 436 | (region: string | undefined) => |
| 437 | <A, E, R>(self: Effect.Effect<A, E, R>) => |
| 438 | Effect.provideContext(withRegion(region)(self), context); |
| 439 | |
| 440 | /** Describe the cluster; NotFound / DELETING → ClusterNotFoundError. */ |
| 441 | const describeLiveCluster = Effect.fn(function* (auth: { |
| 442 | clusterName: string; |
| 443 | region?: string | undefined; |
| 444 | }) { |
| 445 | const described = yield* eks |
| 446 | .describeCluster({ name: auth.clusterName }) |
| 447 | .pipe( |
| 448 | Effect.catchTag("ResourceNotFoundException", () => |
| 449 | Effect.succeed(undefined), |
| 450 | ), |
| 451 | withAws(auth.region), |
| 452 | ); |
| 453 | const cluster = described?.cluster; |
| 454 | if (!cluster || cluster.status === "DELETING") { |
| 455 | return yield* Effect.fail( |
| 456 | new ClusterNotFoundError({ |
| 457 | message: `EKS cluster '${auth.clusterName}' no longer exists`, |
| 458 | }), |
| 459 | ); |
| 460 | } |
| 461 | return cluster; |
| 462 | }); |
| 463 | |
| 464 | const connect = Effect.fn(function* (connection: Connection) { |
| 465 | const auth = yield* narrowEksAuth(connection); |
| 466 | let endpoint = connection.endpoint; |
| 467 | let certificateAuthorityData = connection.certificateAuthorityData; |
| 468 | if (!endpoint || !certificateAuthorityData) { |
| 469 | // Re-describe for a fresh endpoint + CA (persisted attributes |
| 470 | // may predate them, and EKS can rotate the endpoint DNS on |
| 471 | // recreate-with-same-name). |
| 472 | const cluster = yield* describeLiveCluster(auth); |
| 473 | endpoint = cluster.endpoint; |
| 474 | certificateAuthorityData = cluster.certificateAuthority?.data; |
| 475 | if (!endpoint || !certificateAuthorityData) { |
| 476 | return yield* Effect.fail( |
| 477 | new Error( |
| 478 | `EKS cluster '${auth.clusterName}' has no endpoint or ` + |
| 479 | "certificate authority data yet (still creating?)", |
| 480 | ), |
| 481 | ); |
| 482 | } |
| 483 | } |
| 484 | return yield* makeEksTransport({ |
| 485 | clusterName: auth.clusterName, |
| 486 | region: auth.region, |
| 487 | endpoint, |
| 488 | certificateAuthorityData, |
| 489 | }).pipe(withAws(auth.region)); |
| 490 | }); |
| 491 | |
| 492 | const identityReconcile = Effect.fn(function* ( |
| 493 | options: WorkloadIdentityReconcileOptions, |
| 494 | ) { |
| 495 | const auth = yield* narrowEksAuth(options.connection); |
| 496 | return yield* Effect.gen(function* () { |
| 497 | const state = options.state; |
| 498 | const roleName = |
| 499 | typeof state?.roleName === "string" |
| 500 | ? state.roleName |
| 501 | : yield* createRoleName(options.id); |
| 502 | const policyName = yield* createPolicyName(options.id); |
| 503 | |
| 504 | // Ensure IAM role + inline policy from bindings. |
| 505 | const roleArn = |
| 506 | typeof state?.roleArn === "string" |
| 507 | ? state.roleArn |
| 508 | : yield* ensurePodRole({ |
| 509 | id: options.id, |
| 510 | roleName, |
| 511 | managedPolicyArns: options.options?.managedPolicyArns, |
| 512 | }); |
| 513 | yield* attachPolicyStatements({ |
| 514 | roleName, |
| 515 | policyName, |
| 516 | bindings: options.bindings, |
| 517 | }); |
| 518 | |
| 519 | // Ensure the pod identity association wires the role to the SA. |
| 520 | const { associationArn, associationId } = yield* ensureAssociation({ |
| 521 | id: options.id, |
| 522 | clusterName: auth.clusterName, |
| 523 | namespace: options.namespace, |
| 524 | serviceAccount: options.serviceAccount, |
| 525 | roleArn, |
| 526 | }); |
| 527 | |
| 528 | // Unlike ECS/Fargate (whose agent injects `AWS_REGION`), EKS pods |
| 529 | // get no region env var — inject it so the bootstrap's |
| 530 | // `Region.fromEnv()` resolves inside the pod. |
| 531 | const { region } = yield* AWSEnvironment.current; |
| 532 | |
| 533 | return { |
| 534 | env: { AWS_REGION: auth.region ?? region }, |
| 535 | state: { |
| 536 | kind: "aws-pod-identity" as const, |
| 537 | roleArn, |
| 538 | roleName, |
| 539 | associationArn, |
| 540 | associationId, |
| 541 | }, |
| 542 | }; |
| 543 | }).pipe(withAws(auth.region)); |
| 544 | }); |
| 545 | |
| 546 | const identityDelete = Effect.fn(function* (options: { |
| 547 | connection: Connection | undefined; |
| 548 | state: Record<string, unknown> | undefined; |
| 549 | }) { |
| 550 | const state = options.state; |
| 551 | const auth = |
| 552 | options.connection !== undefined |
| 553 | ? yield* narrowEksAuth(options.connection) |
| 554 | : undefined; |
| 555 | yield* Effect.gen(function* () { |
| 556 | // The association is cluster-scoped: skip it when the cluster is |
| 557 | // gone or DELETING (it dies with the cluster; EKS also rejects |
| 558 | // the delete with `InvalidRequestException: Cluster is in |
| 559 | // invalid state` mid-teardown). |
| 560 | if (auth !== undefined && typeof state?.associationId === "string") { |
| 561 | const cluster = yield* describeLiveCluster(auth).pipe( |
| 562 | Effect.catchTag("Kubernetes.ClusterNotFoundError", () => |
| 563 | Effect.succeed(undefined), |
| 564 | ), |
| 565 | ); |
| 566 | if (cluster) { |
| 567 | yield* deleteAssociation({ |
| 568 | clusterName: auth.clusterName, |
| 569 | associationId: state.associationId, |
| 570 | }).pipe( |
| 571 | Effect.catchTag("InvalidRequestException", () => Effect.void), |
| 572 | ); |
| 573 | } |
| 574 | } |
| 575 | if (typeof state?.roleName === "string") { |
| 576 | yield* deletePodRole(state.roleName); |
| 577 | } |
| 578 | }).pipe(withAws(auth?.region)); |
| 579 | }); |
| 580 | |
| 581 | const registryResolve = Effect.fn(function* ( |
| 582 | options: ImageRegistryResolveOptions, |
| 583 | ) { |
| 584 | const state = options.state; |
| 585 | const repositoryName = |
| 586 | typeof state?.repositoryName === "string" |
| 587 | ? state.repositoryName |
| 588 | : // Physical-name generation reads the resource's ambient |
| 589 | // InstanceId/Stack/Stage — do not shadow them with the |
| 590 | // layer-captured context. |
| 591 | yield* createRepositoryName(options.id); |
| 592 | const repositoryUri = |
| 593 | typeof state?.repositoryUri === "string" && |
| 594 | state.repositoryName === repositoryName |
| 595 | ? state.repositoryUri |
| 596 | : undefined; |
| 597 | const resolved = yield* imageSource |
| 598 | .resolve({ |
| 599 | id: options.id, |
| 600 | source: options.source as ImageSourceLike, |
| 601 | repositoryName, |
| 602 | repositoryUri, |
| 603 | tags: options.tags, |
| 604 | platform: options.platform, |
| 605 | port: options.port, |
| 606 | isExternal: options.isExternal, |
| 607 | bootstrap: options.bootstrap, |
| 608 | session: options.session, |
| 609 | }) |
| 610 | .pipe(Effect.provideContext(context)); |
| 611 | return { |
| 612 | imageUri: resolved.imageUri, |
| 613 | codeHash: resolved.codeHash, |
| 614 | state: { |
| 615 | kind: "aws-ecr" as const, |
| 616 | repositoryName: resolved.repositoryName, |
| 617 | repositoryUri: resolved.repositoryUri, |
| 618 | }, |
| 619 | }; |
| 620 | }); |
| 621 | |
| 622 | const registryHash = Effect.fn(function* (options: { |
| 623 | source: ImageSourceLike; |
| 624 | platform: string; |
| 625 | port?: number | undefined; |
| 626 | isExternal?: boolean | undefined; |
| 627 | bootstrap: (importPath: string) => string; |
| 628 | }) { |
| 629 | return yield* imageSource |
| 630 | .hash({ |
| 631 | source: options.source, |
| 632 | platform: options.platform, |
| 633 | port: options.port, |
| 634 | isExternal: options.isExternal, |
| 635 | bootstrap: options.bootstrap, |
| 636 | }) |
| 637 | .pipe(Effect.provideContext(context)); |
| 638 | }); |
| 639 | |
| 640 | const registryDelete = Effect.fn(function* (options: { |
| 641 | state: Record<string, unknown> | undefined; |
| 642 | }) { |
| 643 | if (typeof options.state?.repositoryName !== "string") return; |
| 644 | yield* ecr |
| 645 | .deleteRepository({ |
| 646 | repositoryName: options.state.repositoryName, |
| 647 | force: true, |
| 648 | }) |
| 649 | .pipe( |
| 650 | Effect.catchTag("RepositoryNotFoundException", () => Effect.void), |
| 651 | Effect.provideContext(context), |
| 652 | ); |
| 653 | }); |
| 654 | |
| 655 | const loadBalancerDefaults = Effect.fn(function* (options: { |
| 656 | connection: Connection; |
| 657 | }) { |
| 658 | const auth = yield* narrowEksAuth(options.connection); |
| 659 | // EKS Auto Mode's built-in load balancer controller only |
| 660 | // reconciles `LoadBalancer` Services whose `spec.loadBalancerClass` |
| 661 | // is `eks.amazonaws.com/nlb` (there is no in-tree cloud provider on |
| 662 | // Auto Mode), and it defaults new NLBs to the *internal* scheme. |
| 663 | // Detect the Auto Mode LB capability from the live cluster and |
| 664 | // default to an internet-facing NLB so the returned `url` is |
| 665 | // actually reachable. |
| 666 | const cluster = yield* describeLiveCluster(auth).pipe( |
| 667 | Effect.catchTag("Kubernetes.ClusterNotFoundError", () => |
| 668 | Effect.succeed(undefined), |
| 669 | ), |
| 670 | ); |
| 671 | return { |
| 672 | loadBalancerClass: cluster?.kubernetesNetworkConfig |
| 673 | ?.elasticLoadBalancing?.enabled |
| 674 | ? "eks.amazonaws.com/nlb" |
| 675 | : undefined, |
| 676 | annotations: { |
| 677 | "service.beta.kubernetes.io/aws-load-balancer-scheme": |
| 678 | "internet-facing", |
| 679 | }, |
| 680 | }; |
| 681 | }); |
| 682 | |
| 683 | return { |
| 684 | kind: "Kubernetes.ClusterAdapter" as const, |
| 685 | connect, |
| 686 | identity: { |
| 687 | reconcile: identityReconcile, |
| 688 | delete: identityDelete, |
| 689 | }, |
| 690 | registry: { |
| 691 | resolve: registryResolve, |
| 692 | hash: registryHash, |
| 693 | delete: registryDelete, |
| 694 | }, |
| 695 | bootstrap: { |
| 696 | server: makeEksServerBootstrap, |
| 697 | job: makeEksJobBootstrap, |
| 698 | }, |
| 699 | loadBalancerDefaults, |
| 700 | } satisfies ClusterAdapterService; |
| 701 | }), |
| 702 | ); |