@microagi/alchemy-gcp 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,22 +3,63 @@ import * as Effect from "effect/Effect";
3
3
  import * as https from "node:https";
4
4
 
5
5
  /**
6
- * Minimal GKE Kubernetes REST client.
6
+ * Raw, **server-side-apply-capable** GKE Kubernetes REST client.
7
7
  *
8
- * Talks to a GKE cluster's control plane over HTTPS using:
9
- * - a Google OAuth bearer token (cloud-platform scope), minted from the
10
- * provider's ADC {@link Credentials}, and
11
- * - the cluster's own CA certificate for TLS verification.
8
+ * # Two ways this provider writes to a cluster and when to use each
12
9
  *
13
- * `node:https` is used deliberately here (rather than the Effect
10
+ * There are two transports for talking to a GKE control plane. They are not
11
+ * redundant; each is correct for a different shape of resource.
12
+ *
13
+ * ### 1. Typed per-kind ops — `@distilled.cloud/kubernetes` via `connection.ts`
14
+ *
15
+ * What {@link "./Secret.ts"} uses. A **create + replace (PUT)** upsert built
16
+ * from the typed `core/v1` operations, with the cluster wired in by
17
+ * `clusterLayer`.
18
+ *
19
+ * **Use when** the kind is known at author time and the resource fully owns the
20
+ * object (Secret, ConfigMap, …).
21
+ * - ✅ Typed inputs/outputs (real `metadata`/`data`/`type` fields), no
22
+ * discovery round-trip, no content-type fiddling.
23
+ * - ⚠️ A PUT is a *full-object replacement*: it carries `resourceVersion` and
24
+ * overwrites every field — so it clobbers anything another manager set that
25
+ * you didn't include. Fine for objects you solely own; wrong for shared ones.
26
+ * - ⚠️ Per-kind only — there's a distinct typed op per Kind.
27
+ *
28
+ * ### 2. This raw client — server-side apply of an **arbitrary** object
29
+ *
30
+ * What {@link "./KubernetesManifest.ts"} uses. A single `PATCH` with
31
+ * `Content-Type: application/apply-patch+yaml`, `?fieldManager=alchemy&force=true`,
32
+ * the object itself as the (untyped) body, and the resource path resolved from
33
+ * the apiserver's **discovery** endpoint so *any* Kind works — built-ins and
34
+ * CRDs alike.
35
+ *
36
+ * **Use when** the kind is dynamic/unknown (generic manifests, CRDs), **or**
37
+ * you need server-side-apply semantics:
38
+ * - **field-level merge / ownership** — apply touches only the fields
39
+ * `fieldManager: alchemy` declares, leaving fields owned by other managers
40
+ * (controllers, defaulting, admission) intact instead of clobbering them;
41
+ * - **declarative pruning** — dropping a field you previously owned removes it,
42
+ * without a read-merge-write dance;
43
+ * - **one idempotent call** — no read-before-write, no create-vs-replace
44
+ * branch, no `resourceVersion` juggling.
45
+ * - ⚠️ Untyped body, plus one extra discovery `GET` to map Kind → resource.
46
+ *
47
+ * Rule of thumb: **known Kind you fully own → typed create/replace (path 1);
48
+ * arbitrary Kind or shared/merge semantics → SSA (this client).**
49
+ *
50
+ * ---
51
+ *
52
+ * Both transports authenticate with a Google OAuth bearer token (cloud-platform
53
+ * scope, minted from the provider's ADC credentials) and trust the cluster's
54
+ * own CA. `node:https` is used here deliberately (rather than the Effect
14
55
  * `HttpClient`): the GKE API server presents a certificate signed by the
15
- * *per-cluster* CA — not a public root — so the request must trust an
16
- * explicit, runtime-resolved `ca` PEM. This is the same justified TLS
17
- * exception alchemy makes in its own (EKS-only) Kubernetes client. Calls
18
- * are wrapped in `Effect.tryPromise` so failures surface as typed errors
19
- * in the Effect runtime; a socket timeout bounds hung requests. (Effect
20
- * interruption won't abort an in-flight socket — the timeout is what
21
- * guarantees a deploy can't block forever.)
56
+ * *per-cluster* CA — not a public root — so the request must trust an explicit,
57
+ * runtime-resolved `ca` PEM. (`connection.ts`'s `clusterLayer` solves the same
58
+ * CA-trust problem for the distilled path, by overriding `FetchHttpClient`.)
59
+ * Calls are wrapped in `Effect.tryPromise` so failures surface as typed errors;
60
+ * a socket timeout bounds hung requests (Effect interruption won't abort an
61
+ * in-flight socket — the timeout is what guarantees a deploy can't block
62
+ * forever).
22
63
  */
23
64
  const REQUEST_TIMEOUT_MS = 30_000;
24
65
 
@@ -146,8 +187,8 @@ const requestJson = Effect.fn("k8s.requestJson")(function* ({
146
187
  });
147
188
  });
148
189
 
149
- /** Live view of a Kubernetes Secret object (subset we care about). */
150
- export interface SecretObject {
190
+ /** A Kubernetes object as seen on the wire (only the fields we read back). */
191
+ export interface KubeObject {
151
192
  apiVersion?: string;
152
193
  kind?: string;
153
194
  metadata?: {
@@ -156,61 +197,187 @@ export interface SecretObject {
156
197
  uid?: string;
157
198
  resourceVersion?: string;
158
199
  labels?: Record<string, string>;
200
+ [key: string]: unknown;
159
201
  };
160
- type?: string;
202
+ [key: string]: unknown;
161
203
  }
162
204
 
163
- const secretPath = (namespace: string, name: string) =>
164
- `/api/v1/namespaces/${namespace}/secrets/${name}`;
205
+ /** Where a Kind lives in the REST hierarchy, from the discovery doc. */
206
+ interface ResourceInfo {
207
+ /** Plural resource name, e.g. `secrets`, `deployments`. */
208
+ plural: string;
209
+ /** Whether instances are namespaced (vs cluster-scoped). */
210
+ namespaced: boolean;
211
+ }
165
212
 
166
- /** GET a Secret; the caller maps `KubernetesApiError(404)` to "missing". */
167
- export const getSecret = (
213
+ /**
214
+ * Discovery path for an `apiVersion`: core group (`v1`) lives under `/api/v1`,
215
+ * named groups (`apps/v1`, `networking.k8s.io/v1`) under `/apis/{group}/{ver}`.
216
+ */
217
+ const apiPathPrefix = (apiVersion: string): string =>
218
+ apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`;
219
+
220
+ /**
221
+ * Resolve a Kind to its REST resource (plural + scope) via the apiserver's
222
+ * discovery endpoint. This is what lets SSA work for ANY kind — built-ins and
223
+ * CRDs — without a hard-coded kind→plural table.
224
+ */
225
+ export const resolveResource = (
168
226
  connection: GkeConnection,
169
- namespace: string,
170
- name: string,
171
- ) =>
172
- requestJson({
173
- connection,
174
- method: "GET",
175
- path: secretPath(namespace, name),
176
- }) as Effect.Effect<SecretObject, KubernetesApiError>;
227
+ apiVersion: string,
228
+ kind: string,
229
+ ): Effect.Effect<ResourceInfo, KubernetesApiError> => {
230
+ const path = apiPathPrefix(apiVersion);
231
+ return (
232
+ requestJson({ connection, method: "GET", path }) as Effect.Effect<
233
+ { resources?: Array<{ name: string; namespaced: boolean; kind: string }> },
234
+ KubernetesApiError
235
+ >
236
+ ).pipe(
237
+ Effect.flatMap((doc) => {
238
+ // Match on Kind, excluding subresources (their `name` contains a `/`,
239
+ // e.g. `pods/status`).
240
+ const match = (doc.resources ?? []).find(
241
+ (r) => r.kind === kind && !r.name.includes("/"),
242
+ );
243
+ return match
244
+ ? Effect.succeed({ plural: match.name, namespaced: match.namespaced })
245
+ : Effect.fail(
246
+ new KubernetesApiError({
247
+ method: "GET",
248
+ path,
249
+ statusCode: 0,
250
+ body: `Kind ${apiVersion}/${kind} not found in discovery (${path})`,
251
+ }),
252
+ );
253
+ }),
254
+ );
255
+ };
256
+
257
+ const objectPath = (
258
+ apiVersion: string,
259
+ info: ResourceInfo,
260
+ namespace: string | undefined,
261
+ name: string | undefined,
262
+ ): string => {
263
+ const prefix = apiPathPrefix(apiVersion);
264
+ const collection = info.namespaced
265
+ ? `${prefix}/namespaces/${namespace}/${info.plural}`
266
+ : `${prefix}/${info.plural}`;
267
+ return name ? `${collection}/${name}` : collection;
268
+ };
177
269
 
178
270
  /**
179
- * Server-side apply a Secret (idempotent create-or-update). `force=true`
180
- * makes alchemy the field manager even if another manager owns fields.
181
- *
182
- * Values go through `data` (base64), NOT `stringData`: server-side apply
183
- * tracks the field manager's ownership of `data` keys, so dropping a key
184
- * from a later apply prunes it from the stored Secret. `stringData` is
185
- * write-only/ephemeral and isn't tracked, which would leave stale keys.
271
+ * Guard against building a `/namespaces/undefined/…` path: a namespaced Kind
272
+ * with no namespace would otherwise GET/DELETE a bogus URL the API returns
273
+ * 404, which read treats as "absent" (phantom recreate) and delete tolerates as
274
+ * success (orphan). Fail loudly instead. (No-op for cluster-scoped Kinds.)
275
+ */
276
+ const requireNamespace = (
277
+ method: string,
278
+ apiVersion: string,
279
+ kind: string,
280
+ info: ResourceInfo,
281
+ namespace: string | undefined,
282
+ ): Effect.Effect<void, KubernetesApiError> =>
283
+ info.namespaced && !namespace
284
+ ? Effect.fail(
285
+ new KubernetesApiError({
286
+ method,
287
+ path: "",
288
+ statusCode: 0,
289
+ body: `${apiVersion}/${kind} is namespaced — a namespace is required`,
290
+ }),
291
+ )
292
+ : Effect.void;
293
+
294
+ /**
295
+ * Server-side apply an arbitrary object (idempotent create-or-converge).
296
+ * `force=true` makes alchemy the field manager even where another manager
297
+ * currently owns a field we declare. Only the fields present in `object` are
298
+ * managed; fields owned by other managers are left untouched, and dropping a
299
+ * previously-declared field prunes it.
186
300
  */
187
- export const applySecret = (
301
+ export const applyObject = (
188
302
  connection: GkeConnection,
189
- secret: {
190
- metadata: { name: string; namespace: string; labels?: Record<string, string> };
191
- type: string;
192
- data: Record<string, string>;
193
- },
194
- ) =>
195
- requestJson({
196
- connection,
197
- method: "PATCH",
198
- path: `${secretPath(secret.metadata.namespace, secret.metadata.name)}?fieldManager=alchemy&force=true`,
199
- contentType: "application/apply-patch+yaml",
200
- body: { apiVersion: "v1", kind: "Secret", ...secret },
201
- }) as Effect.Effect<SecretObject | undefined, KubernetesApiError>;
202
-
203
- /** DELETE a Secret; 404 is tolerated as success (idempotent teardown). */
204
- export const deleteSecret = (
303
+ object: KubeObject,
304
+ fieldManager = "alchemy",
305
+ ): Effect.Effect<KubeObject | undefined, KubernetesApiError> => {
306
+ const apiVersion = object.apiVersion;
307
+ const kind = object.kind;
308
+ const name = object.metadata?.name;
309
+ const namespace = object.metadata?.namespace;
310
+ if (!apiVersion || !kind || !name) {
311
+ return Effect.fail(
312
+ new KubernetesApiError({
313
+ method: "PATCH",
314
+ path: "",
315
+ statusCode: 0,
316
+ body: "object must have apiVersion, kind and metadata.name",
317
+ }),
318
+ );
319
+ }
320
+ return resolveResource(connection, apiVersion, kind).pipe(
321
+ Effect.flatMap((info) =>
322
+ requireNamespace("PATCH", apiVersion, kind, info, namespace).pipe(
323
+ Effect.flatMap(() => {
324
+ const path = `${objectPath(apiVersion, info, namespace, name)}?fieldManager=${fieldManager}&force=true`;
325
+ return requestJson({
326
+ connection,
327
+ method: "PATCH",
328
+ path,
329
+ contentType: "application/apply-patch+yaml",
330
+ body: object as Record<string, unknown>,
331
+ }) as Effect.Effect<KubeObject | undefined, KubernetesApiError>;
332
+ }),
333
+ ),
334
+ ),
335
+ );
336
+ };
337
+
338
+ /** GET an object; the caller maps `KubernetesApiError(404)` to "missing". */
339
+ export const getObject = (
205
340
  connection: GkeConnection,
206
- namespace: string,
341
+ apiVersion: string,
342
+ kind: string,
343
+ namespace: string | undefined,
207
344
  name: string,
208
- ) =>
209
- requestJson({
210
- connection,
211
- method: "DELETE",
212
- path: secretPath(namespace, name),
213
- }).pipe(
345
+ ): Effect.Effect<KubeObject, KubernetesApiError> =>
346
+ resolveResource(connection, apiVersion, kind).pipe(
347
+ Effect.flatMap((info) =>
348
+ requireNamespace("GET", apiVersion, kind, info, namespace).pipe(
349
+ Effect.flatMap(
350
+ () =>
351
+ requestJson({
352
+ connection,
353
+ method: "GET",
354
+ path: objectPath(apiVersion, info, namespace, name),
355
+ }) as Effect.Effect<KubeObject, KubernetesApiError>,
356
+ ),
357
+ ),
358
+ ),
359
+ );
360
+
361
+ /** DELETE an object; 404 is tolerated as success (idempotent teardown). */
362
+ export const deleteObject = (
363
+ connection: GkeConnection,
364
+ apiVersion: string,
365
+ kind: string,
366
+ namespace: string | undefined,
367
+ name: string,
368
+ ): Effect.Effect<void, KubernetesApiError> =>
369
+ resolveResource(connection, apiVersion, kind).pipe(
370
+ Effect.flatMap((info) =>
371
+ requireNamespace("DELETE", apiVersion, kind, info, namespace).pipe(
372
+ Effect.flatMap(() =>
373
+ requestJson({
374
+ connection,
375
+ method: "DELETE",
376
+ path: objectPath(apiVersion, info, namespace, name),
377
+ }),
378
+ ),
379
+ ),
380
+ ),
214
381
  Effect.catchIf(
215
382
  (e): e is KubernetesApiError =>
216
383
  e instanceof KubernetesApiError && e.statusCode === 404,
@@ -0,0 +1,180 @@
1
+ import { Credentials } from "@distilled.cloud/kubernetes/Credentials";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import type * as Redacted from "effect/Redacted";
5
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
6
+ import type * as HttpClient from "effect/unstable/http/HttpClient";
7
+ import * as https from "node:https";
8
+
9
+ /**
10
+ * Bridges a GKE control plane into the layers that `@distilled.cloud/kubernetes`
11
+ * operations require — letting us drive the cluster's kube-apiserver through the
12
+ * typed distilled SDK (core/v1, apps/v1, batch, rbac, apiextensions/CRDs, …)
13
+ * instead of hand-rolling one HTTP resource per kind.
14
+ *
15
+ * distilled operations resolve two services from context:
16
+ *
17
+ * - `KubernetesCredentials` (`{ token, apiBaseUrl }`) — the bearer token (a
18
+ * Google OAuth access token minted from the provider's ADC `Credentials`) and
19
+ * the API base URL (`https://<cluster endpoint>`).
20
+ * - `HttpClient.HttpClient` — the transport. The GKE API server presents a cert
21
+ * signed by the cluster's *own* CA (not a public root), so a stock fetch fails
22
+ * TLS verification. We override {@link FetchHttpClient.Fetch} with a
23
+ * `node:https`-backed fetch that trusts the per-cluster CA — the same
24
+ * justified TLS exception the bespoke client made, but now routed through the
25
+ * typed SDK. (undici's `Agent`/`dispatcher` would be the fetch-native route,
26
+ * but undici isn't resolvable as a bare import here, and this reuses the
27
+ * transport we already trust.)
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * import { readCoreV1NamespacedSecret } from "@distilled.cloud/kubernetes/core";
32
+ *
33
+ * const secret = yield* readCoreV1NamespacedSecret({ namespace, name }).pipe(
34
+ * Effect.provide(clusterLayer({ endpoint, caCertificate, token })),
35
+ * );
36
+ * ```
37
+ */
38
+
39
+ /** A resolved connection to a single GKE control plane. */
40
+ export interface ClusterConnection {
41
+ /** Master endpoint — IP or hostname, no scheme (e.g. `34.1.2.3`). */
42
+ readonly endpoint: string;
43
+ /** Base64-encoded cluster CA certificate (PEM), as GKE returns it. */
44
+ readonly caCertificate: string;
45
+ /** Google OAuth access token (cloud-platform scope) for `Authorization`. */
46
+ readonly token: Redacted.Redacted<string>;
47
+ }
48
+
49
+ /** Bounds a hung socket — Effect interruption can't abort an in-flight request. */
50
+ const REQUEST_TIMEOUT_MS = 30_000;
51
+
52
+ /**
53
+ * A `globalThis.fetch`-shaped function that issues the request over `node:https`
54
+ * trusting an explicit CA PEM, then resolves a standard `Response`. Effect's
55
+ * {@link FetchHttpClient.layer} reads this via `fiber.getRef(Fetch)` and feeds
56
+ * it `(url, { method, headers, body, signal })` per request.
57
+ */
58
+ const caFetch = (caPem: string): typeof globalThis.fetch =>
59
+ ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
60
+ const href =
61
+ typeof input === "string"
62
+ ? input
63
+ : input instanceof URL
64
+ ? input.href
65
+ : input.url;
66
+ const url = new URL(href);
67
+
68
+ const headers: Record<string, string> = {};
69
+ if (init?.headers) {
70
+ new Headers(init.headers).forEach((value, key) => {
71
+ headers[key] = value;
72
+ });
73
+ }
74
+
75
+ const body = init?.body;
76
+ if (
77
+ body != null &&
78
+ typeof body !== "string" &&
79
+ !(body instanceof Uint8Array)
80
+ ) {
81
+ // Our k8s calls only ever send JSON (Raw/Uint8Array) bodies. A streaming
82
+ // body would need different handling — fail loudly rather than silently
83
+ // dropping it.
84
+ return Promise.reject(
85
+ new TypeError("caFetch: only string/Uint8Array request bodies are supported"),
86
+ );
87
+ }
88
+
89
+ return new Promise<Response>((resolve, reject) => {
90
+ const request = https.request(
91
+ {
92
+ protocol: url.protocol,
93
+ hostname: url.hostname,
94
+ port: url.port || 443,
95
+ path: `${url.pathname}${url.search}`,
96
+ method: init?.method ?? "GET",
97
+ headers,
98
+ ca: caPem,
99
+ timeout: REQUEST_TIMEOUT_MS,
100
+ },
101
+ (response) => {
102
+ const chunks: Buffer[] = [];
103
+ response.on("data", (chunk) =>
104
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)),
105
+ );
106
+ response.on("end", () => {
107
+ const buffer = Buffer.concat(chunks);
108
+ const responseHeaders = new Headers();
109
+ for (const [key, value] of Object.entries(response.headers)) {
110
+ if (Array.isArray(value)) {
111
+ for (const v of value) responseHeaders.append(key, v);
112
+ } else if (value != null) {
113
+ responseHeaders.set(key, value);
114
+ }
115
+ }
116
+ resolve(
117
+ new Response(buffer.length > 0 ? buffer : null, {
118
+ status: response.statusCode ?? 500,
119
+ statusText: response.statusMessage ?? "",
120
+ headers: responseHeaders,
121
+ }),
122
+ );
123
+ });
124
+ },
125
+ );
126
+
127
+ request.on("error", reject);
128
+ // `timeout` only fires the event — destroy the socket ourselves so the
129
+ // promise rejects instead of hanging.
130
+ request.on("timeout", () =>
131
+ request.destroy(
132
+ new Error(`Kubernetes request timed out after ${REQUEST_TIMEOUT_MS}ms`),
133
+ ),
134
+ );
135
+ // Honour Effect's abort signal so interruption tears down the socket.
136
+ if (init?.signal) {
137
+ if (init.signal.aborted) request.destroy(new Error("aborted"));
138
+ else
139
+ init.signal.addEventListener("abort", () =>
140
+ request.destroy(new Error("aborted")),
141
+ );
142
+ }
143
+ if (body != null) request.write(body);
144
+ request.end();
145
+ });
146
+ }) as typeof globalThis.fetch;
147
+
148
+ /** `HttpClient` layer whose fetch trusts the given (base64 PEM) cluster CA. */
149
+ const caHttpClient = (
150
+ caCertificate: string,
151
+ ): Layer.Layer<HttpClient.HttpClient> =>
152
+ Layer.provide(
153
+ FetchHttpClient.layer,
154
+ Layer.succeed(
155
+ FetchHttpClient.Fetch,
156
+ caFetch(Buffer.from(caCertificate, "base64").toString("utf8")),
157
+ ),
158
+ );
159
+
160
+ /** `KubernetesCredentials` layer for a single cluster + bearer token. */
161
+ const credentials = (
162
+ endpoint: string,
163
+ token: Redacted.Redacted<string>,
164
+ ): Layer.Layer<Credentials> =>
165
+ Layer.succeed(
166
+ Credentials,
167
+ Effect.succeed({ token, apiBaseUrl: `https://${endpoint}` }),
168
+ );
169
+
170
+ /**
171
+ * The full requirements layer for any `@distilled.cloud/kubernetes` operation
172
+ * against one GKE cluster: credentials + a CA-trusting HTTP client.
173
+ */
174
+ export const clusterLayer = (
175
+ connection: ClusterConnection,
176
+ ): Layer.Layer<Credentials | HttpClient.HttpClient> =>
177
+ Layer.merge(
178
+ credentials(connection.endpoint, connection.token),
179
+ caHttpClient(connection.caCertificate),
180
+ );
@@ -1 +1,2 @@
1
+ export * from "./KubernetesManifest.ts";
1
2
  export * from "./Secret.ts";
package/src/Providers.ts CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  import { Subnetwork, SubnetworkProvider } from "./Compute/Subnetwork.ts";
24
24
  import { Cluster, ClusterProvider } from "./Container/Cluster.ts";
25
25
  import { NodePool, NodePoolProvider } from "./Container/NodePool.ts";
26
+ import {
27
+ KubernetesManifest,
28
+ KubernetesManifestProvider,
29
+ } from "./Kubernetes/KubernetesManifest.ts";
26
30
  import {
27
31
  KubernetesSecret,
28
32
  KubernetesSecretProvider,
@@ -86,6 +90,7 @@ export const providers = () =>
86
90
  SqlUser,
87
91
  ArtifactRegistryRepository,
88
92
  KubernetesSecret,
93
+ KubernetesManifest,
89
94
  ]),
90
95
  ).pipe(
91
96
  Layer.provide(
@@ -111,6 +116,7 @@ export const providers = () =>
111
116
  SqlUserProvider(),
112
117
  ArtifactRegistryRepositoryProvider(),
113
118
  KubernetesSecretProvider(),
119
+ KubernetesManifestProvider(),
114
120
  ),
115
121
  ),
116
122
  Layer.provideMerge(fromAuthProvider()),