1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import {
6 toConnection,
7 type ClusterLike,
8 type Connection,
9} from "./Connection.ts";
10import {
11 applyObject,
12 connectCluster,
13 deleteObject,
14 readObject,
15 KubernetesApiError,
16} from "./internal/client.ts";
17import type {
18 KubernetesObjectDefinition,
19 KubernetesObjectRef,
20} from "./internal/objects.ts";
21import {
22 connectionIdentity,
23 connectionOfOutput,
24 tryConnectionOf,
25} from "./internal/workload.ts";
26import 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 */
34export 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
47export 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
62export 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 */
161export const Manifest = Resource<Manifest>("Kubernetes.Manifest", {
162 aliases: ["AWS.EKS.Manifest"],
163});
164
165const 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
179const isNotFound = (error: unknown): error is KubernetesApiError =>
180 error instanceof KubernetesApiError && error.statusCode === 404;
181
182export 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 );