@microagi/alchemy-gcp 0.5.0 → 0.6.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/ArtifactRegistry/Repository.d.ts +1 -1
  3. package/lib/ArtifactRegistry/Repository.d.ts.map +1 -1
  4. package/lib/ArtifactRegistry/Repository.js +8 -9
  5. package/lib/ArtifactRegistry/Repository.js.map +1 -1
  6. package/lib/Auth/Credentials.d.ts +10 -3
  7. package/lib/Auth/Credentials.d.ts.map +1 -1
  8. package/lib/Auth/Credentials.js +13 -3
  9. package/lib/Auth/Credentials.js.map +1 -1
  10. package/lib/CloudResourceManager/Project.d.ts +1 -1
  11. package/lib/Compute/Address.d.ts +97 -0
  12. package/lib/Compute/Address.d.ts.map +1 -0
  13. package/lib/Compute/Address.js +143 -0
  14. package/lib/Compute/Address.js.map +1 -0
  15. package/lib/Compute/Firewall.d.ts +108 -0
  16. package/lib/Compute/Firewall.d.ts.map +1 -0
  17. package/lib/Compute/Firewall.js +163 -0
  18. package/lib/Compute/Firewall.js.map +1 -0
  19. package/lib/Compute/ForwardingRule.d.ts +106 -0
  20. package/lib/Compute/ForwardingRule.d.ts.map +1 -0
  21. package/lib/Compute/ForwardingRule.js +152 -0
  22. package/lib/Compute/ForwardingRule.js.map +1 -0
  23. package/lib/Compute/GlobalAddress.d.ts +1 -1
  24. package/lib/Compute/Network.d.ts +1 -1
  25. package/lib/Compute/SharedVpcHost.d.ts +1 -1
  26. package/lib/Compute/SharedVpcServiceProject.d.ts +1 -1
  27. package/lib/Compute/Subnetwork.d.ts +1 -1
  28. package/lib/Compute/index.d.ts +3 -0
  29. package/lib/Compute/index.d.ts.map +1 -1
  30. package/lib/Compute/index.js +3 -0
  31. package/lib/Compute/index.js.map +1 -1
  32. package/lib/Container/Cluster.d.ts +21 -1
  33. package/lib/Container/Cluster.d.ts.map +1 -1
  34. package/lib/Container/Cluster.js +5 -2
  35. package/lib/Container/Cluster.js.map +1 -1
  36. package/lib/Container/NodePool.d.ts +27 -2
  37. package/lib/Container/NodePool.d.ts.map +1 -1
  38. package/lib/Container/NodePool.js +45 -8
  39. package/lib/Container/NodePool.js.map +1 -1
  40. package/lib/Kubernetes/Secret.d.ts +89 -0
  41. package/lib/Kubernetes/Secret.d.ts.map +1 -0
  42. package/lib/Kubernetes/Secret.js +139 -0
  43. package/lib/Kubernetes/Secret.js.map +1 -0
  44. package/lib/Kubernetes/client.d.ts +58 -0
  45. package/lib/Kubernetes/client.d.ts.map +1 -0
  46. package/lib/Kubernetes/client.js +131 -0
  47. package/lib/Kubernetes/client.js.map +1 -0
  48. package/lib/Kubernetes/index.d.ts +2 -0
  49. package/lib/Kubernetes/index.d.ts.map +1 -0
  50. package/lib/Kubernetes/index.js +2 -0
  51. package/lib/Kubernetes/index.js.map +1 -0
  52. package/lib/ManagedLustre/Instance.d.ts +1 -1
  53. package/lib/ManagedLustre/Instance.d.ts.map +1 -1
  54. package/lib/ManagedLustre/Instance.js +25 -5
  55. package/lib/ManagedLustre/Instance.js.map +1 -1
  56. package/lib/Providers.d.ts +1 -1
  57. package/lib/Providers.d.ts.map +1 -1
  58. package/lib/Providers.js +9 -1
  59. package/lib/Providers.js.map +1 -1
  60. package/lib/Run/Job.d.ts +1 -1
  61. package/lib/Run/Service.d.ts +1 -1
  62. package/lib/ServiceNetworking/PsaConnection.d.ts +1 -1
  63. package/lib/ServiceUsage/ApiEnable.d.ts +1 -1
  64. package/lib/Sqladmin/Database.d.ts +1 -1
  65. package/lib/Sqladmin/Instance.d.ts +1 -1
  66. package/lib/Sqladmin/Instance.d.ts.map +1 -1
  67. package/lib/Sqladmin/Instance.js +28 -6
  68. package/lib/Sqladmin/Instance.js.map +1 -1
  69. package/lib/Sqladmin/User.d.ts +1 -1
  70. package/lib/Tags.d.ts +4 -4
  71. package/lib/index.d.ts +1 -0
  72. package/lib/index.d.ts.map +1 -1
  73. package/lib/index.js +1 -0
  74. package/lib/index.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/ArtifactRegistry/Repository.ts +13 -9
  77. package/src/Auth/Credentials.ts +15 -4
  78. package/src/Compute/Address.ts +288 -0
  79. package/src/Compute/Firewall.ts +330 -0
  80. package/src/Compute/ForwardingRule.ts +301 -0
  81. package/src/Compute/index.ts +3 -0
  82. package/src/Container/Cluster.ts +25 -2
  83. package/src/Container/NodePool.ts +87 -13
  84. package/src/Kubernetes/Secret.ts +274 -0
  85. package/src/Kubernetes/client.ts +219 -0
  86. package/src/Kubernetes/index.ts +1 -0
  87. package/src/ManagedLustre/Instance.ts +35 -6
  88. package/src/Providers.ts +18 -0
  89. package/src/Sqladmin/Instance.ts +36 -7
  90. package/src/index.ts +1 -0
@@ -113,8 +113,31 @@ export type NodePoolProps = {
113
113
  enableSecureBoot?: boolean;
114
114
  enableIntegrityMonitoring?: boolean;
115
115
  };
116
- /** Local SSDs. **Replace-only**. */
116
+ /**
117
+ * Local SSDs via the legacy SCSI-era count field. Prefer
118
+ * `localNvmeSsdBlockConfig` / `ephemeralStorageLocalSsdConfig` for
119
+ * NVMe-only machine families (Gen3+, Titanium SSD). Mutually
120
+ * exclusive with both. **Replace-only**.
121
+ */
117
122
  localSsdCount?: number;
123
+ /**
124
+ * Raw-block local NVMe SSDs — exposed to workloads as unformatted
125
+ * block devices (local PVs / your own RAID). For machine types with
126
+ * a fixed disk count (e.g. g4-standard-48 → 4), `localSsdCount`
127
+ * must equal that count or be 0/unset for the machine default.
128
+ * **Replace-only**.
129
+ */
130
+ localNvmeSsdBlockConfig?: { localSsdCount?: number };
131
+ /**
132
+ * Local NVMe SSDs backing node ephemeral storage (emptyDir, image
133
+ * layers, logs) — GKE RAID0s the disks. Same fixed-count rule as
134
+ * `localNvmeSsdBlockConfig`. `dataCacheCount` carves disks out for
135
+ * GKE Data Cache. **Replace-only**.
136
+ */
137
+ ephemeralStorageLocalSsdConfig?: {
138
+ localSsdCount?: number;
139
+ dataCacheCount?: number;
140
+ };
118
141
  /** Workload Identity metadata. Mutable via `update`. */
119
142
  workloadMetadataConfig?: { mode: "GKE_METADATA" | "GCE_METADATA" };
120
143
  /** GVNIC. Mutable via `update`. */
@@ -261,6 +284,12 @@ export const toNodeConfigCreateBody = (
261
284
  ...(config.localSsdCount !== undefined
262
285
  ? { localSsdCount: config.localSsdCount }
263
286
  : {}),
287
+ ...(config.localNvmeSsdBlockConfig
288
+ ? { localNvmeSsdBlockConfig: config.localNvmeSsdBlockConfig }
289
+ : {}),
290
+ ...(config.ephemeralStorageLocalSsdConfig
291
+ ? { ephemeralStorageLocalSsdConfig: config.ephemeralStorageLocalSsdConfig }
292
+ : {}),
264
293
  ...(config.workloadMetadataConfig
265
294
  ? { workloadMetadataConfig: config.workloadMetadataConfig }
266
295
  : {}),
@@ -320,8 +349,23 @@ const toNodePoolUpdateBody = (
320
349
  if (nc.taints && !deepEqual(oc.taints ?? [], nc.taints)) {
321
350
  body.taints = { taints: nc.taints };
322
351
  }
323
- if (!deepEqual(oc.resourceLabels ?? {}, desiredResourceLabels)) {
324
- body.resourceLabels = { labels: desiredResourceLabels };
352
+ // GKE injects its own `goog-*` resource labels (accelerator type,
353
+ // provisioning model, …) after create. Treat them as server-managed:
354
+ // fold the observed ones into the desired set before diffing, or
355
+ // every reconcile issues a labels update — which on a large pool is
356
+ // a long rolling cluster operation that wedges all other mutations
357
+ // on the cluster ("incompatible operation").
358
+ const observedResourceLabels = oc.resourceLabels ?? {};
359
+ const desiredWithServerManaged = {
360
+ ...Object.fromEntries(
361
+ Object.entries(observedResourceLabels).filter(([k]) =>
362
+ k.startsWith("goog-"),
363
+ ),
364
+ ),
365
+ ...desiredResourceLabels,
366
+ };
367
+ if (!deepEqual(observedResourceLabels, desiredWithServerManaged)) {
368
+ body.resourceLabels = { labels: desiredWithServerManaged };
325
369
  }
326
370
 
327
371
  if (nc.gvnic && !deepEqual(oc.gvnic, nc.gvnic)) {
@@ -365,6 +409,7 @@ export const NodePoolProvider = () =>
365
409
  NodePool,
366
410
  Effect.gen(function* () {
367
411
  const getNodePools = yield* cont.getProjectsLocationsClustersNodePools;
412
+ const listNodePools = yield* cont.listProjectsLocationsClustersNodePools;
368
413
  const createNodePools = yield* cont.createProjectsLocationsClustersNodePools;
369
414
  const updateNodePools = yield* cont.updateProjectsLocationsClustersNodePools;
370
415
  const setSize = yield* cont.setSizeProjectsLocationsClustersNodePools;
@@ -500,7 +545,12 @@ export const NodePoolProvider = () =>
500
545
  !deepEqual(oc.metadata, nc.metadata) ||
501
546
  !deepEqual(oc.sandboxConfig, nc.sandboxConfig) ||
502
547
  !deepEqual(oc.reservationAffinity, nc.reservationAffinity) ||
503
- !deepEqual(oc.shieldedInstanceConfig, nc.shieldedInstanceConfig)
548
+ !deepEqual(oc.shieldedInstanceConfig, nc.shieldedInstanceConfig) ||
549
+ !deepEqual(oc.localNvmeSsdBlockConfig, nc.localNvmeSsdBlockConfig) ||
550
+ !deepEqual(
551
+ oc.ephemeralStorageLocalSsdConfig,
552
+ nc.ephemeralStorageLocalSsdConfig,
553
+ )
504
554
  ) {
505
555
  return { action: "replace" } as const;
506
556
  }
@@ -596,15 +646,39 @@ export const NodePoolProvider = () =>
596
646
  const location = output?.location ?? olds?.location;
597
647
  const clusterName = output?.clusterName ?? olds?.clusterName;
598
648
  if (!project || !location || !clusterName) return undefined;
599
- const name =
600
- output?.name ??
601
- olds?.name ??
602
- (yield* createPhysicalName({ id, maxLength: 40 })).toLowerCase();
603
- const fqName = `projects/${project}/locations/${location}/clusters/${clusterName}/nodePools/${name}`;
604
- const observed = yield* getNodePools({ name: fqName }).pipe(
605
- Effect.catchTag("NotFound", () => Effect.succeed(undefined as cont.NodePool | undefined)),
606
- Effect.catchTag("Forbidden", () => Effect.succeed(undefined as cont.NodePool | undefined)),
607
- );
649
+ const name = output?.name ?? olds?.name;
650
+ let observed: cont.NodePool | undefined;
651
+ if (name) {
652
+ const fqName = `projects/${project}/locations/${location}/clusters/${clusterName}/nodePools/${name}`;
653
+ observed = yield* getNodePools({ name: fqName }).pipe(
654
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined as cont.NodePool | undefined)),
655
+ Effect.catchTag("Forbidden", () => Effect.succeed(undefined as cont.NodePool | undefined)),
656
+ );
657
+ } else {
658
+ // Cold recovery (lost state): the pool name truncates the
659
+ // logical id and appends a random per-instance suffix that
660
+ // lived only in state, so a probe against a freshly-generated
661
+ // name can never hit. Scan the cluster's pools for our
662
+ // alchemy labels (stamped into config.resourceLabels).
663
+ const page = yield* listNodePools({
664
+ parent: `projects/${project}/locations/${location}/clusters/${clusterName}`,
665
+ }).pipe(
666
+ Effect.catchTag("NotFound", () =>
667
+ Effect.succeed(undefined as cont.ListNodePoolsResponse | undefined),
668
+ ),
669
+ Effect.catchTag("Forbidden", () =>
670
+ Effect.succeed(undefined as cont.ListNodePoolsResponse | undefined),
671
+ ),
672
+ );
673
+ for (const candidate of page?.nodePools ?? []) {
674
+ if (
675
+ yield* hasAlchemyLabels(id, candidate.config?.resourceLabels)
676
+ ) {
677
+ observed = candidate;
678
+ break;
679
+ }
680
+ }
681
+ }
608
682
  if (!observed) return undefined;
609
683
  const attrs = toNodePoolAttributes(observed, {
610
684
  project,
@@ -0,0 +1,274 @@
1
+ import { Credentials } from "@distilled.cloud/gcp";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import { isResolved, somePropsAreDifferent } from "alchemy/Diff";
5
+ import { createPhysicalName } from "alchemy/PhysicalName";
6
+ import * as Provider from "alchemy/Provider";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Redacted from "effect/Redacted";
9
+ import type * as GCP from "../Providers.ts";
10
+ import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
11
+ import {
12
+ applySecret,
13
+ deleteSecret,
14
+ getSecret,
15
+ KubernetesApiError,
16
+ type GkeConnection,
17
+ type SecretObject,
18
+ } from "./client.ts";
19
+
20
+ /**
21
+ * An Opaque Kubernetes Secret in a GKE cluster.
22
+ *
23
+ * Unlike the rest of the GCP provider (which calls typed
24
+ * `@distilled.cloud/gcp` operations), this resource talks directly to a
25
+ * GKE cluster's Kubernetes API server — alchemy's upstream Kubernetes
26
+ * provider is EKS-only. The control-plane connection (`endpoint` +
27
+ * `caCertificate`) comes from a {@link GCP.Cluster}'s attributes; the
28
+ * bearer token is minted from the provider's ADC {@link Credentials}.
29
+ *
30
+ * Ownership for adoption is gated on alchemy-internal `metadata.labels`
31
+ * (same triple as labelled GCP resources), so a Secret created out of
32
+ * band reads back as {@link Unowned} until adopted.
33
+ *
34
+ * @section Creating a Secret
35
+ * @example Wire a Cloudflare tunnel token into the cluster
36
+ * ```typescript
37
+ * // `tunnel.token` is a `Redacted<string>`; pass it straight through —
38
+ * // the value is unwrapped only at the moment it's written to the API,
39
+ * // and stays redacted in logs/plan output.
40
+ * yield* GCP.KubernetesSecret("CloudflaredTunnelSecret", {
41
+ * endpoint: cluster.endpoint,
42
+ * caCertificate: cluster.clusterCaCertificate,
43
+ * namespace: "admin",
44
+ * name: "cloudflared-tunnel",
45
+ * stringData: { TUNNEL_TOKEN: tunnel.token },
46
+ * }).pipe(adopt(true)); // take over a secret previously applied by kubectl
47
+ * ```
48
+ */
49
+ export type KubernetesSecretProps = {
50
+ /**
51
+ * GKE control-plane endpoint (IP or hostname, no scheme) — typically
52
+ * `cluster.endpoint`. Mutable (a cluster replace yields a new one).
53
+ */
54
+ endpoint: string;
55
+ /**
56
+ * Base64-encoded cluster CA certificate (PEM) — typically
57
+ * `cluster.clusterCaCertificate`. Mutable.
58
+ */
59
+ caCertificate: string;
60
+ /** Target namespace. Immutable — replace if changed. */
61
+ namespace: string;
62
+ /**
63
+ * Secret name. Defaults to `createPhysicalName({ id, lowercase: true,
64
+ * maxLength: 63 })`. Immutable — replace if changed.
65
+ */
66
+ name?: string;
67
+ /** Secret `type`. Default `"Opaque"`. Mutable. */
68
+ type?: string;
69
+ /**
70
+ * String data (written under `stringData`; UTF-8 values). Mutable.
71
+ *
72
+ * Values may be `Redacted<string>` (e.g. a resource's secret output
73
+ * like `tunnel.token`) — they are unwrapped only when written to the
74
+ * Kubernetes API and stay opaque in logs/plan output. Plain strings
75
+ * are accepted too.
76
+ *
77
+ * NOTE: the underlying values are persisted in alchemy stack state
78
+ * (the engine diffs on the real value) — protect the state backend.
79
+ */
80
+ stringData: Record<string, Redacted.Redacted<string> | string>;
81
+ /** Extra metadata labels (alchemy ownership labels merge on top). Mutable. */
82
+ labels?: Record<string, string>;
83
+ };
84
+
85
+ export type KubernetesSecretAttributes = {
86
+ /** Secret name. */
87
+ name: string;
88
+ /** Namespace. */
89
+ namespace: string;
90
+ /** Control-plane endpoint, threaded through for delete/read. */
91
+ endpoint: string;
92
+ /** Cluster CA certificate, threaded through for delete/read. */
93
+ caCertificate: string;
94
+ /** Secret type. */
95
+ type: string;
96
+ /** Server-assigned uid. */
97
+ uid: string | undefined;
98
+ /** Server-assigned resourceVersion at last apply. */
99
+ resourceVersion: string | undefined;
100
+ };
101
+
102
+ export type KubernetesSecret = Resource<
103
+ "GCP.KubernetesSecret",
104
+ KubernetesSecretProps,
105
+ KubernetesSecretAttributes,
106
+ never,
107
+ GCP.Providers
108
+ >;
109
+ export const KubernetesSecret = Resource<KubernetesSecret>(
110
+ "GCP.KubernetesSecret",
111
+ );
112
+
113
+ const toAttributes = (
114
+ s: SecretObject | undefined,
115
+ parent: {
116
+ name: string;
117
+ namespace: string;
118
+ endpoint: string;
119
+ caCertificate: string;
120
+ type: string;
121
+ },
122
+ ): KubernetesSecretAttributes => ({
123
+ name: s?.metadata?.name ?? parent.name,
124
+ namespace: s?.metadata?.namespace ?? parent.namespace,
125
+ endpoint: parent.endpoint,
126
+ caCertificate: parent.caCertificate,
127
+ type: s?.type ?? parent.type,
128
+ uid: s?.metadata?.uid,
129
+ resourceVersion: s?.metadata?.resourceVersion,
130
+ });
131
+
132
+ /** Mint a fresh GKE connection from props + ADC credentials. */
133
+ const connect = Effect.fn("k8sSecret.connect")(function* (props: {
134
+ endpoint: string;
135
+ caCertificate: string;
136
+ }) {
137
+ const { accessToken } = yield* yield* Credentials;
138
+ return {
139
+ endpoint: props.endpoint,
140
+ caCertificate: props.caCertificate,
141
+ token: Redacted.value(accessToken),
142
+ } satisfies GkeConnection;
143
+ });
144
+
145
+ const observe = (connection: GkeConnection, namespace: string, name: string) =>
146
+ getSecret(connection, namespace, name).pipe(
147
+ Effect.catchIf(
148
+ (e): e is KubernetesApiError =>
149
+ e instanceof KubernetesApiError && e.statusCode === 404,
150
+ () => Effect.succeed(undefined as SecretObject | undefined),
151
+ ),
152
+ );
153
+
154
+ export const KubernetesSecretProvider = () =>
155
+ Provider.effect(
156
+ KubernetesSecret,
157
+ Effect.gen(function* () {
158
+ return {
159
+ // Attributes unchanged by an in-place update (so dependents can
160
+ // resolve them at plan time): identity (name/namespace), the
161
+ // server-assigned uid, and endpoint — which is a replace trigger,
162
+ // so it never changes on update. caCertificate is intentionally
163
+ // excluded: it can rotate on the same cluster.
164
+ stables: ["name", "namespace", "endpoint", "uid"],
165
+ diff: Effect.fn(function* ({ id, news, olds = {} }) {
166
+ if (!isResolved(news)) return undefined;
167
+ const o = olds as KubernetesSecretProps;
168
+ // Compare the RESOLVED name and type (with their defaults
169
+ // applied), not the raw props — otherwise omitting `name` on one
170
+ // side and setting it to the generated physical name on the
171
+ // other (or the same for `type`/"Opaque") triggers a spurious
172
+ // replace.
173
+ const defaultName = (
174
+ yield* createPhysicalName({ id, maxLength: 63 })
175
+ ).toLowerCase();
176
+ const sameName = (o.name ?? defaultName) === (news.name ?? defaultName);
177
+ const sameType = (o.type ?? "Opaque") === (news.type ?? "Opaque");
178
+ // Replace (create new + delete old) rather than in-place SSA on:
179
+ // - `endpoint`: points at a different cluster (would orphan the
180
+ // old Secret). caCertificate is excluded — it can rotate on
181
+ // the same cluster and should just re-apply.
182
+ // - `namespace`/`name`: the object's identity.
183
+ // - `type`: immutable on a Kubernetes Secret; an in-place apply
184
+ // with a new type is rejected by the API.
185
+ if (
186
+ !sameName ||
187
+ !sameType ||
188
+ somePropsAreDifferent(o, news, ["namespace", "endpoint"])
189
+ ) {
190
+ return { action: "replace" } as const;
191
+ }
192
+ return undefined;
193
+ }),
194
+ reconcile: Effect.fn(function* ({ id, news }) {
195
+ const desiredName =
196
+ news.name ??
197
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
198
+ const desiredType = news.type ?? "Opaque";
199
+ const labels = {
200
+ ...(news.labels ?? {}),
201
+ ...(yield* gcpInternalLabels(id)),
202
+ };
203
+ const connection = yield* connect(news);
204
+
205
+ // Unwrap any Redacted values at the last moment — Redacted is
206
+ // kept opaque through Output resolution, so it arrives here as
207
+ // a Redacted object that would JSON-serialize to "<redacted>"
208
+ // if passed through verbatim — then base64-encode for `data`.
209
+ const data: Record<string, string> = {};
210
+ for (const [k, v] of Object.entries(news.stringData)) {
211
+ const raw = Redacted.isRedacted(v) ? Redacted.value(v) : v;
212
+ data[k] = Buffer.from(raw, "utf8").toString("base64");
213
+ }
214
+
215
+ // Server-side apply is an idempotent upsert — it both creates
216
+ // the Secret if missing and converges its data/labels if it
217
+ // exists, taking field-manager ownership (force=true).
218
+ const applied = yield* applySecret(connection, {
219
+ metadata: { name: desiredName, namespace: news.namespace, labels },
220
+ type: desiredType,
221
+ data,
222
+ });
223
+
224
+ // A 2xx apply normally echoes the object, but a successful
225
+ // empty-body response is possible — re-read so we always return
226
+ // real attributes (uid/resourceVersion) rather than crash. Use
227
+ // getSecret (NOT observe): if the Secret is somehow absent after
228
+ // a successful apply, propagate the 404 and fail the reconcile
229
+ // instead of recording an empty success.
230
+ const final = applied?.metadata
231
+ ? applied
232
+ : yield* getSecret(connection, news.namespace, desiredName);
233
+
234
+ return toAttributes(final, {
235
+ name: desiredName,
236
+ namespace: news.namespace,
237
+ endpoint: news.endpoint,
238
+ caCertificate: news.caCertificate,
239
+ type: desiredType,
240
+ });
241
+ }),
242
+ delete: Effect.fn(function* ({ output }) {
243
+ const connection = yield* connect(output);
244
+ yield* deleteSecret(connection, output.namespace, output.name);
245
+ }),
246
+ read: Effect.fn(function* ({ id, output, olds }) {
247
+ const endpoint = output?.endpoint ?? olds?.endpoint;
248
+ const caCertificate = output?.caCertificate ?? olds?.caCertificate;
249
+ const namespace = output?.namespace ?? olds?.namespace;
250
+ if (!endpoint || !caCertificate || !namespace) return undefined;
251
+ const name =
252
+ output?.name ??
253
+ olds?.name ??
254
+ (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
255
+ const type = output?.type ?? olds?.type ?? "Opaque";
256
+
257
+ const connection = yield* connect({ endpoint, caCertificate });
258
+ const observed = yield* observe(connection, namespace, name);
259
+ if (!observed) return undefined;
260
+
261
+ const attrs = toAttributes(observed, {
262
+ name,
263
+ namespace,
264
+ endpoint,
265
+ caCertificate,
266
+ type,
267
+ });
268
+ return (yield* hasAlchemyLabels(id, observed.metadata?.labels))
269
+ ? attrs
270
+ : Unowned(attrs);
271
+ }),
272
+ };
273
+ }),
274
+ );
@@ -0,0 +1,219 @@
1
+ import * as Data from "effect/Data";
2
+ import * as Effect from "effect/Effect";
3
+ import * as https from "node:https";
4
+
5
+ /**
6
+ * Minimal GKE Kubernetes REST client.
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.
12
+ *
13
+ * `node:https` is used deliberately here (rather than the Effect
14
+ * `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.)
22
+ */
23
+ const REQUEST_TIMEOUT_MS = 30_000;
24
+
25
+ /** Connection details for a single GKE control plane. */
26
+ export interface GkeConnection {
27
+ /** Master endpoint — IP or hostname, no scheme (e.g. `34.1.2.3`). */
28
+ endpoint: string;
29
+ /** Base64-encoded cluster CA certificate (PEM), as GKE returns it. */
30
+ caCertificate: string;
31
+ /** Google OAuth access token (cloud-platform scope) for `Authorization`. */
32
+ token: string;
33
+ }
34
+
35
+ /** A non-2xx (or transport) response from the Kubernetes API server. */
36
+ export class KubernetesApiError extends Data.TaggedError(
37
+ "KubernetesApiError",
38
+ )<{
39
+ method: string;
40
+ path: string;
41
+ statusCode: number;
42
+ body: string;
43
+ }> {}
44
+
45
+ const requestJson = Effect.fn("k8s.requestJson")(function* ({
46
+ connection,
47
+ method,
48
+ path,
49
+ body,
50
+ contentType,
51
+ }: {
52
+ connection: GkeConnection;
53
+ method: string;
54
+ path: string;
55
+ body?: Record<string, unknown>;
56
+ contentType?: string;
57
+ }) {
58
+ const url = new URL(path, `https://${connection.endpoint}`);
59
+ const payload = body ? JSON.stringify(body) : undefined;
60
+ const ca = Buffer.from(connection.caCertificate, "base64").toString("utf8");
61
+
62
+ return yield* Effect.tryPromise({
63
+ try: () =>
64
+ new Promise<unknown>((resolve, reject) => {
65
+ const request = https.request(
66
+ {
67
+ protocol: url.protocol,
68
+ hostname: url.hostname,
69
+ port: url.port || 443,
70
+ path: `${url.pathname}${url.search}`,
71
+ method,
72
+ headers: {
73
+ Authorization: `Bearer ${connection.token}`,
74
+ Accept: "application/json",
75
+ ...(payload
76
+ ? {
77
+ "Content-Type":
78
+ contentType ?? "application/json",
79
+ "Content-Length": Buffer.byteLength(payload),
80
+ }
81
+ : {}),
82
+ },
83
+ ca,
84
+ timeout: REQUEST_TIMEOUT_MS,
85
+ },
86
+ (response) => {
87
+ const chunks: Buffer[] = [];
88
+ response.on("data", (chunk) => {
89
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
90
+ });
91
+ response.on("end", () => {
92
+ const responseBody = Buffer.concat(chunks).toString("utf8");
93
+ const statusCode = response.statusCode ?? 500;
94
+
95
+ if (statusCode < 200 || statusCode >= 300) {
96
+ reject(
97
+ new KubernetesApiError({
98
+ method,
99
+ path,
100
+ statusCode,
101
+ body: responseBody,
102
+ }),
103
+ );
104
+ return;
105
+ }
106
+
107
+ if (!responseBody.trim()) {
108
+ resolve(undefined);
109
+ return;
110
+ }
111
+
112
+ try {
113
+ resolve(JSON.parse(responseBody));
114
+ } catch {
115
+ resolve(responseBody);
116
+ }
117
+ });
118
+ },
119
+ );
120
+
121
+ request.on("error", reject);
122
+ // `timeout` only fires the event — we must destroy the socket
123
+ // ourselves so the promise rejects instead of hanging.
124
+ request.on("timeout", () =>
125
+ request.destroy(
126
+ new KubernetesApiError({
127
+ method,
128
+ path,
129
+ statusCode: 0,
130
+ body: `request timed out after ${REQUEST_TIMEOUT_MS}ms`,
131
+ }),
132
+ ),
133
+ );
134
+ if (payload) request.write(payload);
135
+ request.end();
136
+ }),
137
+ catch: (error) =>
138
+ error instanceof KubernetesApiError
139
+ ? error
140
+ : new KubernetesApiError({
141
+ method,
142
+ path,
143
+ statusCode: 0,
144
+ body: error instanceof Error ? error.message : String(error),
145
+ }),
146
+ });
147
+ });
148
+
149
+ /** Live view of a Kubernetes Secret object (subset we care about). */
150
+ export interface SecretObject {
151
+ apiVersion?: string;
152
+ kind?: string;
153
+ metadata?: {
154
+ name?: string;
155
+ namespace?: string;
156
+ uid?: string;
157
+ resourceVersion?: string;
158
+ labels?: Record<string, string>;
159
+ };
160
+ type?: string;
161
+ }
162
+
163
+ const secretPath = (namespace: string, name: string) =>
164
+ `/api/v1/namespaces/${namespace}/secrets/${name}`;
165
+
166
+ /** GET a Secret; the caller maps `KubernetesApiError(404)` to "missing". */
167
+ export const getSecret = (
168
+ 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>;
177
+
178
+ /**
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.
186
+ */
187
+ export const applySecret = (
188
+ 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 = (
205
+ connection: GkeConnection,
206
+ namespace: string,
207
+ name: string,
208
+ ) =>
209
+ requestJson({
210
+ connection,
211
+ method: "DELETE",
212
+ path: secretPath(namespace, name),
213
+ }).pipe(
214
+ Effect.catchIf(
215
+ (e): e is KubernetesApiError =>
216
+ e instanceof KubernetesApiError && e.statusCode === 404,
217
+ () => Effect.void,
218
+ ),
219
+ );
@@ -0,0 +1 @@
1
+ export * from "./Secret.ts";