1import * as gkeonprem from "@distilled.cloud/gcp/gkeonprem_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 ResourceNotResolved,
14 VMWARE_NAME_LENGTH,
15 collectPages,
16 createInternalLabels,
17 desiredAnnotations,
18 differs,
19 encodeOwnership,
20 fieldMask,
21 isOwned,
22 listAtLocation,
23 membershipName,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 parseOwnership,
28 replaceOnIdentity,
29 rfc1035,
30 sameText,
31 textState,
32 toPhysicalId,
33 userLabels,
34 waitForOperation,
35 waitUntilExists,
36 waitUntilGone,
37} from "./internal.ts";
38import type {
39 Authorization,
40 BinaryAuthorization,
41 Fleet,
42 VmwareAAGConfig,
43 VmwareAutoRepairConfig,
44 VmwareClusterUpgradePolicy,
45 VmwareControlPlaneNodeConfig,
46 VmwareDataplaneV2Config,
47 VmwareLoadBalancerConfig,
48 VmwareNetworkConfig,
49 VmwareStorageConfig,
50 VmwareVCenterConfig,
51} from "./types.ts";
52
53const COLLECTION = "vmwareClusters";
54
55export type VmwareClusterProps = {
56 /**
57 * Cluster id (the `{vmwareCluster}` segment). If omitted, a unique
58 * RFC1123 name is generated. Max 40 characters. Immutable — changing
59 * it replaces the cluster.
60 */
61 vmwareClusterId?: string;
62 /**
63 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
64 * replaces the cluster. `US-CENTRAL1` is accepted and normalized.
65 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
66 */
67 location?: string;
68 /**
69 * Admin cluster Hub membership this user cluster belongs to. Full name
70 * `projects/{project}/locations/global/memberships/{membership}` or a
71 * membership id. Immutable — changing it replaces the cluster.
72 */
73 adminClusterMembership: string;
74 /**
75 * Anthos clusters on VMware version for the user cluster.
76 */
77 onPremVersion: string;
78 /**
79 * Control plane node configuration (CPU, memory, replica count).
80 */
81 controlPlaneNode?: VmwareControlPlaneNodeConfig;
82 /**
83 * Cluster network configuration (pod/service CIDRs, DHCP or static IP).
84 */
85 networkConfig?: VmwareNetworkConfig;
86 /**
87 * Load balancer configuration (MetalLB, F5, Seesaw, or manual).
88 */
89 loadBalancer?: VmwareLoadBalancerConfig;
90 /**
91 * vSphere CSI storage configuration.
92 */
93 storage?: VmwareStorageConfig;
94 /**
95 * vCenter settings. Inherited from the admin cluster when omitted.
96 */
97 vcenter?: VmwareVCenterConfig;
98 /**
99 * Dataplane V2 configuration.
100 */
101 dataplaneV2?: VmwareDataplaneV2Config;
102 /**
103 * Anti-affinity group (spread nodes across three hosts).
104 */
105 antiAffinityGroups?: VmwareAAGConfig;
106 /**
107 * Bootstrap RBAC users granted cluster-admin.
108 */
109 authorization?: Authorization;
110 /**
111 * Auto-repair configuration.
112 */
113 autoRepairConfig?: VmwareAutoRepairConfig;
114 /**
115 * Cluster upgrade policy.
116 */
117 upgradePolicy?: VmwareClusterUpgradePolicy;
118 /**
119 * Binary Authorization evaluation mode.
120 */
121 binaryAuthorization?: BinaryAuthorization;
122 /**
123 * Enable control plane V2.
124 * @default false
125 */
126 enableControlPlaneV2?: boolean;
127 /**
128 * Enable advanced cluster.
129 */
130 enableAdvancedCluster?: boolean;
131 /**
132 * Disable bundled ingress.
133 */
134 disableBundledIngress?: boolean;
135 /**
136 * Enable VM tracking.
137 */
138 vmTrackingEnabled?: boolean;
139 /**
140 * Human-readable description. Clusters have no GCP labels field, so
141 * Alchemy stamps ownership into annotations and a `[alchemy …]`
142 * description prefix and strips both from attributes.
143 */
144 description?: string;
145 /**
146 * Kubernetes-style annotations. Alchemy ownership keys are merged in.
147 */
148 annotations?: Record<string, string>;
149 /**
150 * User labels stored as annotations (keys sanitized like GCP labels).
151 * Alchemy ownership labels are merged in automatically.
152 */
153 labels?: Record<string, string>;
154};
155
156export type VmwareCluster = Resource<
157 "GCP.GKEOnPrem.VmwareCluster",
158 VmwareClusterProps,
159 {
160 /** Full resource name. */
161 name: string;
162 /** Cluster id (last path segment). */
163 vmwareClusterId: string;
164 /** Project id. */
165 project: string;
166 /** Location id. */
167 location: string;
168 /** Admin cluster Hub membership. */
169 adminClusterMembership: string | undefined;
170 /** Output-only admin cluster resource name. */
171 adminClusterName: string | undefined;
172 /** Anthos on-prem version. */
173 onPremVersion: string | undefined;
174 /** Control plane node configuration. */
175 controlPlaneNode: VmwareControlPlaneNodeConfig | undefined;
176 /** Network configuration. */
177 networkConfig: VmwareNetworkConfig | undefined;
178 /** Load balancer configuration. */
179 loadBalancer: VmwareLoadBalancerConfig | undefined;
180 /** Storage configuration. */
181 storage: VmwareStorageConfig | undefined;
182 /** vCenter configuration. */
183 vcenter: VmwareVCenterConfig | undefined;
184 /** Dataplane V2 configuration. */
185 dataplaneV2: VmwareDataplaneV2Config | undefined;
186 /** Anti-affinity configuration. */
187 antiAffinityGroups: VmwareAAGConfig | undefined;
188 /** Bootstrap RBAC. */
189 authorization: Authorization | undefined;
190 /** Auto-repair configuration. */
191 autoRepairConfig: VmwareAutoRepairConfig | undefined;
192 /** Upgrade policy. */
193 upgradePolicy: VmwareClusterUpgradePolicy | undefined;
194 /** Binary Authorization configuration. */
195 binaryAuthorization: BinaryAuthorization | undefined;
196 /** Control plane V2 enabled. */
197 enableControlPlaneV2: boolean | undefined;
198 /** Advanced cluster enabled. */
199 enableAdvancedCluster: boolean | undefined;
200 /** Bundled ingress disabled. */
201 disableBundledIngress: boolean | undefined;
202 /** VM tracking enabled. */
203 vmTrackingEnabled: boolean | undefined;
204 /** User description with the Alchemy ownership prefix stripped. */
205 description: string | undefined;
206 /** User annotations (Alchemy ownership keys stripped). */
207 annotations: Record<string, string>;
208 /** User labels (Alchemy ownership keys stripped). */
209 labels: Record<string, string>;
210 /** Output-only Kubernetes API endpoint. */
211 endpoint: string | undefined;
212 /** Output-only Fleet membership. */
213 fleet: Fleet | undefined;
214 /** Local CR name on the admin cluster. */
215 localName: string | undefined;
216 /** Server-reported state. */
217 state: string | undefined;
218 /** Server-generated resource uid. */
219 uid: string | undefined;
220 /** Whether a change is in flight. */
221 reconciling: boolean | undefined;
222 /** Controller error message. */
223 errorMessage: string | undefined;
224 /** RFC3339 creation timestamp. */
225 createTime: string | undefined;
226 /** RFC3339 last-update timestamp. */
227 updateTime: string | undefined;
228 /** RFC3339 deletion timestamp. */
229 deleteTime: string | undefined;
230 /** Server etag. */
231 etag: string | undefined;
232 },
233 never,
234 Providers
235>;
236
237/**
238 * An Anthos on VMware user cluster registered with the GKE On-Prem API.
239 * Creating a cluster requires a connected admin cluster (Hub membership)
240 * and vSphere infrastructure.
241 *
242 * Clusters have no GCP labels field, so Alchemy stamps ownership into
243 * annotations and a `[alchemy …]` description prefix for `list` / nuke.
244 * Cluster id, location, and admin membership are identity — changing
245 * them replaces the cluster. Other fields update in place.
246 *
247 * ### Creating a VMware Cluster
248 * **Example:** User cluster with MetalLB
249 * ```typescript
250 * const cluster = yield* GCP.GKEOnPrem.VmwareCluster("Workload", {
251 * adminClusterMembership:
252 * "projects/my-project/locations/global/memberships/admin",
253 * onPremVersion: "1.28.0-gke.1",
254 * controlPlaneNode: { cpus: "4", memory: "8192", replicas: "1" },
255 * networkConfig: {
256 * serviceAddressCidrBlocks: ["10.96.0.0/12"],
257 * podAddressCidrBlocks: ["192.168.0.0/16"],
258 * dhcpIpConfig: { enabled: true },
259 * },
260 * loadBalancer: {
261 * vipConfig: { controlPlaneVip: "10.200.0.8", ingressVip: "10.200.0.9" },
262 * metalLbConfig: {
263 * addressPools: [{ pool: "pool-1", addresses: ["10.200.0.10-10.200.0.20"] }],
264 * },
265 * },
266 * description: "app user cluster",
267 * });
268 * ```
269 *
270 * ### Updating a VMware Cluster
271 * Re-declare the same logical id with changed props; the engine keeps the
272 * physical resource and updates it in place.
273 *
274 * **Example:** Description and version
275 * ```typescript
276 * const cluster = yield* GCP.GKEOnPrem.VmwareCluster("Workload", {
277 * adminClusterMembership:
278 * "projects/my-project/locations/global/memberships/admin",
279 * onPremVersion: "1.29.0-gke.1",
280 * controlPlaneNode: { cpus: "4", memory: "8192", replicas: "1" },
281 * networkConfig: {
282 * serviceAddressCidrBlocks: ["10.96.0.0/12"],
283 * podAddressCidrBlocks: ["192.168.0.0/16"],
284 * dhcpIpConfig: { enabled: true },
285 * },
286 * loadBalancer: {
287 * vipConfig: { controlPlaneVip: "10.200.0.8", ingressVip: "10.200.0.9" },
288 * metalLbConfig: {
289 * addressPools: [{ pool: "pool-1", addresses: ["10.200.0.10-10.200.0.20"] }],
290 * },
291 * },
292 * description: "app user cluster v2",
293 * });
294 * ```
295 *
296 * @resource
297 * @category GKEOnPrem
298 */
299export const VmwareCluster = Resource<VmwareCluster>(
300 "GCP.GKEOnPrem.VmwareCluster",
301);
302
303const resourceName = (
304 project: string,
305 location: string,
306 vmwareClusterId: string,
307) => `${parentOf(project, location)}/${COLLECTION}/${vmwareClusterId}`;
308
309const toAttrs = (
310 item: gkeonprem.VmwareCluster,
311 project: string,
312 region: string,
313) => {
314 const name = item.name ?? "";
315 const parsed = parseName(name, COLLECTION, region);
316 const ownership = parseOwnership(item.description);
317 const annotations = userLabels(item.annotations);
318 return {
319 name,
320 vmwareClusterId: parsed.id,
321 project: parsed.project || project,
322 location: parsed.location,
323 adminClusterMembership: item.adminClusterMembership,
324 adminClusterName: item.adminClusterName,
325 onPremVersion: item.onPremVersion,
326 controlPlaneNode: item.controlPlaneNode,
327 networkConfig: item.networkConfig,
328 loadBalancer: item.loadBalancer,
329 storage: item.storage,
330 vcenter: item.vcenter,
331 dataplaneV2: item.dataplaneV2,
332 antiAffinityGroups: item.antiAffinityGroups,
333 authorization: item.authorization,
334 autoRepairConfig: item.autoRepairConfig,
335 upgradePolicy: item.upgradePolicy,
336 binaryAuthorization: item.binaryAuthorization,
337 enableControlPlaneV2: item.enableControlPlaneV2,
338 enableAdvancedCluster: item.enableAdvancedCluster,
339 disableBundledIngress: item.disableBundledIngress,
340 vmTrackingEnabled: item.vmTrackingEnabled,
341 description: ownership.text,
342 annotations,
343 labels: annotations,
344 endpoint: item.endpoint,
345 fleet: item.fleet,
346 localName: item.localName,
347 state: textState(item.state),
348 uid: item.uid,
349 reconciling: item.reconciling,
350 errorMessage: item.status?.errorMessage,
351 createTime: item.createTime,
352 updateTime: item.updateTime,
353 deleteTime: item.deleteTime,
354 etag: item.etag,
355 };
356};
357
358const getByName = (name: string) =>
359 gkeonprem
360 .getProjectsLocationsVmwareClusters({ name, view: "FULL" })
361 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
362
363const listOwned = (project: string, region: string) =>
364 listAtLocation(project, region, (parent) =>
365 collectPages(
366 gkeonprem.listProjectsLocationsVmwareClusters.pages({
367 parent,
368 pageSize: 1000,
369 view: "FULL",
370 }),
371 (page): readonly gkeonprem.VmwareCluster[] | undefined =>
372 page.vmwareClusters,
373 ),
374 ).pipe(
375 Effect.map((items) =>
376 items.filter((item: gkeonprem.VmwareCluster) =>
377 isOwned(item.annotations, item.description),
378 ),
379 ),
380 );
381
382const toBody = (
383 news: VmwareClusterProps,
384 annotations: Record<string, string>,
385 description: string,
386 membership: string,
387): gkeonprem.VmwareCluster => ({
388 adminClusterMembership: membership,
389 onPremVersion: news.onPremVersion,
390 controlPlaneNode: news.controlPlaneNode,
391 networkConfig: news.networkConfig,
392 loadBalancer: news.loadBalancer,
393 storage: news.storage,
394 vcenter: news.vcenter,
395 dataplaneV2: news.dataplaneV2,
396 antiAffinityGroups: news.antiAffinityGroups,
397 authorization: news.authorization,
398 autoRepairConfig: news.autoRepairConfig,
399 upgradePolicy: news.upgradePolicy,
400 binaryAuthorization: news.binaryAuthorization,
401 enableControlPlaneV2: news.enableControlPlaneV2,
402 enableAdvancedCluster: news.enableAdvancedCluster,
403 disableBundledIngress: news.disableBundledIngress,
404 vmTrackingEnabled: news.vmTrackingEnabled,
405 description,
406 annotations,
407});
408
409export const VmwareClusterProvider = () =>
410 Provider.succeed(VmwareCluster, {
411 stables: [
412 "name",
413 "vmwareClusterId",
414 "project",
415 "location",
416 "uid",
417 "createTime",
418 ],
419
420 diff: Effect.fn(function* ({ news, olds, output }) {
421 if (!isResolved(news)) return undefined;
422 const env = yield* GcpEnvironment.current;
423 const previousMembership =
424 olds?.adminClusterMembership ?? output?.adminClusterMembership;
425 const nextMembership = news.adminClusterMembership;
426 return replaceOnIdentity({
427 previousId: olds?.vmwareClusterId ?? output?.vmwareClusterId,
428 nextId: news.vmwareClusterId
429 ? rfc1035(news.vmwareClusterId, "vmwarecluster", VMWARE_NAME_LENGTH)
430 : (olds?.vmwareClusterId ?? output?.vmwareClusterId),
431 previousLocation: normalizeLocation(
432 olds?.location ?? output?.location,
433 env.region,
434 ),
435 nextLocation: normalizeLocation(
436 news.location ?? olds?.location ?? output?.location,
437 env.region,
438 ),
439 extra:
440 previousMembership !== undefined &&
441 nextMembership !== undefined &&
442 previousMembership !== nextMembership &&
443 !previousMembership.endsWith(`/${nextMembership}`),
444 });
445 }),
446
447 read: Effect.fn(function* ({ id, olds, output }) {
448 const env = yield* GcpEnvironment.current;
449 const vmwareClusterId = yield* toPhysicalId(
450 id,
451 olds?.vmwareClusterId,
452 output?.vmwareClusterId,
453 "vmwarecluster",
454 VMWARE_NAME_LENGTH,
455 );
456 const location = normalizeLocation(
457 olds?.location ?? output?.location,
458 env.region,
459 );
460 const name =
461 output?.name ?? resourceName(env.project, location, vmwareClusterId);
462 const existing = yield* getByName(name);
463 if (existing === undefined) return undefined;
464 const attrs = toAttrs(existing, env.project, env.region);
465 const fromDescription = parseOwnership(existing.description).labels;
466 const owned =
467 (yield* hasAlchemyLabels(id, tagRecord(existing.annotations))) ||
468 (yield* hasAlchemyLabels(id, fromDescription));
469 return owned ? attrs : Unowned(attrs);
470 }),
471
472 list: () =>
473 Effect.gen(function* () {
474 const env = yield* GcpEnvironment.current;
475 const items = yield* listOwned(env.project, env.region);
476 return items.map((item: gkeonprem.VmwareCluster) =>
477 toAttrs(item, env.project, env.region),
478 );
479 }),
480
481 reconcile: Effect.fn(function* ({ id, news, output }) {
482 const env = yield* GcpEnvironment.current;
483 const vmwareClusterId = yield* toPhysicalId(
484 id,
485 news.vmwareClusterId,
486 output?.vmwareClusterId,
487 "vmwarecluster",
488 VMWARE_NAME_LENGTH,
489 );
490 const location = normalizeLocation(
491 news.location ?? output?.location,
492 env.region,
493 );
494 const name = resourceName(env.project, location, vmwareClusterId);
495 const ownership = yield* createInternalLabels(id);
496 const annotations = desiredAnnotations(
497 ownership,
498 news.labels,
499 news.annotations,
500 );
501 const description = encodeOwnership(ownership, news.description);
502 const membership = membershipName(
503 news.adminClusterMembership,
504 env.project,
505 );
506 const body = toBody(news, annotations, description, membership);
507
508 let current = yield* getByName(output?.name ?? name);
509
510 if (current === undefined) {
511 const created = yield* gkeonprem
512 .createProjectsLocationsVmwareClusters({
513 parent: parentOf(env.project, location),
514 vmwareClusterId,
515 body,
516 })
517 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
518 if (created !== undefined) {
519 yield* waitForOperation(created);
520 }
521 current = yield* waitUntilExists(getByName(name), name);
522 }
523
524 if (current === undefined) {
525 return yield* new ResourceNotResolved({ name });
526 }
527
528 const mask = fieldMask([
529 differs(current.annotations, annotations) && "annotations",
530 !sameText(parseOwnership(current.description).text, news.description) &&
531 "description",
532 differs(current.onPremVersion, news.onPremVersion) && "onPremVersion",
533 differs(current.controlPlaneNode, news.controlPlaneNode) &&
534 "controlPlaneNode",
535 differs(current.networkConfig, news.networkConfig) && "networkConfig",
536 differs(current.loadBalancer, news.loadBalancer) && "loadBalancer",
537 differs(current.storage, news.storage) && "storage",
538 differs(current.vcenter, news.vcenter) && "vcenter",
539 differs(current.dataplaneV2, news.dataplaneV2) && "dataplaneV2",
540 differs(current.antiAffinityGroups, news.antiAffinityGroups) &&
541 "antiAffinityGroups",
542 differs(current.authorization, news.authorization) && "authorization",
543 differs(current.autoRepairConfig, news.autoRepairConfig) &&
544 "autoRepairConfig",
545 differs(current.upgradePolicy, news.upgradePolicy) && "upgradePolicy",
546 differs(current.binaryAuthorization, news.binaryAuthorization) &&
547 "binaryAuthorization",
548 news.enableControlPlaneV2 !== undefined &&
549 news.enableControlPlaneV2 !== current.enableControlPlaneV2 &&
550 "enableControlPlaneV2",
551 news.enableAdvancedCluster !== undefined &&
552 news.enableAdvancedCluster !== current.enableAdvancedCluster &&
553 "enableAdvancedCluster",
554 news.disableBundledIngress !== undefined &&
555 news.disableBundledIngress !== current.disableBundledIngress &&
556 "disableBundledIngress",
557 news.vmTrackingEnabled !== undefined &&
558 news.vmTrackingEnabled !== current.vmTrackingEnabled &&
559 "vmTrackingEnabled",
560 ]);
561
562 if (mask.length > 0) {
563 const operation = yield* gkeonprem.patchProjectsLocationsVmwareClusters(
564 {
565 name: current.name ?? name,
566 updateMask: mask,
567 body: {
568 ...body,
569 etag: current.etag,
570 },
571 },
572 );
573 yield* waitForOperation(operation);
574 current = yield* waitUntilExists(
575 getByName(current.name ?? name),
576 current.name ?? name,
577 );
578 }
579
580 if (current === undefined) {
581 return yield* new ResourceNotResolved({ name });
582 }
583 return toAttrs(current, env.project, env.region);
584 }),
585
586 delete: Effect.fn(function* ({ output }) {
587 const operation = yield* gkeonprem
588 .deleteProjectsLocationsVmwareClusters({
589 name: output.name,
590 allowMissing: true,
591 force: true,
592 etag: output.etag,
593 })
594 .pipe(
595 Effect.retry({
596 while: (error) => error._tag === "Conflict",
597 times: 8,
598 schedule: Schedule.spaced("2 seconds"),
599 }),
600 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
601 );
602 if (operation !== undefined) {
603 yield* waitForOperation(operation, { notFoundOk: true });
604 }
605 yield* waitUntilGone(getByName(output.name), output.name);
606 }),
607 });