| 1 | --- |
| 2 | title: Cluster adapters |
| 3 | description: The extension point between Alchemy's cluster-agnostic Kubernetes resources and the platform a cluster runs on. Covers authentication, image registries, workload identity, load balancer defaults, and how to write your own. |
| 4 | --- |
| 5 | |
| 6 | The Kubernetes resources don't know which platform a cluster runs |
| 7 | on. Everything platform-specific lives in a **cluster adapter**, |
| 8 | chosen by the `auth.kind` of the resource's |
| 9 | [connection](/kubernetes/clusters/connecting): |
| 10 | |
| 11 | - `kubeconfig`, `token`, `client-cert`, and `exec` are registered by |
| 12 | `Kubernetes.providers()`. They handle authentication only. |
| 13 | - `aws-eks` is registered by `AWS.providers()`. It handles |
| 14 | authentication, image registry, workload identity, generated |
| 15 | entrypoints, and load balancer defaults. |
| 16 | |
| 17 | When a resource runs, Alchemy looks up the adapter for its |
| 18 | connection's kind among the Stack's provider layers. If none is |
| 19 | registered, the deploy fails with a message naming the missing kind |
| 20 | and the provider layer that contributes it. |
| 21 | |
| 22 | ## What an adapter provides |
| 23 | |
| 24 | An adapter implements `Kubernetes.ClusterAdapterService`. Only |
| 25 | `connect` is required: |
| 26 | |
| 27 | - `connect` resolves the API endpoint and CA, and returns a |
| 28 | per-request header mint so short-lived tokens stay fresh. It fails |
| 29 | with `ClusterNotFoundError` when the cluster definitely no longer |
| 30 | exists. |
| 31 | - `registry` builds or mirrors workload images into a registry the |
| 32 | platform manages. Without it, `main` and `context` sources are |
| 33 | pushed to the connection's |
| 34 | [`registry`](/kubernetes/clusters/registries). |
| 35 | - `identity` provisions cloud credentials for a workload's |
| 36 | ServiceAccount. It turns binding grants, such as AWS |
| 37 | `policyStatements`, into those credentials. |
| 38 | - `bootstrap` replaces the generated container entrypoints for |
| 39 | `main` programs, for example to wire a cloud credential chain. |
| 40 | - `loadBalancerDefaults` sets defaults for `LoadBalancer` Services, |
| 41 | such as `loadBalancerClass` and annotations. Your |
| 42 | `serviceAnnotations` win. |
| 43 | |
| 44 | Missing members degrade gracefully. Without `registry`, only `image` |
| 45 | sources work. Without `identity`, bindings may only carry `env`. |
| 46 | |
| 47 | ## Write an adapter |
| 48 | |
| 49 | A custom adapter is a Layer for `Kubernetes.ClusterAdapter(kind)` |
| 50 | plus a type for its auth descriptor. This one reads a bearer token |
| 51 | from an environment variable on every request, so the token itself |
| 52 | never lands in Alchemy's state: |
| 53 | |
| 54 | ```typescript |
| 55 | // src/env-token-adapter.ts |
| 56 | import * as Kubernetes from "alchemy/Kubernetes"; |
| 57 | import * as Config from "effect/Config"; |
| 58 | import * as Effect from "effect/Effect"; |
| 59 | import * as Layer from "effect/Layer"; |
| 60 | |
| 61 | declare module "alchemy/Kubernetes" { |
| 62 | interface AuthRegistry { |
| 63 | "env-token": { |
| 64 | /** Environment variable holding the bearer token. */ |
| 65 | variable: string; |
| 66 | }; |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | export const EnvTokenAdapter = Layer.succeed( |
| 71 | Kubernetes.ClusterAdapter("env-token"), |
| 72 | { |
| 73 | kind: "Kubernetes.ClusterAdapter", |
| 74 | connect: (connection: Kubernetes.Connection) => |
| 75 | Effect.gen(function* () { |
| 76 | if (connection.auth.kind !== "env-token" || !connection.endpoint) { |
| 77 | return yield* Effect.fail( |
| 78 | new Error("env-token needs an endpoint and an env-token auth"), |
| 79 | ); |
| 80 | } |
| 81 | const variable = connection.auth.variable; |
| 82 | return { |
| 83 | endpoint: connection.endpoint, |
| 84 | certificateAuthorityData: connection.certificateAuthorityData, |
| 85 | headers: Config.String(variable).pipe( |
| 86 | Effect.map((token) => ({ Authorization: `Bearer ${token}` })), |
| 87 | Effect.mapError((cause) => new Error(String(cause))), |
| 88 | ), |
| 89 | }; |
| 90 | }), |
| 91 | }, |
| 92 | ); |
| 93 | ``` |
| 94 | |
| 95 | The `declare module` block adds `"env-token"` to the `auth` union, |
| 96 | so connections using it type-check. |
| 97 | |
| 98 | ## Register it |
| 99 | |
| 100 | Merge the adapter into the Stack's providers, then use its kind in a |
| 101 | connection: |
| 102 | |
| 103 | ```typescript |
| 104 | import { EnvTokenAdapter } from "./src/env-token-adapter.ts"; |
| 105 | |
| 106 | const cluster: Kubernetes.Connection = { |
| 107 | endpoint: "https://k8s.internal:6443", |
| 108 | certificateAuthorityData: caData, |
| 109 | auth: { kind: "env-token", variable: "K8S_TOKEN" }, |
| 110 | }; |
| 111 | |
| 112 | export default Alchemy.Stack( |
| 113 | "MyCluster", |
| 114 | { |
| 115 | providers: Layer.mergeAll(Kubernetes.providers(), EnvTokenAdapter), |
| 116 | state: Alchemy.localState(), |
| 117 | }, |
| 118 | Effect.gen(function* () { |
| 119 | yield* Kubernetes.Manifest("Config", { |
| 120 | cluster, |
| 121 | manifest: { |
| 122 | apiVersion: "v1", |
| 123 | kind: "ConfigMap", |
| 124 | metadata: { name: "app-config", namespace: "default" }, |
| 125 | data: { LOG_LEVEL: "info" }, |
| 126 | }, |
| 127 | }); |
| 128 | }), |
| 129 | ); |
| 130 | ``` |
| 131 | |
| 132 | The connection stored in state is `{ kind: "env-token", variable: |
| 133 | "K8S_TOKEN" }`, so `alchemy destroy` authenticates the same way. It |
| 134 | uses whatever `K8S_TOKEN` holds at that time. |
| 135 | |
| 136 | ## Registries and identity |
| 137 | |
| 138 | A platform adapter such as `aws-eks` goes further: |
| 139 | |
| 140 | - `registry.resolve` receives the workload's image source, target |
| 141 | platform, generated entrypoint, and tags. It returns the image URI |
| 142 | the pod should run, a content hash, and serializable state. The EKS |
| 143 | adapter stores its ECR repository there. `registry.hash` computes |
| 144 | the same hash at plan time so content changes show up as updates. |
| 145 | `registry.delete` cleans up on destroy. |
| 146 | - `identity.reconcile` receives the workload's namespace, |
| 147 | ServiceAccount, bindings, and `identity` options. It returns extra |
| 148 | environment variables, ServiceAccount annotations, and state. The |
| 149 | EKS adapter stores its IAM role and Pod Identity association there. |
| 150 | `identity.delete` removes them, even when the cluster is already |
| 151 | gone. |
| 152 | |
| 153 | Platforms extend the typed surfaces through module augmentation of |
| 154 | `alchemy/Kubernetes`: |
| 155 | |
| 156 | - `AuthRegistry` holds connection kinds. |
| 157 | - `IdentityStateRegistry` and `RegistryStateRegistry` hold persisted |
| 158 | state. |
| 159 | - `WorkloadIdentityOptions` types the `identity` prop. EKS adds |
| 160 | `managedPolicyArns`. |
| 161 | - `WorkloadServicesRegistry` lists services available inside the |
| 162 | container. |
| 163 | |
| 164 | The |
| 165 | [EKS adapter source](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/AWS/EKS/KubernetesAdapter.ts) |
| 166 | is the complete reference. |
| 167 | |
| 168 | ## Where next |
| 169 | |
| 170 | - **[Connecting to clusters](/kubernetes/clusters/connecting).** The |
| 171 | built-in connection kinds. |
| 172 | - **[Amazon EKS](/kubernetes/clusters/eks).** What the EKS |
| 173 | adapter does in practice. |
| 174 | - **[Layers](/infrastructure-as-effects/layers).** How Layers compose |
| 175 | in an Alchemy program. |