@microagi/alchemy-gcp 0.1.2

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 (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +88 -0
  3. package/lib/Auth/AuthProvider.d.ts +48 -0
  4. package/lib/Auth/AuthProvider.d.ts.map +1 -0
  5. package/lib/Auth/AuthProvider.js +84 -0
  6. package/lib/Auth/AuthProvider.js.map +1 -0
  7. package/lib/Auth/Credentials.d.ts +28 -0
  8. package/lib/Auth/Credentials.d.ts.map +1 -0
  9. package/lib/Auth/Credentials.js +41 -0
  10. package/lib/Auth/Credentials.js.map +1 -0
  11. package/lib/CloudResourceManager/Project.d.ts +143 -0
  12. package/lib/CloudResourceManager/Project.d.ts.map +1 -0
  13. package/lib/CloudResourceManager/Project.js +272 -0
  14. package/lib/CloudResourceManager/Project.js.map +1 -0
  15. package/lib/CloudResourceManager/index.d.ts +3 -0
  16. package/lib/CloudResourceManager/index.d.ts.map +1 -0
  17. package/lib/CloudResourceManager/index.js +2 -0
  18. package/lib/CloudResourceManager/index.js.map +1 -0
  19. package/lib/Compute/GlobalAddress.d.ts +101 -0
  20. package/lib/Compute/GlobalAddress.d.ts.map +1 -0
  21. package/lib/Compute/GlobalAddress.js +134 -0
  22. package/lib/Compute/GlobalAddress.js.map +1 -0
  23. package/lib/Compute/Network.d.ts +114 -0
  24. package/lib/Compute/Network.d.ts.map +1 -0
  25. package/lib/Compute/Network.js +145 -0
  26. package/lib/Compute/Network.js.map +1 -0
  27. package/lib/Compute/Operations.d.ts +34 -0
  28. package/lib/Compute/Operations.d.ts.map +1 -0
  29. package/lib/Compute/Operations.js +62 -0
  30. package/lib/Compute/Operations.js.map +1 -0
  31. package/lib/Compute/SharedVpcHost.d.ts +44 -0
  32. package/lib/Compute/SharedVpcHost.d.ts.map +1 -0
  33. package/lib/Compute/SharedVpcHost.js +71 -0
  34. package/lib/Compute/SharedVpcHost.js.map +1 -0
  35. package/lib/Compute/SharedVpcServiceProject.d.ts +46 -0
  36. package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -0
  37. package/lib/Compute/SharedVpcServiceProject.js +96 -0
  38. package/lib/Compute/SharedVpcServiceProject.js.map +1 -0
  39. package/lib/Compute/Subnetwork.d.ts +108 -0
  40. package/lib/Compute/Subnetwork.d.ts.map +1 -0
  41. package/lib/Compute/Subnetwork.js +201 -0
  42. package/lib/Compute/Subnetwork.js.map +1 -0
  43. package/lib/Compute/index.d.ts +6 -0
  44. package/lib/Compute/index.d.ts.map +1 -0
  45. package/lib/Compute/index.js +6 -0
  46. package/lib/Compute/index.js.map +1 -0
  47. package/lib/Container/Cluster.d.ts +216 -0
  48. package/lib/Container/Cluster.d.ts.map +1 -0
  49. package/lib/Container/Cluster.js +326 -0
  50. package/lib/Container/Cluster.js.map +1 -0
  51. package/lib/Container/NodePool.d.ts +214 -0
  52. package/lib/Container/NodePool.d.ts.map +1 -0
  53. package/lib/Container/NodePool.js +344 -0
  54. package/lib/Container/NodePool.js.map +1 -0
  55. package/lib/Container/Operations.d.ts +50 -0
  56. package/lib/Container/Operations.d.ts.map +1 -0
  57. package/lib/Container/Operations.js +51 -0
  58. package/lib/Container/Operations.js.map +1 -0
  59. package/lib/Container/index.d.ts +5 -0
  60. package/lib/Container/index.d.ts.map +1 -0
  61. package/lib/Container/index.js +3 -0
  62. package/lib/Container/index.js.map +1 -0
  63. package/lib/Parallelstore/Instance.d.ts +119 -0
  64. package/lib/Parallelstore/Instance.d.ts.map +1 -0
  65. package/lib/Parallelstore/Instance.js +180 -0
  66. package/lib/Parallelstore/Instance.js.map +1 -0
  67. package/lib/Parallelstore/index.d.ts +2 -0
  68. package/lib/Parallelstore/index.d.ts.map +1 -0
  69. package/lib/Parallelstore/index.js +2 -0
  70. package/lib/Parallelstore/index.js.map +1 -0
  71. package/lib/Providers.d.ts +24 -0
  72. package/lib/Providers.d.ts.map +1 -0
  73. package/lib/Providers.js +51 -0
  74. package/lib/Providers.js.map +1 -0
  75. package/lib/ServiceNetworking/PsaConnection.d.ts +84 -0
  76. package/lib/ServiceNetworking/PsaConnection.d.ts.map +1 -0
  77. package/lib/ServiceNetworking/PsaConnection.js +130 -0
  78. package/lib/ServiceNetworking/PsaConnection.js.map +1 -0
  79. package/lib/ServiceNetworking/index.d.ts +2 -0
  80. package/lib/ServiceNetworking/index.d.ts.map +1 -0
  81. package/lib/ServiceNetworking/index.js +2 -0
  82. package/lib/ServiceNetworking/index.js.map +1 -0
  83. package/lib/ServiceUsage/ApiEnable.d.ts +78 -0
  84. package/lib/ServiceUsage/ApiEnable.d.ts.map +1 -0
  85. package/lib/ServiceUsage/ApiEnable.js +132 -0
  86. package/lib/ServiceUsage/ApiEnable.js.map +1 -0
  87. package/lib/ServiceUsage/index.d.ts +3 -0
  88. package/lib/ServiceUsage/index.d.ts.map +1 -0
  89. package/lib/ServiceUsage/index.js +2 -0
  90. package/lib/ServiceUsage/index.js.map +1 -0
  91. package/lib/Tags.d.ts +57 -0
  92. package/lib/Tags.d.ts.map +1 -0
  93. package/lib/Tags.js +106 -0
  94. package/lib/Tags.js.map +1 -0
  95. package/lib/index.d.ts +11 -0
  96. package/lib/index.d.ts.map +1 -0
  97. package/lib/index.js +10 -0
  98. package/lib/index.js.map +1 -0
  99. package/package.json +70 -0
  100. package/src/Auth/AuthProvider.ts +180 -0
  101. package/src/Auth/Credentials.ts +69 -0
  102. package/src/CloudResourceManager/Project.ts +517 -0
  103. package/src/CloudResourceManager/index.ts +2 -0
  104. package/src/Compute/GlobalAddress.ts +283 -0
  105. package/src/Compute/Network.ts +306 -0
  106. package/src/Compute/Operations.ts +108 -0
  107. package/src/Compute/SharedVpcHost.ts +141 -0
  108. package/src/Compute/SharedVpcServiceProject.ts +185 -0
  109. package/src/Compute/Subnetwork.ts +375 -0
  110. package/src/Compute/index.ts +5 -0
  111. package/src/Container/Cluster.ts +599 -0
  112. package/src/Container/NodePool.ts +620 -0
  113. package/src/Container/Operations.ts +88 -0
  114. package/src/Container/index.ts +8 -0
  115. package/src/Parallelstore/Instance.ts +371 -0
  116. package/src/Parallelstore/index.ts +1 -0
  117. package/src/Providers.ts +84 -0
  118. package/src/ServiceNetworking/PsaConnection.ts +269 -0
  119. package/src/ServiceNetworking/index.ts +1 -0
  120. package/src/ServiceUsage/ApiEnable.ts +267 -0
  121. package/src/ServiceUsage/index.ts +2 -0
  122. package/src/Tags.ts +130 -0
  123. package/src/index.ts +16 -0
@@ -0,0 +1,5 @@
1
+ export * from "./GlobalAddress.ts";
2
+ export * from "./Network.ts";
3
+ export * from "./SharedVpcHost.ts";
4
+ export * from "./SharedVpcServiceProject.ts";
5
+ export * from "./Subnetwork.ts";
@@ -0,0 +1,599 @@
1
+ import * as cont from "@distilled.cloud/gcp/container-v1";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
+ import { deepEqual, isResolved, somePropsAreDifferent } from "alchemy/Diff";
6
+ import { createPhysicalName } from "alchemy/PhysicalName";
7
+ import * as Provider from "alchemy/Provider";
8
+ import * as Duration from "effect/Duration";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Schedule from "effect/Schedule";
11
+ import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
12
+ import type * as GCP from "../Providers.ts";
13
+ import { type NodePoolProps, toNodeConfigCreateBody } from "./NodePool.ts";
14
+ import { makeAwaitOperation, qualifyOperationName } from "./Operations.ts";
15
+
16
+ /**
17
+ * Spec for the bootstrap node pool created inline with the cluster.
18
+ *
19
+ * Same shape as the create-time subset of `NodePoolProps`, minus the
20
+ * parent-identity fields (`project`, `location`, `clusterName`), which
21
+ * are inherited from the cluster.
22
+ */
23
+ export type ClusterInitialNodePool = {
24
+ /** Pool name. Required — no default. Lowercase alphanumeric+hyphens, ≤40 chars. */
25
+ name: string;
26
+ /** Initial node count. Required — cluster needs nonzero capacity at create. */
27
+ initialNodeCount: number;
28
+ /** Per-pool zones. Defaults to the cluster's zone/region. */
29
+ nodeLocations?: ReadonlyArray<string>;
30
+ /** Node configuration (machine type, disk, accelerators, taints, …). */
31
+ config: NodePoolProps["config"];
32
+ /** Cluster autoscaler config. */
33
+ autoscaling?: NodePoolProps["autoscaling"];
34
+ /** Auto-upgrade / auto-repair. */
35
+ management?: NodePoolProps["management"];
36
+ /** Surge upgrade settings. */
37
+ upgradeSettings?: NodePoolProps["upgradeSettings"];
38
+ };
39
+
40
+ export type ClusterProps = {
41
+ /** GCP project ID hosting the cluster. Immutable — replace if changed. */
42
+ project: string;
43
+ /**
44
+ * GCP zone (e.g. `us-central1-a`) for a zonal cluster, or region
45
+ * (e.g. `us-central1`) for a regional cluster. Immutable — replace.
46
+ */
47
+ location: string;
48
+ /**
49
+ * Cluster name. Defaults to `createPhysicalName({ id, lowercase: true,
50
+ * maxLength: 40 })`. Lowercase alphanumeric + hyphens, ≤40 chars.
51
+ * Immutable — replace if changed.
52
+ */
53
+ name?: string;
54
+ /**
55
+ * Additional zones for cluster nodes. Mutable via `setLocations`.
56
+ */
57
+ nodeLocations?: ReadonlyArray<string>;
58
+ /**
59
+ * VPC short name or self-link. Omit to use the project's `default`
60
+ * VPC. Immutable — replace if changed.
61
+ */
62
+ network?: string;
63
+ /** Subnet within the VPC. Immutable — replace if changed. */
64
+ subnetwork?: string;
65
+ /**
66
+ * VPC-native settings. `useIpAliases: true` is GKE's default for new
67
+ * clusters and the only path that supports private clusters.
68
+ * Immutable — replace if changed.
69
+ */
70
+ ipAllocationPolicy?: {
71
+ useIpAliases?: boolean;
72
+ clusterIpv4CidrBlock?: string;
73
+ servicesIpv4CidrBlock?: string;
74
+ clusterSecondaryRangeName?: string;
75
+ servicesSecondaryRangeName?: string;
76
+ };
77
+ /** GKE release channel. Mutable via `update` (`desiredReleaseChannel`). */
78
+ releaseChannel?: { channel: "RAPID" | "REGULAR" | "STABLE" | "UNSPECIFIED" };
79
+ /**
80
+ * Workload Identity binds K8s SAs to GCP SAs. Format:
81
+ * `<projectId>.svc.id.goog`. Mutable via `update`.
82
+ */
83
+ workloadIdentityConfig?: { workloadPool: string };
84
+ /** Cluster addons. Mutable via dedicated `setAddons`. */
85
+ addonsConfig?: {
86
+ httpLoadBalancing?: { disabled?: boolean };
87
+ horizontalPodAutoscaling?: { disabled?: boolean };
88
+ networkPolicyConfig?: { disabled?: boolean };
89
+ gcePersistentDiskCsiDriverConfig?: { enabled?: boolean };
90
+ gcsFuseCsiDriverConfig?: { enabled?: boolean };
91
+ gkeBackupAgentConfig?: { enabled?: boolean };
92
+ dnsCacheConfig?: { enabled?: boolean };
93
+ configConnectorConfig?: { enabled?: boolean };
94
+ };
95
+ /** Logging service. Mutable via `setLogging`. */
96
+ loggingService?: string;
97
+ /** Monitoring service. Mutable via `setMonitoring`. */
98
+ monitoringService?: string;
99
+ /** Maintenance window/exclusions. Mutable via `setMaintenancePolicy`. */
100
+ maintenancePolicy?: {
101
+ window?: {
102
+ dailyMaintenanceWindow?: { startTime: string };
103
+ recurringWindow?: {
104
+ window: { startTime: string; endTime: string };
105
+ recurrence: string;
106
+ };
107
+ };
108
+ };
109
+ /**
110
+ * Network policy enforcement (e.g. Calico). Mutable via
111
+ * `setNetworkPolicy`. Requires `networkPolicyConfig` addon.
112
+ */
113
+ networkPolicy?: {
114
+ enabled?: boolean;
115
+ provider?: "PROVIDER_UNSPECIFIED" | "CALICO";
116
+ };
117
+ /**
118
+ * Resource labels. Alchemy internal labels (`alchemy_app`,
119
+ * `alchemy_stage`, `alchemy_id`) are merged on top automatically and
120
+ * are reserved. Mutable via `setResourceLabels`.
121
+ */
122
+ resourceLabels?: Record<string, string>;
123
+ /**
124
+ * Initial cluster version (`MASTER_DEFAULT` if omitted). Subsequent
125
+ * master upgrades are not exposed in v1.
126
+ */
127
+ initialClusterVersion?: string;
128
+ /**
129
+ * Bootstrap node pool created inline with the cluster. **Required.**
130
+ * GKE rejects cluster creation without at least one pool.
131
+ *
132
+ * Owned by the cluster — its `config.resourceLabels` are stamped
133
+ * with the cluster's `alchemy_*` internal labels at create time.
134
+ * Consumed only on the create path: after the cluster exists,
135
+ * `Cluster.reconcile` does NOT observe, sync, or mutate this pool,
136
+ * and changes to `initialNodePool` props on subsequent runs are a
137
+ * silent no-op (NOT diffed, NOT a replace trigger).
138
+ *
139
+ * Don't declare a separate `GCP.NodePool` for the same physical
140
+ * pool — it would key its own `alchemy_id` against a different
141
+ * logical id and `read` would return `Unowned(...)`. If you want
142
+ * post-create management of the bootstrap pool's size/config, do
143
+ * it via gcloud or expand `Cluster.reconcile` to sync it; declare
144
+ * additional pools (`compute`, `gpu`, …) as separate
145
+ * `GCP.NodePool` resources.
146
+ */
147
+ initialNodePool: ClusterInitialNodePool;
148
+ };
149
+
150
+ export type ClusterAttributes = {
151
+ /** Cluster name. */
152
+ name: string;
153
+ /** Server-defined URL. */
154
+ selfLink: string;
155
+ /** IP of the master endpoint. */
156
+ endpoint: string;
157
+ /** Base64-encoded master CA cert. */
158
+ clusterCaCertificate: string;
159
+ /** GCP project ID, threaded through from props for `delete`/`read`. */
160
+ project: string;
161
+ /** Zone or region, threaded through from props. */
162
+ location: string;
163
+ /** Current control-plane version. */
164
+ currentMasterVersion: string | undefined;
165
+ /** Lifecycle status. */
166
+ status: string | undefined;
167
+ /** Active release channel. */
168
+ releaseChannel: { channel?: string } | undefined;
169
+ /** Resource labels currently set, including `alchemy_*` internals. */
170
+ resourceLabels: Record<string, string>;
171
+ };
172
+
173
+ /**
174
+ * A GKE Standard cluster.
175
+ *
176
+ * Lifecycle: observe → ensure (create with the user-supplied
177
+ * `initialNodePool` inline) → sync (labels, addons, maintenance,
178
+ * locations, network policy, logging, monitoring, generic update) →
179
+ * return.
180
+ *
181
+ * Adoption: a cluster whose `resourceLabels` lack our `alchemy_*` keys
182
+ * is wrapped in `Unowned(attrs)` from `read`, forcing the user to
183
+ * pass `--adopt` before takeover.
184
+ *
185
+ * @section Creating a cluster
186
+ * @example Minimal Standard cluster
187
+ * ```typescript
188
+ * const cluster = yield* GCP.Cluster("Main", {
189
+ * project: project.projectId,
190
+ * location: "us-central1-a",
191
+ * releaseChannel: { channel: "REGULAR" },
192
+ * initialNodePool: {
193
+ * name: "system",
194
+ * initialNodeCount: 1,
195
+ * config: { machineType: "e2-medium" },
196
+ * },
197
+ * });
198
+ * ```
199
+ */
200
+ export type Cluster = Resource<
201
+ "GCP.Cluster",
202
+ ClusterProps,
203
+ ClusterAttributes,
204
+ never,
205
+ GCP.Providers
206
+ >;
207
+ export const Cluster = Resource<Cluster>("GCP.Cluster");
208
+
209
+ /**
210
+ * Build the inline `NodePool` entry for the cluster create body.
211
+ *
212
+ * The cluster's alchemy internal labels (`alchemy_app`, `alchemy_stage`,
213
+ * `alchemy_id` keyed by the cluster's logical id) are merged into the
214
+ * pool's `config.resourceLabels`, marking the bootstrap pool as part
215
+ * of the cluster — not a free-standing resource that needs adoption.
216
+ * It is owned by the cluster, not by any `GCP.NodePool` resource; a
217
+ * separate `GCP.NodePool` declared with a different logical id will
218
+ * still see `Unowned(...)` from `read` (its own `alchemy_id` won't
219
+ * match), which is correct: that pool belongs to the cluster.
220
+ */
221
+ const toInitialNodePoolBody = (
222
+ spec: ClusterInitialNodePool,
223
+ clusterInternalLabels: Record<string, string>,
224
+ ): cont.NodePool => ({
225
+ name: spec.name,
226
+ initialNodeCount: spec.initialNodeCount,
227
+ ...(spec.nodeLocations ? { locations: spec.nodeLocations } : {}),
228
+ config: toNodeConfigCreateBody(spec.config, {
229
+ ...(spec.config.resourceLabels ?? {}),
230
+ ...clusterInternalLabels,
231
+ }),
232
+ ...(spec.autoscaling ? { autoscaling: spec.autoscaling } : {}),
233
+ ...(spec.management ? { management: spec.management } : {}),
234
+ ...(spec.upgradeSettings ? { upgradeSettings: spec.upgradeSettings } : {}),
235
+ });
236
+
237
+ const toClusterAttributes = (
238
+ c: cont.Cluster,
239
+ parent: { project: string; location: string },
240
+ ): ClusterAttributes => ({
241
+ name: c.name ?? "",
242
+ selfLink: c.selfLink ?? "",
243
+ endpoint: c.endpoint ?? "",
244
+ clusterCaCertificate: c.masterAuth?.clusterCaCertificate ?? "",
245
+ project: parent.project,
246
+ location: parent.location,
247
+ currentMasterVersion: c.currentMasterVersion,
248
+ status: c.status,
249
+ releaseChannel: c.releaseChannel,
250
+ resourceLabels: { ...(c.resourceLabels ?? {}) },
251
+ });
252
+
253
+ export const ClusterProvider = () =>
254
+ Provider.effect(
255
+ Cluster,
256
+ Effect.gen(function* () {
257
+ const getClusters = yield* cont.getProjectsLocationsClusters;
258
+ const createClusters = yield* cont.createProjectsLocationsClusters;
259
+ const updateClusters = yield* cont.updateProjectsLocationsClusters;
260
+ const setAddons = yield* cont.setAddonsProjectsLocationsClusters;
261
+ const setLocations = yield* cont.setLocationsProjectsLocationsClusters;
262
+ const setMaintenance = yield* cont.setMaintenancePolicyProjectsLocationsClusters;
263
+ const setNetworkPolicy = yield* cont.setNetworkPolicyProjectsLocationsClusters;
264
+ const setResourceLabels = yield* cont.setResourceLabelsProjectsLocationsClusters;
265
+ const setLogging = yield* cont.setLoggingProjectsLocationsClusters;
266
+ const setMonitoring = yield* cont.setMonitoringProjectsLocationsClusters;
267
+ const deleteClusters = yield* cont.deleteProjectsLocationsClusters;
268
+ const getOperations = yield* cont.getProjectsLocationsOperations;
269
+ const awaitOperation = makeAwaitOperation(getOperations);
270
+
271
+ // Qualify a bare operation id using the parent path of the
272
+ // resource it targets — every fqName in this provider has the
273
+ // shape `projects/{p}/locations/{l}/clusters/{c}`, so the first
274
+ // four segments give us the parent context. Avoids threading
275
+ // project/location through every sync helper's args type.
276
+ const qualifyOp = (fqName: string, opName: string) =>
277
+ qualifyOperationName(
278
+ fqName.split("/")[1],
279
+ fqName.split("/")[3],
280
+ opName,
281
+ );
282
+
283
+ const syncClusterLabels = Effect.fn(function* (args: {
284
+ fqName: string;
285
+ observed: cont.Cluster;
286
+ desired: Record<string, string>;
287
+ session: ScopedPlanStatusSession;
288
+ }) {
289
+ if (deepEqual(args.observed.resourceLabels ?? {}, args.desired)) return;
290
+ // `setResourceLabels` requires `labelFingerprint` for optimistic
291
+ // concurrency — read it fresh from the observed cluster.
292
+ const op = yield* setResourceLabels({
293
+ name: args.fqName,
294
+ body: {
295
+ resourceLabels: args.desired,
296
+ labelFingerprint: args.observed.labelFingerprint,
297
+ },
298
+ });
299
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
300
+ });
301
+
302
+ const syncClusterAddons = Effect.fn(function* (args: {
303
+ fqName: string;
304
+ observed: cont.Cluster;
305
+ news: ClusterProps;
306
+ session: ScopedPlanStatusSession;
307
+ }) {
308
+ if (!args.news.addonsConfig) return;
309
+ if (deepEqual(args.observed.addonsConfig, args.news.addonsConfig)) return;
310
+ const op = yield* setAddons({
311
+ name: args.fqName,
312
+ body: { addonsConfig: args.news.addonsConfig },
313
+ });
314
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
315
+ });
316
+
317
+ const syncClusterMaintenance = Effect.fn(function* (args: {
318
+ fqName: string;
319
+ observed: cont.Cluster;
320
+ news: ClusterProps;
321
+ session: ScopedPlanStatusSession;
322
+ }) {
323
+ if (!args.news.maintenancePolicy) return;
324
+ if (deepEqual(args.observed.maintenancePolicy, args.news.maintenancePolicy)) return;
325
+ const op = yield* setMaintenance({
326
+ name: args.fqName,
327
+ body: { maintenancePolicy: args.news.maintenancePolicy },
328
+ });
329
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
330
+ });
331
+
332
+ const syncClusterLocations = Effect.fn(function* (args: {
333
+ fqName: string;
334
+ observed: cont.Cluster;
335
+ news: ClusterProps;
336
+ session: ScopedPlanStatusSession;
337
+ }) {
338
+ if (!args.news.nodeLocations) return;
339
+ if (deepEqual(args.observed.locations ?? [], args.news.nodeLocations)) return;
340
+ const op = yield* setLocations({
341
+ name: args.fqName,
342
+ body: { locations: args.news.nodeLocations },
343
+ });
344
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
345
+ });
346
+
347
+ const syncClusterNetworkPolicy = Effect.fn(function* (args: {
348
+ fqName: string;
349
+ observed: cont.Cluster;
350
+ news: ClusterProps;
351
+ session: ScopedPlanStatusSession;
352
+ }) {
353
+ if (!args.news.networkPolicy) return;
354
+ if (deepEqual(args.observed.networkPolicy, args.news.networkPolicy)) return;
355
+ const op = yield* setNetworkPolicy({
356
+ name: args.fqName,
357
+ body: { networkPolicy: args.news.networkPolicy },
358
+ });
359
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
360
+ });
361
+
362
+ const syncClusterLogging = Effect.fn(function* (args: {
363
+ fqName: string;
364
+ observed: cont.Cluster;
365
+ news: ClusterProps;
366
+ session: ScopedPlanStatusSession;
367
+ }) {
368
+ if (args.news.loggingService === undefined) return;
369
+ if (args.observed.loggingService === args.news.loggingService) return;
370
+ const op = yield* setLogging({
371
+ name: args.fqName,
372
+ body: { loggingService: args.news.loggingService },
373
+ });
374
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
375
+ });
376
+
377
+ const syncClusterMonitoring = Effect.fn(function* (args: {
378
+ fqName: string;
379
+ observed: cont.Cluster;
380
+ news: ClusterProps;
381
+ session: ScopedPlanStatusSession;
382
+ }) {
383
+ if (args.news.monitoringService === undefined) return;
384
+ if (args.observed.monitoringService === args.news.monitoringService) return;
385
+ const op = yield* setMonitoring({
386
+ name: args.fqName,
387
+ body: { monitoringService: args.news.monitoringService },
388
+ });
389
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
390
+ });
391
+
392
+ // Catch-all generic update for fields without dedicated setters:
393
+ // currently `releaseChannel` and `workloadIdentityConfig`. The
394
+ // API auto-derives the field mask from which `desired*` keys are
395
+ // populated — no `updateMask` parameter needed.
396
+ const syncClusterUpdate = Effect.fn(function* (args: {
397
+ fqName: string;
398
+ observed: cont.Cluster;
399
+ news: ClusterProps;
400
+ session: ScopedPlanStatusSession;
401
+ }) {
402
+ const update: cont.ClusterUpdate = {};
403
+ if (
404
+ args.news.releaseChannel &&
405
+ !deepEqual(args.observed.releaseChannel, args.news.releaseChannel)
406
+ ) {
407
+ update.desiredReleaseChannel = args.news.releaseChannel;
408
+ }
409
+ if (
410
+ args.news.workloadIdentityConfig &&
411
+ !deepEqual(args.observed.workloadIdentityConfig, args.news.workloadIdentityConfig)
412
+ ) {
413
+ update.desiredWorkloadIdentityConfig = args.news.workloadIdentityConfig;
414
+ }
415
+ if (Object.keys(update).length === 0) return;
416
+ const op = yield* updateClusters({
417
+ name: args.fqName,
418
+ body: { update },
419
+ });
420
+ if (op.name) yield* awaitOperation(qualifyOp(args.fqName, op.name), args.session);
421
+ });
422
+
423
+ return {
424
+ stables: [
425
+ "name",
426
+ "selfLink",
427
+ "project",
428
+ "location",
429
+ "endpoint",
430
+ "clusterCaCertificate",
431
+ ],
432
+ diff: Effect.fn(function* ({ news, olds = {} }) {
433
+ if (!isResolved(news)) return undefined;
434
+ if (
435
+ somePropsAreDifferent(olds as ClusterProps, news, [
436
+ "project",
437
+ "location",
438
+ "name",
439
+ "network",
440
+ "subnetwork",
441
+ ])
442
+ ) {
443
+ return { action: "replace" } as const;
444
+ }
445
+ if (!deepEqual(olds.ipAllocationPolicy, news.ipAllocationPolicy)) {
446
+ return { action: "replace" } as const;
447
+ }
448
+ // `initialNodePool` is intentionally NOT part of diff — it is
449
+ // consumed only on create. See ClusterProps JSDoc.
450
+ return undefined;
451
+ }),
452
+ reconcile: Effect.fn(function* ({ id, news, session }) {
453
+ const internalLabels = yield* gcpInternalLabels(id);
454
+ const desiredName =
455
+ news.name ??
456
+ (yield* createPhysicalName({ id, maxLength: 40 })).toLowerCase();
457
+ const parent = `projects/${news.project}/locations/${news.location}`;
458
+ const fqName = `${parent}/clusters/${desiredName}`;
459
+ const desiredLabels: Record<string, string> = {
460
+ ...(news.resourceLabels ?? {}),
461
+ ...internalLabels,
462
+ };
463
+
464
+ // 1. Observe — collapse 403 to "missing" alongside 404. GCP
465
+ // returns 403 for invisible resources/projects.
466
+ let observed = yield* getClusters({ name: fqName }).pipe(
467
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined as cont.Cluster | undefined)),
468
+ Effect.catchTag("Forbidden", () => Effect.succeed(undefined as cont.Cluster | undefined)),
469
+ );
470
+
471
+ // 2. Ensure — create with the user-supplied bootstrap pool
472
+ // inline. No default-pool dance: an explicit
473
+ // `nodePools: [...]` entry instructs GKE to skip the
474
+ // implicit default. `Conflict` covers concurrent creates
475
+ // and state-persistence races; fall through and re-observe.
476
+ if (!observed) {
477
+ const initialPoolBody = toInitialNodePoolBody(
478
+ news.initialNodePool,
479
+ internalLabels,
480
+ );
481
+ // GKE create can fire faster than ApiEnable's effect
482
+ // propagates: GCP returns 403 with "Kubernetes Engine API
483
+ // has not been used in project ... If you enabled this
484
+ // API recently, wait a few minutes …" even when the
485
+ // ApiEnable LRO has reported DONE. Retry the *create call*
486
+ // (not the LRO poll, which doesn't run until create
487
+ // succeeds) on this specific 403 for ~5 minutes; other
488
+ // Forbidden cases propagate normally.
489
+ const op = yield* createClusters({
490
+ parent,
491
+ body: {
492
+ cluster: {
493
+ name: desiredName,
494
+ ...(news.network ? { network: news.network } : {}),
495
+ ...(news.subnetwork ? { subnetwork: news.subnetwork } : {}),
496
+ ...(news.ipAllocationPolicy
497
+ ? { ipAllocationPolicy: news.ipAllocationPolicy }
498
+ : {}),
499
+ ...(news.releaseChannel ? { releaseChannel: news.releaseChannel } : {}),
500
+ ...(news.workloadIdentityConfig
501
+ ? { workloadIdentityConfig: news.workloadIdentityConfig }
502
+ : {}),
503
+ ...(news.addonsConfig ? { addonsConfig: news.addonsConfig } : {}),
504
+ ...(news.loggingService ? { loggingService: news.loggingService } : {}),
505
+ ...(news.monitoringService
506
+ ? { monitoringService: news.monitoringService }
507
+ : {}),
508
+ ...(news.maintenancePolicy
509
+ ? { maintenancePolicy: news.maintenancePolicy }
510
+ : {}),
511
+ ...(news.networkPolicy ? { networkPolicy: news.networkPolicy } : {}),
512
+ ...(news.initialClusterVersion
513
+ ? { initialClusterVersion: news.initialClusterVersion }
514
+ : {}),
515
+ ...(news.nodeLocations ? { locations: news.nodeLocations } : {}),
516
+ resourceLabels: desiredLabels,
517
+ nodePools: [initialPoolBody],
518
+ },
519
+ },
520
+ }).pipe(
521
+ Effect.retry({
522
+ while: (e: { _tag?: string; message?: string }) =>
523
+ e?._tag === "Forbidden" &&
524
+ /enabled this API recently|has not been used/i.test(e.message ?? ""),
525
+ schedule: Schedule.spaced(Duration.seconds(15)).pipe(
526
+ Schedule.both(Schedule.recurs(20)), // 20 × 15s ≈ 5 min
527
+ Schedule.tapOutput(() =>
528
+ session.note(
529
+ "Waiting for Container API enablement to propagate…",
530
+ ),
531
+ ),
532
+ ),
533
+ }),
534
+ Effect.catchTag("Conflict", () =>
535
+ Effect.succeed(undefined as cont.Operation | undefined),
536
+ ),
537
+ );
538
+ if (op?.name) yield* awaitOperation(qualifyOp(fqName, op.name), session);
539
+ observed = yield* getClusters({ name: fqName });
540
+ }
541
+
542
+ // 3. Sync — each aspect independently idempotent. Re-observe
543
+ // after the labels call so the fingerprint stays valid for
544
+ // subsequent setters (although they don't all use it,
545
+ // keeping the observed view fresh prevents subtle drift in
546
+ // the deepEqual short-circuits below).
547
+ yield* syncClusterLabels({
548
+ fqName,
549
+ observed,
550
+ desired: desiredLabels,
551
+ session,
552
+ });
553
+ yield* syncClusterAddons({ fqName, observed, news, session });
554
+ yield* syncClusterMaintenance({ fqName, observed, news, session });
555
+ yield* syncClusterLocations({ fqName, observed, news, session });
556
+ yield* syncClusterNetworkPolicy({ fqName, observed, news, session });
557
+ yield* syncClusterLogging({ fqName, observed, news, session });
558
+ yield* syncClusterMonitoring({ fqName, observed, news, session });
559
+ yield* syncClusterUpdate({ fqName, observed, news, session });
560
+
561
+ const final = yield* getClusters({ name: fqName });
562
+ return toClusterAttributes(final, {
563
+ project: news.project,
564
+ location: news.location,
565
+ });
566
+ }),
567
+ delete: Effect.fn(function* ({ output, session }) {
568
+ const fqName = `projects/${output.project}/locations/${output.location}/clusters/${output.name}`;
569
+ yield* deleteClusters({ name: fqName }).pipe(
570
+ Effect.flatMap((op) =>
571
+ op.name
572
+ ? awaitOperation(qualifyOp(fqName, op.name), session)
573
+ : Effect.succeed(op),
574
+ ),
575
+ Effect.catchTag("NotFound", () => Effect.void),
576
+ );
577
+ }),
578
+ read: Effect.fn(function* ({ id, output, olds }) {
579
+ const project = output?.project ?? olds?.project;
580
+ const location = output?.location ?? olds?.location;
581
+ if (!project || !location) return undefined;
582
+ const name =
583
+ output?.name ??
584
+ olds?.name ??
585
+ (yield* createPhysicalName({ id, maxLength: 40 })).toLowerCase();
586
+ const fqName = `projects/${project}/locations/${location}/clusters/${name}`;
587
+ const observed = yield* getClusters({ name: fqName }).pipe(
588
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined as cont.Cluster | undefined)),
589
+ Effect.catchTag("Forbidden", () => Effect.succeed(undefined as cont.Cluster | undefined)),
590
+ );
591
+ if (!observed) return undefined;
592
+ const attrs = toClusterAttributes(observed, { project, location });
593
+ return (yield* hasAlchemyLabels(id, observed.resourceLabels))
594
+ ? attrs
595
+ : Unowned(attrs);
596
+ }),
597
+ };
598
+ }),
599
+ );