| 1 | import * as Effect from "effect/Effect"; |
| 2 | import { isResolved } from "../Diff.ts"; |
| 3 | import * as Provider from "../Provider.ts"; |
| 4 | import { Resource } from "../Resource.ts"; |
| 5 | import { |
| 6 | toConnection, |
| 7 | type ClusterLike, |
| 8 | type Connection, |
| 9 | } from "./Connection.ts"; |
| 10 | import { |
| 11 | applyObject, |
| 12 | connectCluster, |
| 13 | deleteObject, |
| 14 | readObject, |
| 15 | KubernetesApiError, |
| 16 | } from "./internal/client.ts"; |
| 17 | import type { |
| 18 | KubernetesObjectDefinition, |
| 19 | KubernetesObjectRef, |
| 20 | } from "./internal/objects.ts"; |
| 21 | import { |
| 22 | connectionIdentity, |
| 23 | connectionOfOutput, |
| 24 | tryConnectionOf, |
| 25 | } from "./internal/workload.ts"; |
| 26 | import type { Providers } from "./Providers.ts"; |
| 27 | |
| 28 | /** |
| 29 | * A literal Kubernetes object: `apiVersion` + `kind` + `metadata`, with the |
| 30 | * rest of the object's fields (`spec`, `data`, …) carried as-is. Any kind is |
| 31 | * accepted — built-in objects and CRDs alike; the API server validates the |
| 32 | * shape on apply. |
| 33 | */ |
| 34 | export interface KubernetesManifest { |
| 35 | apiVersion: string; |
| 36 | kind: string; |
| 37 | metadata?: { |
| 38 | name?: string; |
| 39 | namespace?: string; |
| 40 | labels?: Record<string, string>; |
| 41 | annotations?: Record<string, string>; |
| 42 | [key: string]: unknown; |
| 43 | }; |
| 44 | [key: string]: unknown; |
| 45 | } |
| 46 | |
| 47 | export interface ManifestProps { |
| 48 | /** |
| 49 | * Target cluster the manifest is applied onto. Pass a managed cluster |
| 50 | * resource (e.g. `AWS.EKS.Cluster`), a `Kubernetes.KubeConfig(...)`, or |
| 51 | * a raw `Kubernetes.Connection`. |
| 52 | */ |
| 53 | cluster: ClusterLike; |
| 54 | /** |
| 55 | * The Kubernetes object to apply (server-side apply, field manager |
| 56 | * `alchemy`) — a literal object with `apiVersion`, `kind`, `metadata`, and |
| 57 | * the kind's own fields. Arbitrary CRDs are supported via API discovery. |
| 58 | */ |
| 59 | manifest: KubernetesManifest; |
| 60 | } |
| 61 | |
| 62 | export interface Manifest extends Resource< |
| 63 | "Kubernetes.Manifest", |
| 64 | ManifestProps, |
| 65 | { |
| 66 | /** The connection of the cluster the object is applied to. */ |
| 67 | connection: Connection; |
| 68 | /** The Kubernetes API version of the applied object. */ |
| 69 | apiVersion: string; |
| 70 | /** The Kubernetes kind of the applied object. */ |
| 71 | kind: string; |
| 72 | /** The name of the applied object. */ |
| 73 | name: string; |
| 74 | /** The namespace of the applied object (`undefined` for cluster-scoped kinds). */ |
| 75 | namespace: string | undefined; |
| 76 | /** Reference to the applied Kubernetes object. */ |
| 77 | ref: KubernetesObjectRef; |
| 78 | /** The server-assigned UID of the applied object, when returned. */ |
| 79 | uid: string | undefined; |
| 80 | }, |
| 81 | {}, |
| 82 | Providers |
| 83 | > {} |
| 84 | |
| 85 | /** |
| 86 | * Applies a raw Kubernetes manifest onto any cluster via server-side |
| 87 | * apply. |
| 88 | * |
| 89 | * Any literal object is accepted — built-in kinds and custom resources |
| 90 | * alike; unknown kinds are resolved through the Kubernetes API discovery |
| 91 | * endpoint, so CRDs work without any registration. The target `cluster` |
| 92 | * can be a managed cluster resource (e.g. `AWS.EKS.Cluster`) or any |
| 93 | * cluster your kubeconfig can reach (`Kubernetes.KubeConfig(...)`). |
| 94 | * ### Applying Manifests |
| 95 | * **Example:** StatefulSet |
| 96 | * ```typescript |
| 97 | * const sts = yield* Kubernetes.Manifest("Cache", { |
| 98 | * cluster, |
| 99 | * manifest: { |
| 100 | * apiVersion: "apps/v1", |
| 101 | * kind: "StatefulSet", |
| 102 | * metadata: { name: "cache", namespace: "apps" }, |
| 103 | * spec: { |
| 104 | * serviceName: "cache", |
| 105 | * replicas: 3, |
| 106 | * selector: { matchLabels: { app: "cache" } }, |
| 107 | * template: { |
| 108 | * metadata: { labels: { app: "cache" } }, |
| 109 | * spec: { containers: [{ name: "redis", image: "redis:7" }] }, |
| 110 | * }, |
| 111 | * }, |
| 112 | * }, |
| 113 | * }); |
| 114 | * ``` |
| 115 | * |
| 116 | * **Example:** Custom resource (CRD) |
| 117 | * ```typescript |
| 118 | * const widget = yield* Kubernetes.Manifest("Widget", { |
| 119 | * cluster, |
| 120 | * manifest: { |
| 121 | * apiVersion: "acme.io/v1", |
| 122 | * kind: "Widget", |
| 123 | * metadata: { name: "w", namespace: "default" }, |
| 124 | * spec: { size: 3 }, |
| 125 | * }, |
| 126 | * }); |
| 127 | * ``` |
| 128 | * |
| 129 | * ### Namespaces |
| 130 | * **Example:** Create a Namespace |
| 131 | * ```typescript |
| 132 | * const ns = yield* Kubernetes.Manifest("AppsNamespace", { |
| 133 | * cluster, |
| 134 | * manifest: { |
| 135 | * apiVersion: "v1", |
| 136 | * kind: "Namespace", |
| 137 | * metadata: { name: "apps" }, |
| 138 | * }, |
| 139 | * }); |
| 140 | * ``` |
| 141 | * |
| 142 | * ### Any Cluster |
| 143 | * **Example:** Apply onto a kubeconfig context |
| 144 | * ```typescript |
| 145 | * const local = Kubernetes.KubeConfig({ context: "kind-dev" }); |
| 146 | * |
| 147 | * const config = yield* Kubernetes.Manifest("AppConfig", { |
| 148 | * cluster: local, |
| 149 | * manifest: { |
| 150 | * apiVersion: "v1", |
| 151 | * kind: "ConfigMap", |
| 152 | * metadata: { name: "app-config", namespace: "default" }, |
| 153 | * data: { LOG_LEVEL: "info" }, |
| 154 | * }, |
| 155 | * }); |
| 156 | * ``` |
| 157 | * |
| 158 | * @resource |
| 159 | * @product Manifest |
| 160 | */ |
| 161 | export const Manifest = Resource<Manifest>("Kubernetes.Manifest", { |
| 162 | aliases: ["AWS.EKS.Manifest"], |
| 163 | }); |
| 164 | |
| 165 | const toObjectDefinition = ( |
| 166 | manifest: KubernetesManifest, |
| 167 | ): Effect.Effect<KubernetesObjectDefinition, Error> => { |
| 168 | const name = manifest.metadata?.name; |
| 169 | if (!name) { |
| 170 | return Effect.fail( |
| 171 | new Error( |
| 172 | `Kubernetes.Manifest requires manifest.metadata.name (got ${manifest.apiVersion}/${manifest.kind})`, |
| 173 | ), |
| 174 | ); |
| 175 | } |
| 176 | return Effect.succeed(manifest as KubernetesObjectDefinition); |
| 177 | }; |
| 178 | |
| 179 | const isNotFound = (error: unknown): error is KubernetesApiError => |
| 180 | error instanceof KubernetesApiError && error.statusCode === 404; |
| 181 | |
| 182 | export const ManifestProvider = () => |
| 183 | Provider.effect( |
| 184 | Manifest, |
| 185 | Effect.gen(function* () { |
| 186 | return { |
| 187 | stables: ["connection", "apiVersion", "kind", "name", "namespace"], |
| 188 | // In-cluster objects have no cloud-side enumeration that attributes |
| 189 | // them to alchemy; refresh happens per-instance through `read`. |
| 190 | list: () => Effect.succeed([] as Manifest["Attributes"][]), |
| 191 | diff: Effect.fn(function* ({ olds = {} as ManifestProps, news }) { |
| 192 | if (!isResolved(news)) return; |
| 193 | const oldManifest = olds.manifest as KubernetesManifest | undefined; |
| 194 | const newManifest = news.manifest as KubernetesManifest; |
| 195 | const oldCluster = connectionIdentity(tryConnectionOf(olds.cluster)); |
| 196 | const newCluster = connectionIdentity(tryConnectionOf(news.cluster)); |
| 197 | // Object identity (cluster, group/version/kind, name, namespace) is |
| 198 | // immutable — changing any of it is a replacement. |
| 199 | if ( |
| 200 | oldManifest && |
| 201 | ((oldCluster !== undefined && |
| 202 | newCluster !== undefined && |
| 203 | oldCluster !== newCluster) || |
| 204 | oldManifest.apiVersion !== newManifest.apiVersion || |
| 205 | oldManifest.kind !== newManifest.kind || |
| 206 | oldManifest.metadata?.name !== newManifest.metadata?.name || |
| 207 | oldManifest.metadata?.namespace !== |
| 208 | newManifest.metadata?.namespace) |
| 209 | ) { |
| 210 | return { action: "replace" } as const; |
| 211 | } |
| 212 | }), |
| 213 | read: Effect.fn(function* ({ output }) { |
| 214 | if (!output) return undefined; |
| 215 | const connection = connectionOfOutput(output); |
| 216 | if (!connection) return undefined; |
| 217 | const transport = yield* connectCluster(connection).pipe( |
| 218 | // Cluster gone — its objects went with it. |
| 219 | Effect.catchTag("Kubernetes.ClusterNotFoundError", () => |
| 220 | Effect.succeed(undefined), |
| 221 | ), |
| 222 | ); |
| 223 | if (!transport) return undefined; |
| 224 | const observed = yield* readObject({ |
| 225 | transport, |
| 226 | object: output.ref, |
| 227 | }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined))); |
| 228 | if (!observed) return undefined; |
| 229 | const uid = (observed as { metadata?: { uid?: string } }).metadata |
| 230 | ?.uid; |
| 231 | return { ...output, uid }; |
| 232 | }), |
| 233 | reconcile: Effect.fn(function* ({ news, output, session }) { |
| 234 | const connection = toConnection(news.cluster); |
| 235 | const transport = yield* connectCluster(connection); |
| 236 | const object = yield* toObjectDefinition(news.manifest); |
| 237 | const ref: KubernetesObjectRef = { |
| 238 | apiVersion: object.apiVersion, |
| 239 | kind: object.kind, |
| 240 | name: object.metadata.name, |
| 241 | namespace: object.metadata.namespace, |
| 242 | }; |
| 243 | |
| 244 | // Server-side apply is a true upsert: create-if-missing and |
| 245 | // converge-if-present in one call, `force: true` so alchemy owns |
| 246 | // the fields it manages regardless of prior managers. |
| 247 | const applied = yield* applyObject({ transport, object }); |
| 248 | |
| 249 | yield* session.note( |
| 250 | `Applied ${ref.apiVersion}/${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ""}${ref.name}`, |
| 251 | ); |
| 252 | |
| 253 | const uid = |
| 254 | (applied as { metadata?: { uid?: string } })?.metadata?.uid ?? |
| 255 | output?.uid; |
| 256 | |
| 257 | return { |
| 258 | connection, |
| 259 | apiVersion: ref.apiVersion, |
| 260 | kind: ref.kind, |
| 261 | name: ref.name, |
| 262 | namespace: ref.namespace, |
| 263 | ref, |
| 264 | uid, |
| 265 | }; |
| 266 | }), |
| 267 | delete: Effect.fn(function* ({ output }) { |
| 268 | const connection = connectionOfOutput(output); |
| 269 | if (!connection) return; |
| 270 | const transport = yield* connectCluster(connection).pipe( |
| 271 | // Cluster already destroyed — nothing left to delete. |
| 272 | Effect.catchTag("Kubernetes.ClusterNotFoundError", () => |
| 273 | Effect.succeed(undefined), |
| 274 | ), |
| 275 | ); |
| 276 | if (!transport) return; |
| 277 | yield* deleteObject({ transport, object: output.ref }).pipe( |
| 278 | // Tolerate any residual API failure so delete stays idempotent |
| 279 | // (e.g. the CRD backing an object was removed before the object). |
| 280 | Effect.catch(() => Effect.void), |
| 281 | ); |
| 282 | }), |
| 283 | }; |
| 284 | }), |
| 285 | ); |