| 1 | /** |
| 2 | * The extension seam between the cluster-agnostic `Kubernetes.*` workloads |
| 3 | * and the platform a cluster runs on. |
| 4 | * |
| 5 | * A {@link ClusterAdapterService} is registered under a keyed Context tag — |
| 6 | * `Kubernetes.ClusterAdapter/<auth kind>` — and resolved dynamically from |
| 7 | * the ambient provider context by {@link findClusterAdapter}, mirroring how |
| 8 | * resource providers are resolved by type. `Kubernetes.providers()` ships |
| 9 | * the built-in adapters (`kubeconfig`, `token`, `client-cert`, `exec`); |
| 10 | * cloud provider layers contribute theirs (`AWS.providers()` registers |
| 11 | * `aws-eks`). |
| 12 | * |
| 13 | * An adapter owns everything platform-specific: |
| 14 | * |
| 15 | * - **connect** (required) — resolve the API server endpoint/CA and mint |
| 16 | * per-request auth headers. |
| 17 | * - **identity** (optional) — provision workload identity for a namespace + |
| 18 | * service account and translate host bindings into cloud credentials |
| 19 | * (EKS Pod Identity, GKE Workload Identity Federation). |
| 20 | * - **registry** (optional) — build/mirror container images into a managed |
| 21 | * registry the cluster can pull from (ECR on EKS, Artifact Registry on GKE). |
| 22 | * - **bootstrap** (optional) — platform-specific generated container |
| 23 | * entries for Effect-native workloads (e.g. wiring the AWS credential |
| 24 | * chain for Pod Identity). |
| 25 | * - **loadBalancerDefaults** (optional) — platform defaults for |
| 26 | * `LoadBalancer` Services (EKS Auto Mode's `loadBalancerClass` + |
| 27 | * internet-facing scheme). |
| 28 | */ |
| 29 | import * as Context from "effect/Context"; |
| 30 | import * as Data from "effect/Data"; |
| 31 | import * as Effect from "effect/Effect"; |
| 32 | import * as Option from "effect/Option"; |
| 33 | import type * as Bundle from "../Bundle/Bundle.ts"; |
| 34 | import type { InstanceId } from "../InstanceId.ts"; |
| 35 | import type { ResourceBinding } from "../Resource.ts"; |
| 36 | import type { InlineDockerfile } from "../Docker/Dockerfile.ts"; |
| 37 | import type { Stack } from "../Stack.ts"; |
| 38 | import type { Stage } from "../Stage.ts"; |
| 39 | import type { Connection } from "./Connection.ts"; |
| 40 | |
| 41 | /** |
| 42 | * Per-resource engine services ambient inside every provider lifecycle |
| 43 | * operation. Adapter methods run inside the workload providers' lifecycle |
| 44 | * ops, so they may require these (physical-name generation needs the |
| 45 | * resource's InstanceId + Stack + Stage) — everything else an adapter |
| 46 | * needs is captured at its layer build. |
| 47 | */ |
| 48 | export type AdapterLifecycleServices = InstanceId | Stack | Stage; |
| 49 | |
| 50 | /** |
| 51 | * The target cluster no longer exists (definitively — e.g. the managed |
| 52 | * control plane is deleted or deleting). Distinct from transient |
| 53 | * unreachability: `read`/`delete` treat this as "everything in-cluster is |
| 54 | * already gone". |
| 55 | */ |
| 56 | export class ClusterNotFoundError extends Data.TaggedError( |
| 57 | "Kubernetes.ClusterNotFoundError", |
| 58 | )<{ |
| 59 | message: string; |
| 60 | }> {} |
| 61 | |
| 62 | /** |
| 63 | * A resolved transport to a cluster's API server. `headers` is an Effect |
| 64 | * so short-lived tokens (EKS SigV4 presigns, exec plugin credentials) are |
| 65 | * minted per request. |
| 66 | */ |
| 67 | export interface ClusterTransport { |
| 68 | /** The API server endpoint URL. */ |
| 69 | endpoint: string; |
| 70 | /** Base64-encoded PEM certificate authority bundle. */ |
| 71 | certificateAuthorityData?: string; |
| 72 | /** Skip TLS verification (self-signed local clusters). */ |
| 73 | insecureSkipTlsVerify?: boolean; |
| 74 | /** Mint the auth headers for one request. */ |
| 75 | headers: Effect.Effect<Record<string, string>, Error>; |
| 76 | /** PEM client certificate credentials for mutual TLS. */ |
| 77 | clientCert?: { certificate: string; key: string }; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Workload identity state persisted on a workload's attributes, keyed by |
| 82 | * adapter kind. Cloud providers extend it via module augmentation — AWS |
| 83 | * registers: |
| 84 | * |
| 85 | * ```ts |
| 86 | * declare module "../../Kubernetes/ClusterAdapter.ts" { |
| 87 | * interface IdentityStateRegistry { |
| 88 | * "aws-pod-identity": { |
| 89 | * roleArn: string; |
| 90 | * roleName: string; |
| 91 | * associationArn: string; |
| 92 | * associationId: string; |
| 93 | * }; |
| 94 | * } |
| 95 | * } |
| 96 | * ``` |
| 97 | */ |
| 98 | export interface IdentityStateRegistry {} |
| 99 | |
| 100 | /** The discriminated identity-state union across all registered adapters. */ |
| 101 | export type IdentityState = { |
| 102 | [K in keyof IdentityStateRegistry]: { |
| 103 | readonly kind: K; |
| 104 | } & IdentityStateRegistry[K]; |
| 105 | }[keyof IdentityStateRegistry]; |
| 106 | |
| 107 | /** |
| 108 | * Image-registry state persisted on a workload's attributes, keyed by |
| 109 | * adapter kind (AWS registers `"aws-ecr"` with the repository name/URI). |
| 110 | * The built-in `"registry"` entry records images pushed to a connection's |
| 111 | * {@link Connection.registry}. |
| 112 | */ |
| 113 | export interface RegistryStateRegistry { |
| 114 | registry: { |
| 115 | /** The registry `server` the image was pushed to. */ |
| 116 | server: string; |
| 117 | /** The repository the image was pushed to (`<server>/<name>`). */ |
| 118 | repository: string; |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | /** The discriminated registry-state union across all registered adapters. */ |
| 123 | export type RegistryState = { |
| 124 | [K in keyof RegistryStateRegistry]: { |
| 125 | readonly kind: K; |
| 126 | } & RegistryStateRegistry[K]; |
| 127 | }[keyof RegistryStateRegistry]; |
| 128 | |
| 129 | /** |
| 130 | * Cloud-specific workload identity options, extended via module |
| 131 | * augmentation (AWS adds `managedPolicyArns`, GCP `gcpServiceAccount`). |
| 132 | */ |
| 133 | export interface WorkloadIdentityOptions {} |
| 134 | |
| 135 | /** |
| 136 | * The binding contract of `Kubernetes.Deployment` / `Kubernetes.Job` |
| 137 | * hosts. The core contract is environment variables; cloud providers |
| 138 | * augment it with their credential-grant channels (AWS adds |
| 139 | * `policyStatements`, GCP adds `iam`), which the matching {@link ClusterAdapterService}'s |
| 140 | * identity adapter materializes at deploy time. |
| 141 | */ |
| 142 | export interface WorkloadBindingContract { |
| 143 | env?: Record<string, any>; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Ambient runtime services the workload platforms assume inside the |
| 148 | * deployed container, keyed by contributor and extended via module |
| 149 | * augmentation — AWS registers its credential-chain services so inline |
| 150 | * impls can use AWS bindings without providing them: |
| 151 | * |
| 152 | * ```ts |
| 153 | * declare module "../../Kubernetes/ClusterAdapter.ts" { |
| 154 | * interface WorkloadServicesRegistry { |
| 155 | * aws: Credentials | Region | AWSEnvironment; |
| 156 | * } |
| 157 | * } |
| 158 | * ``` |
| 159 | */ |
| 160 | export interface WorkloadServicesRegistry {} |
| 161 | |
| 162 | /** The union of all registered ambient workload services. */ |
| 163 | export type WorkloadServices = |
| 164 | WorkloadServicesRegistry[keyof WorkloadServicesRegistry]; |
| 165 | |
| 166 | /** |
| 167 | * The image-source shape shared by every workload: exactly one of `main` |
| 168 | * (bundle an inline Effect program), `context`/`dockerfile` (build the |
| 169 | * user's Dockerfile), or `image` (a pre-built registry reference). |
| 170 | * Structurally mirrors the AWS container platforms' source props. |
| 171 | */ |
| 172 | export interface WorkloadImageSource { |
| 173 | main?: string; |
| 174 | handler?: string; |
| 175 | /** |
| 176 | * Bundler configuration for `main`. Unused code is tree-shaken. |
| 177 | * `effect`, alchemy, and `@distilled.cloud` are marked pure so unused |
| 178 | * parts prune more aggressively. List extra packages with |
| 179 | * `pure.packages`, or disable with `pure: false`. |
| 180 | */ |
| 181 | build?: Bundle.BundleConfig; |
| 182 | context?: string; |
| 183 | dockerfile?: string | InlineDockerfile; |
| 184 | image?: string; |
| 185 | } |
| 186 | |
| 187 | export interface WorkloadIdentityReconcileOptions { |
| 188 | /** Logical resource id. */ |
| 189 | id: string; |
| 190 | /** The resolved cluster connection. */ |
| 191 | connection: Connection; |
| 192 | /** The namespace the workload runs in. */ |
| 193 | namespace: string; |
| 194 | /** The service account name the workload's pods run as. */ |
| 195 | serviceAccount: string; |
| 196 | /** Host bindings attached to the workload. */ |
| 197 | bindings: ResourceBinding<WorkloadBindingContract>[]; |
| 198 | /** Cloud-specific identity options from the workload's `identity` prop. */ |
| 199 | options: WorkloadIdentityOptions | undefined; |
| 200 | /** |
| 201 | * Previously persisted identity state, if any — untyped hints because |
| 202 | * legacy pre-rename rows carry the old flat attribute shape; adapters |
| 203 | * narrow structurally and re-observe cloud state as the authority. |
| 204 | */ |
| 205 | state: Record<string, unknown> | undefined; |
| 206 | /** Internal + user tags for identity-owned cloud resources. */ |
| 207 | tags: Record<string, string>; |
| 208 | } |
| 209 | |
| 210 | export interface WorkloadIdentityResult { |
| 211 | /** Extra container environment collected from bindings. */ |
| 212 | env: Record<string, any>; |
| 213 | /** Annotations to stamp on the workload's ServiceAccount object. */ |
| 214 | serviceAccountAnnotations?: Record<string, string>; |
| 215 | /** Serializable state persisted on the workload's attributes. */ |
| 216 | state: IdentityState | undefined; |
| 217 | } |
| 218 | |
| 219 | export interface WorkloadIdentityDeleteOptions { |
| 220 | /** The persisted cluster connection (may reference a gone cluster). */ |
| 221 | connection: Connection | undefined; |
| 222 | /** |
| 223 | * The persisted identity state. Untyped because legacy pre-rename rows |
| 224 | * carry the old flat attribute shape — adapters narrow structurally. |
| 225 | */ |
| 226 | state: Record<string, unknown> | undefined; |
| 227 | } |
| 228 | |
| 229 | export interface ImageRegistryResolveOptions { |
| 230 | /** Logical resource id — keys generated repository names. */ |
| 231 | id: string; |
| 232 | /** The image-source props bag. */ |
| 233 | source: WorkloadImageSource; |
| 234 | /** Target image platform (`linux/amd64` / `linux/arm64`). */ |
| 235 | platform: string; |
| 236 | /** Port the generated Dockerfile exposes (`main` sources only). */ |
| 237 | port?: number; |
| 238 | /** True when `main` bundles as-is (no Effect bootstrap entry). */ |
| 239 | isExternal?: boolean; |
| 240 | /** The generated-entry bootstrap wrapped around `main`. */ |
| 241 | bootstrap: (importPath: string) => string; |
| 242 | /** Tags for registry-owned cloud resources. */ |
| 243 | tags: Record<string, string>; |
| 244 | /** |
| 245 | * Previously persisted registry state, if any — untyped hints because |
| 246 | * legacy pre-rename rows carry the old flat attribute shape; adapters |
| 247 | * narrow structurally. |
| 248 | */ |
| 249 | state: Record<string, unknown> | undefined; |
| 250 | /** Plan-status session for build/push progress notes. */ |
| 251 | session: { note: (message: string) => Effect.Effect<void> }; |
| 252 | } |
| 253 | |
| 254 | export interface ImageRegistryResult { |
| 255 | /** Full image reference the pod spec should run. */ |
| 256 | imageUri: string; |
| 257 | /** Content hash identifying the image. */ |
| 258 | codeHash: string; |
| 259 | /** Serializable state persisted on the workload's attributes. */ |
| 260 | state: RegistryState | undefined; |
| 261 | } |
| 262 | |
| 263 | export interface ImageRegistryHashOptions { |
| 264 | source: WorkloadImageSource; |
| 265 | platform: string; |
| 266 | port?: number; |
| 267 | isExternal?: boolean; |
| 268 | bootstrap: (importPath: string) => string; |
| 269 | } |
| 270 | |
| 271 | export interface ImageRegistryDeleteOptions { |
| 272 | /** |
| 273 | * The persisted registry state. Untyped because legacy pre-rename rows |
| 274 | * carry the old flat attribute shape — adapters narrow structurally. |
| 275 | */ |
| 276 | state: Record<string, unknown> | undefined; |
| 277 | } |
| 278 | |
| 279 | export interface ClusterAdapterService { |
| 280 | readonly kind: "Kubernetes.ClusterAdapter"; |
| 281 | /** |
| 282 | * Resolve the transport for a connection: discover the endpoint/CA when |
| 283 | * the connection doesn't carry them, and return a per-request auth |
| 284 | * header mint. Fails with {@link ClusterNotFoundError} when the cluster |
| 285 | * definitively no longer exists. |
| 286 | */ |
| 287 | readonly connect: ( |
| 288 | connection: Connection, |
| 289 | ) => Effect.Effect<ClusterTransport, ClusterNotFoundError | Error>; |
| 290 | /** Workload identity provisioning (Pod Identity on EKS, Workload Identity Federation on GKE). */ |
| 291 | readonly identity?: { |
| 292 | readonly reconcile: ( |
| 293 | options: WorkloadIdentityReconcileOptions, |
| 294 | ) => Effect.Effect<WorkloadIdentityResult, any, AdapterLifecycleServices>; |
| 295 | readonly delete: ( |
| 296 | options: WorkloadIdentityDeleteOptions, |
| 297 | ) => Effect.Effect<void, any, AdapterLifecycleServices>; |
| 298 | }; |
| 299 | /** Managed container-image registry (ECR on EKS, Artifact Registry on GKE). */ |
| 300 | readonly registry?: { |
| 301 | readonly resolve: ( |
| 302 | options: ImageRegistryResolveOptions, |
| 303 | ) => Effect.Effect<ImageRegistryResult, any, AdapterLifecycleServices>; |
| 304 | /** Plan-time content hash used by `diff` to surface source drift. */ |
| 305 | readonly hash: ( |
| 306 | options: ImageRegistryHashOptions, |
| 307 | ) => Effect.Effect<string | undefined, any, AdapterLifecycleServices>; |
| 308 | readonly delete: ( |
| 309 | options: ImageRegistryDeleteOptions, |
| 310 | ) => Effect.Effect<void, any, AdapterLifecycleServices>; |
| 311 | }; |
| 312 | /** |
| 313 | * Platform-specific generated container entries for Effect-native |
| 314 | * workloads. Defaults to the platform-neutral bootstraps when omitted. |
| 315 | */ |
| 316 | readonly bootstrap?: { |
| 317 | readonly server?: (handler: string) => (importPath: string) => string; |
| 318 | readonly job?: (handler: string) => (importPath: string) => string; |
| 319 | }; |
| 320 | /** |
| 321 | * Platform defaults applied to a `LoadBalancer` Service (EKS Auto Mode |
| 322 | * sets `loadBalancerClass: eks.amazonaws.com/nlb` and defaults the |
| 323 | * scheme to internet-facing). User `serviceAnnotations` always win. |
| 324 | */ |
| 325 | readonly loadBalancerDefaults?: (options: { |
| 326 | connection: Connection; |
| 327 | }) => Effect.Effect< |
| 328 | { |
| 329 | loadBalancerClass?: string | undefined; |
| 330 | annotations?: Record<string, string>; |
| 331 | }, |
| 332 | any, |
| 333 | AdapterLifecycleServices |
| 334 | >; |
| 335 | } |
| 336 | |
| 337 | const adapterKey = (authKind: string) => |
| 338 | `Kubernetes.ClusterAdapter/${authKind}`; |
| 339 | |
| 340 | /** |
| 341 | * The keyed Context tag for an adapter. Same auth kind → same tag, so a |
| 342 | * layer built with `ClusterAdapter("aws-eks")` is found by any dynamic |
| 343 | * lookup for that kind. |
| 344 | */ |
| 345 | export const ClusterAdapter = ( |
| 346 | authKind: string, |
| 347 | ): Context.Service<ClusterAdapterService, ClusterAdapterService> => |
| 348 | Context.Service<ClusterAdapterService, ClusterAdapterService>()( |
| 349 | adapterKey(authKind), |
| 350 | ) as any; |
| 351 | |
| 352 | /** |
| 353 | * Resolve the {@link ClusterAdapterService} for a connection's auth kind |
| 354 | * from the ambient context (the stack's composed provider layers). Dies |
| 355 | * with setup guidance when no adapter is registered — that means the |
| 356 | * provider layer contributing it (e.g. `AWS.providers()` for `aws-eks`) |
| 357 | * is missing from the stack. |
| 358 | */ |
| 359 | export const findClusterAdapter = ( |
| 360 | authKind: string, |
| 361 | ): Effect.Effect<ClusterAdapterService> => |
| 362 | Effect.serviceOption(ClusterAdapter(authKind)).pipe( |
| 363 | Effect.flatMap( |
| 364 | Option.match({ |
| 365 | onSome: (adapter) => Effect.succeed(adapter), |
| 366 | onNone: () => |
| 367 | Effect.die( |
| 368 | new Error( |
| 369 | `No Kubernetes cluster adapter is registered for auth kind ` + |
| 370 | `'${authKind}'. Add the provider layer that contributes it ` + |
| 371 | `to the stack's providers — e.g. 'aws-eks' ships with ` + |
| 372 | "`AWS.providers()`; the built-in kinds (kubeconfig, token, " + |
| 373 | "client-cert, exec) ship with `Kubernetes.providers()`. " + |
| 374 | "Compose multiple provider layers with " + |
| 375 | "`Layer.mergeAll(AWS.providers(), Kubernetes.providers())`.", |
| 376 | ), |
| 377 | ), |
| 378 | }), |
| 379 | ), |
| 380 | ); |