1import * as vmwareengine from "@distilled.cloud/gcp/vmwareengine_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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_ZONE,
12 VmwareengineNotResolved,
13 changedFields,
14 collectPages,
15 createInternalLabels,
16 expandName,
17 hasOwnershipMarker,
18 listAcrossLocations,
19 locationFromName,
20 normalizeLocation,
21 parseName,
22 replaceOnIdentity,
23 rfc1035,
24 sameJson,
25 toPhysicalId,
26 waitForOperation,
27 waitUntilGone,
28 waitUntilPresent,
29 waitUntilReady,
30} from "./internal.ts";
31import type { NodeTypeConfig, StretchedClusterConfig } from "./PrivateCloud.ts";
32
33const COLLECTION = "clusters";
34const PARENT_COLLECTION = "privateClouds";
35const DEFAULT_NODE_TYPE = "standard-72";
36const DEFAULT_NODE_COUNT = 3;
37
38export type Thresholds = {
39 /** Utilization percent that triggers scale-out. */
40 scaleOut?: number;
41 /** Utilization percent that triggers scale-in. */
42 scaleIn?: number;
43};
44
45export type AutoscalingPolicy = {
46 /** Canonical node type id this policy scales. */
47 nodeTypeId?: string;
48 /** Storage utilization thresholds. */
49 storageThresholds?: Thresholds;
50 /** CPU utilization thresholds. */
51 cpuThresholds?: Thresholds;
52 /** Granted-memory utilization thresholds. */
53 grantedMemoryThresholds?: Thresholds;
54 /** Consumed-memory utilization thresholds. */
55 consumedMemoryThresholds?: Thresholds;
56 /** Nodes added per scale-out. Must be even for stretched clusters. */
57 scaleOutSize?: number;
58};
59
60export type AutoscalingSettings = {
61 /** Minimum nodes of any type. */
62 minClusterNodeCount?: number;
63 /** Cool-down between autoscale operations (duration string). */
64 coolDownPeriod?: string;
65 /** Map of policy id to autoscaling policy. */
66 autoscalingPolicies?: Record<string, AutoscalingPolicy | undefined>;
67 /** Maximum nodes of any type. */
68 maxClusterNodeCount?: number;
69};
70
71export type DatastoreNetwork = {
72 /** Output-only network peering used to reach the file share. */
73 networkPeering?: string;
74 /** NFS connections from each ESXi host (`1`–`4`). */
75 connectionCount?: number;
76 /** Subnet resource name. */
77 subnet?: string;
78 /** VMKernel adapter MTU. */
79 mtu?: number;
80};
81
82export type DatastoreMountConfig = {
83 /** Output-only file share name. */
84 fileShare?: string;
85 /** NFS protocol. Default `NFS_V3`. */
86 nfsVersion?: vmwareengine.DatastoreMountConfigNfsVersionEnum | (string & {});
87 /** Datastore resource name to mount. */
88 datastore?: string;
89 /** Access mode. Default `READ_WRITE`. */
90 accessMode?: vmwareengine.DatastoreMountConfigAccessModeEnum | (string & {});
91 /** Network configuration for the mount. */
92 datastoreNetwork?: DatastoreNetwork;
93 /** Output-only NFS server addresses. */
94 servers?: string[];
95};
96
97export type PrivateCloudsClusterProps = {
98 /**
99 * Parent PrivateCloud resource name
100 * (`projects/{project}/locations/{location}/privateClouds/{privateCloud}`)
101 * or the cloud id. Immutable — changing it replaces the cluster.
102 */
103 privateCloud: string;
104 /**
105 * Cluster id (the `{cluster}` segment of
106 * `.../privateClouds/{privateCloud}/clusters/{cluster}`). If omitted, a
107 * unique RFC1035 name is generated. Immutable.
108 */
109 clusterId?: string;
110 /**
111 * Location of the parent private cloud. Inferred from `privateCloud`
112 * when that value is a full resource name. Immutable.
113 * @default "us-central1-a"
114 */
115 location?: string;
116 /**
117 * Map of node type id to config. Keys are canonical node type ids.
118 * @default { "standard-72": { nodeCount: 3 } }
119 */
120 nodeTypeConfigs?: Record<string, NodeTypeConfig | undefined>;
121 /**
122 * Autoscaling applied to this cluster.
123 */
124 autoscalingSettings?: AutoscalingSettings;
125 /**
126 * Stretched-cluster zones. Required for clusters on a `STRETCHED`
127 * private cloud. Immutable.
128 */
129 stretchedClusterConfig?: StretchedClusterConfig;
130};
131
132export type PrivateCloudsCluster = Resource<
133 "GCP.VMwareEngine.PrivateCloudsCluster",
134 PrivateCloudsClusterProps,
135 {
136 /** Full resource name. */
137 name: string;
138 /** Cluster id (last path segment). */
139 clusterId: string;
140 /** Parent PrivateCloud resource name. */
141 privateCloud: string;
142 /** Project id. */
143 project: string;
144 /** Location id. */
145 location: string;
146 /** Node type configuration currently applied. */
147 nodeTypeConfigs: Record<string, NodeTypeConfig | undefined> | undefined;
148 /** Autoscaling configuration currently applied. */
149 autoscalingSettings: AutoscalingSettings | undefined;
150 /** Stretched-cluster configuration. */
151 stretchedClusterConfig: StretchedClusterConfig | undefined;
152 /** Mounted datastore configuration. */
153 datastoreMountConfig: DatastoreMountConfig[];
154 /** Whether this is the management cluster. */
155 management: boolean;
156 /** Server-reported state. */
157 state: string | undefined;
158 /** System-generated unique identifier. */
159 uid: string | undefined;
160 /** RFC3339 creation timestamp. */
161 createTime: string | undefined;
162 /** RFC3339 last-update timestamp. */
163 updateTime: string | undefined;
164 },
165 never,
166 Providers
167>;
168
169/**
170 * A workload cluster in a VMware Engine private cloud. The management
171 * cluster is created with the private cloud and cannot be deleted
172 * independently.
173 *
174 * Clusters have no labels or description field, so Alchemy treats
175 * clusters whose parent private cloud carries the `[alchemy …]`
176 * ownership marker as owned for `list` / nuke. Changing the parent
177 * cloud, cluster id, location, or stretched config replaces the
178 * cluster. Node counts and autoscaling update in place.
179 *
180 * ### Creating a PrivateCloudsCluster
181 * **Example:** Three-node workload cluster
182 * ```typescript
183 * const cluster = yield* GCP.VMwareEngine.PrivateCloudsCluster("Work", {
184 * privateCloud: cloud.name,
185 * nodeTypeConfigs: {
186 * "standard-72": { nodeCount: 3 },
187 * },
188 * });
189 * ```
190 *
191 * @resource
192 * @category VMwareEngine
193 */
194export const PrivateCloudsCluster = Resource<PrivateCloudsCluster>(
195 "GCP.VMwareEngine.PrivateCloudsCluster",
196);
197
198const parentCloudName = (
199 project: string,
200 location: string,
201 privateCloud: string,
202) => expandName(privateCloud, project, location, PARENT_COLLECTION);
203
204const resourceNameOf = (parent: string, clusterId: string) =>
205 `${parent}/${COLLECTION}/${clusterId}`;
206
207const nodeTypeConfigsOf = (
208 value: vmwareengine.NodeTypeConfigMap | undefined,
209): Record<string, NodeTypeConfig | undefined> | undefined => {
210 if (value === undefined) return undefined;
211 return Object.fromEntries(
212 Object.entries(value).map(([key, config]) => [
213 key,
214 config === undefined
215 ? undefined
216 : {
217 customCoreCount: config.customCoreCount,
218 nodeCount: config.nodeCount,
219 },
220 ]),
221 );
222};
223
224const thresholdsOf = (
225 value: vmwareengine.Thresholds | Thresholds | undefined,
226): Thresholds | undefined => {
227 if (value === undefined) return undefined;
228 return { scaleOut: value.scaleOut, scaleIn: value.scaleIn };
229};
230
231const autoscalingPolicyOf = (
232 value: vmwareengine.AutoscalingPolicy | AutoscalingPolicy | undefined,
233): AutoscalingPolicy | undefined => {
234 if (value === undefined) return undefined;
235 return {
236 nodeTypeId: value.nodeTypeId,
237 storageThresholds: thresholdsOf(value.storageThresholds),
238 cpuThresholds: thresholdsOf(value.cpuThresholds),
239 grantedMemoryThresholds: thresholdsOf(value.grantedMemoryThresholds),
240 consumedMemoryThresholds: thresholdsOf(value.consumedMemoryThresholds),
241 scaleOutSize: value.scaleOutSize,
242 };
243};
244
245const autoscalingOf = (
246 value: vmwareengine.AutoscalingSettings | AutoscalingSettings | undefined,
247): AutoscalingSettings | undefined => {
248 if (value === undefined) return undefined;
249 const policies = value.autoscalingPolicies
250 ? Object.fromEntries(
251 Object.entries(value.autoscalingPolicies).map(([key, policy]) => [
252 key,
253 autoscalingPolicyOf(policy),
254 ]),
255 )
256 : undefined;
257 return {
258 minClusterNodeCount: value.minClusterNodeCount,
259 coolDownPeriod: value.coolDownPeriod,
260 autoscalingPolicies: policies,
261 maxClusterNodeCount: value.maxClusterNodeCount,
262 };
263};
264
265const stretchedOf = (
266 value:
267 | vmwareengine.StretchedClusterConfig
268 | StretchedClusterConfig
269 | undefined,
270): StretchedClusterConfig | undefined => {
271 if (value === undefined) return undefined;
272 return {
273 preferredLocation: value.preferredLocation,
274 secondaryLocation: value.secondaryLocation,
275 };
276};
277
278const mountsOf = (
279 values: vmwareengine.DatastoreMountConfigList | undefined,
280): DatastoreMountConfig[] =>
281 (values ?? []).map((mount) => ({
282 fileShare: mount.fileShare,
283 nfsVersion: mount.nfsVersion,
284 datastore: mount.datastore,
285 accessMode: mount.accessMode,
286 datastoreNetwork:
287 mount.datastoreNetwork === undefined
288 ? undefined
289 : {
290 networkPeering: mount.datastoreNetwork.networkPeering,
291 connectionCount: mount.datastoreNetwork.connectionCount,
292 subnet: mount.datastoreNetwork.subnet,
293 mtu: mount.datastoreNetwork.mtu,
294 },
295 servers: mount.servers,
296 }));
297
298const toAttrs = (item: vmwareengine.Cluster, project: string) => {
299 const name = item.name ?? "";
300 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
301 return {
302 name,
303 clusterId: parsed.id,
304 privateCloud: parsed.parent,
305 project: parsed.project || project,
306 location: parsed.location,
307 nodeTypeConfigs: nodeTypeConfigsOf(item.nodeTypeConfigs),
308 autoscalingSettings: autoscalingOf(item.autoscalingSettings),
309 stretchedClusterConfig: stretchedOf(item.stretchedClusterConfig),
310 datastoreMountConfig: mountsOf(item.datastoreMountConfig),
311 management: item.management === true,
312 state: item.state,
313 uid: item.uid,
314 createTime: item.createTime,
315 updateTime: item.updateTime,
316 };
317};
318
319const getByName = (name: string) =>
320 vmwareengine
321 .getProjectsLocationsPrivateCloudsClusters({ name })
322 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
323
324const getParentCloud = (name: string) =>
325 vmwareengine
326 .getProjectsLocationsPrivateClouds({ name })
327 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
328
329const defaultNodeTypeConfigs = (
330 value: Record<string, NodeTypeConfig | undefined> | undefined,
331) =>
332 value ?? {
333 [DEFAULT_NODE_TYPE]: { nodeCount: DEFAULT_NODE_COUNT },
334 };
335
336export const PrivateCloudsClusterProvider = () =>
337 Provider.succeed(PrivateCloudsCluster, {
338 stables: [
339 "name",
340 "clusterId",
341 "privateCloud",
342 "project",
343 "location",
344 "uid",
345 "createTime",
346 ],
347
348 diff: Effect.fn(function* ({ news, olds, output }) {
349 if (!isResolved(news)) return undefined;
350 const previousLocation = normalizeLocation(
351 olds?.location ?? output?.location,
352 DEFAULT_ZONE,
353 );
354 return replaceOnIdentity({
355 previousId: olds?.clusterId ?? output?.clusterId,
356 nextId: news.clusterId
357 ? rfc1035(news.clusterId, "cluster")
358 : (olds?.clusterId ?? output?.clusterId),
359 previousLocation,
360 nextLocation: normalizeLocation(
361 news.location ??
362 locationFromName(news.privateCloud, previousLocation),
363 DEFAULT_ZONE,
364 ),
365 previousParent: olds?.privateCloud ?? output?.privateCloud,
366 nextParent: news.privateCloud,
367 extra: !sameJson(
368 stretchedOf(news.stretchedClusterConfig),
369 stretchedOf(
370 olds?.stretchedClusterConfig ?? output?.stretchedClusterConfig,
371 ),
372 ),
373 });
374 }),
375
376 read: Effect.fn(function* ({ id, olds, output }) {
377 const env = yield* GcpEnvironment.current;
378 const location = normalizeLocation(
379 olds?.location ??
380 output?.location ??
381 (olds?.privateCloud
382 ? locationFromName(olds.privateCloud, DEFAULT_ZONE)
383 : undefined),
384 DEFAULT_ZONE,
385 );
386 const parent = parentCloudName(
387 env.project,
388 location,
389 olds?.privateCloud ?? output?.privateCloud ?? "",
390 );
391 const clusterId = yield* toPhysicalId(
392 id,
393 olds?.clusterId,
394 output?.clusterId,
395 "cluster",
396 );
397 const name = output?.name ?? resourceNameOf(parent, clusterId);
398 const existing = yield* getByName(name);
399 if (existing === undefined) return undefined;
400 const attrs = toAttrs(existing, env.project);
401 if (output?.name === (existing.name ?? name)) return attrs;
402 const parentCloud = yield* getParentCloud(attrs.privateCloud);
403 return hasOwnershipMarker(parentCloud?.description)
404 ? attrs
405 : Unowned(attrs);
406 }),
407
408 list: () =>
409 Effect.gen(function* () {
410 const env = yield* GcpEnvironment.current;
411 const clouds = yield* listAcrossLocations(
412 env.project,
413 env.region,
414 (parent) =>
415 collectPages(
416 vmwareengine.listProjectsLocationsPrivateClouds.pages({
417 parent,
418 pageSize: 1000,
419 }),
420 (page) => page.privateClouds,
421 ),
422 );
423 const ownedClouds = clouds.filter((cloud) =>
424 hasOwnershipMarker(cloud.description),
425 );
426 const nested = yield* Effect.forEach(
427 ownedClouds.filter((cloud) => (cloud.name ?? "").length > 0),
428 (cloud) =>
429 collectPages(
430 vmwareengine.listProjectsLocationsPrivateCloudsClusters.pages({
431 parent: cloud.name ?? "",
432 pageSize: 1000,
433 }),
434 (page) => page.clusters,
435 ),
436 { concurrency: 4 },
437 );
438 return nested
439 .flat()
440 .filter((item) => item.management !== true)
441 .map((item) => toAttrs(item, env.project));
442 }),
443
444 reconcile: Effect.fn(function* ({ id, news, output }) {
445 const env = yield* GcpEnvironment.current;
446 const location = normalizeLocation(
447 news.location ??
448 output?.location ??
449 locationFromName(news.privateCloud, DEFAULT_ZONE),
450 DEFAULT_ZONE,
451 );
452 const parent = parentCloudName(env.project, location, news.privateCloud);
453 const clusterId = yield* toPhysicalId(
454 id,
455 news.clusterId,
456 output?.clusterId,
457 "cluster",
458 );
459 const name = resourceNameOf(parent, clusterId);
460 yield* createInternalLabels(id);
461 const nodeTypeConfigs = defaultNodeTypeConfigs(news.nodeTypeConfigs);
462
463 let current = yield* getByName(output?.name ?? name);
464
465 if (current === undefined) {
466 const created = yield* vmwareengine
467 .createProjectsLocationsPrivateCloudsClusters({
468 parent,
469 clusterId,
470 body: {
471 nodeTypeConfigs,
472 autoscalingSettings: news.autoscalingSettings,
473 stretchedClusterConfig: news.stretchedClusterConfig,
474 },
475 })
476 .pipe(
477 Effect.retry({
478 while: (error) => error._tag === "Conflict",
479 times: 5,
480 schedule: Schedule.spaced("2 seconds"),
481 }),
482 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
483 );
484 if (created !== undefined) {
485 yield* waitForOperation(created);
486 }
487 current = yield* waitUntilPresent(getByName(name), name);
488 }
489
490 if (current === undefined) {
491 return yield* new VmwareengineNotResolved({ name });
492 }
493
494 current = yield* waitUntilReady(
495 getByName(current.name ?? name),
496 current.name ?? name,
497 (item) => item.state,
498 );
499
500 const nodesChanged = !sameJson(
501 nodeTypeConfigsOf(current.nodeTypeConfigs),
502 nodeTypeConfigs,
503 );
504 const autoscalingChanged = !sameJson(
505 autoscalingOf(current.autoscalingSettings),
506 autoscalingOf(news.autoscalingSettings),
507 );
508 const updateMask = changedFields([
509 ["nodeTypeConfigs", nodesChanged],
510 ["autoscalingSettings", autoscalingChanged],
511 ]);
512
513 if (updateMask.length > 0) {
514 const operation =
515 yield* vmwareengine.patchProjectsLocationsPrivateCloudsClusters({
516 name: current.name ?? name,
517 updateMask: updateMask.join(","),
518 body: {
519 name: current.name ?? name,
520 nodeTypeConfigs,
521 autoscalingSettings: news.autoscalingSettings,
522 },
523 });
524 yield* waitForOperation(operation);
525 current = yield* waitUntilReady(
526 getByName(current.name ?? name),
527 current.name ?? name,
528 (item) => item.state,
529 );
530 }
531
532 return toAttrs(current, env.project);
533 }),
534
535 delete: Effect.fn(function* ({ output }) {
536 if (output.management) return;
537 const operation = yield* vmwareengine
538 .deleteProjectsLocationsPrivateCloudsClusters({ name: output.name })
539 .pipe(
540 Effect.retry({
541 while: (error) => error._tag === "Conflict",
542 times: 8,
543 schedule: Schedule.spaced("2 seconds"),
544 }),
545 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
546 );
547 if (operation !== undefined) {
548 yield* waitForOperation(operation, { notFoundOk: true });
549 }
550 yield* waitUntilGone(getByName(output.name), output.name);
551 }),
552 });