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_GLOBAL,
12 DEFAULT_ZONE,
13 VmwareengineNotResolved,
14 canonicalizeLink,
15 changedFields,
16 collectPages,
17 createInternalLabels,
18 encodeOwnership,
19 expandName,
20 hasAlchemyLabels,
21 hasOwnershipMarker,
22 listAcrossLocations,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 parseOwnership,
27 replaceOnIdentity,
28 rfc1035,
29 sameJson,
30 toPhysicalId,
31 waitForOperation,
32 waitUntilGone,
33 waitUntilPresent,
34 waitUntilReady,
35} from "./internal.ts";
36
37const COLLECTION = "privateClouds";
38const VEN_COLLECTION = "vmwareEngineNetworks";
39const DEFAULT_TYPE = "STANDARD";
40const DEFAULT_MGMT_CLUSTER_ID = "mgmt";
41const DEFAULT_NODE_TYPE = "standard-72";
42const DEFAULT_NODE_COUNT = 3;
43const DEFAULT_MANAGEMENT_CIDR = "192.168.1.0/24";
44
45export type StretchedClusterConfig = {
46 /** Preferred zone that stays up if the inter-zone link is lost. */
47 preferredLocation?: string;
48 /** Additional zone for availability. */
49 secondaryLocation?: string;
50};
51
52export type NodeTypeConfig = {
53 /** Custom core count per node. `0` uses the node type maximum. */
54 customCoreCount?: number;
55 /** Number of nodes of this type. */
56 nodeCount?: number;
57};
58
59export type ManagementCluster = {
60 /** Stretched-cluster zones. Required for `STRETCHED` private clouds. */
61 stretchedClusterConfig?: StretchedClusterConfig;
62 /**
63 * Map of node type id to config. Keys are canonical node type ids
64 * (`standard-72`, …).
65 */
66 nodeTypeConfigs?: Record<string, NodeTypeConfig | undefined>;
67 /** User-provided cluster id for the management cluster. Immutable. */
68 clusterId?: string;
69};
70
71export type NetworkConfig = {
72 /** Management CIDR used by VMware appliances. Immutable. */
73 managementCidr?: string;
74 /** VMware Engine network to attach. Immutable. */
75 vmwareEngineNetwork?: string;
76 /** Output-only IP layout version. */
77 managementIpAddressLayoutVersion?: number;
78 /** Output-only DNS server IP. */
79 dnsServerIp?: string;
80 /** Output-only canonical VMware Engine network name. */
81 vmwareEngineNetworkCanonical?: string;
82};
83
84export type EncryptionConfig = {
85 /** KMS key for CMEK. */
86 cryptoKeyName?: string;
87 /** Encryption type (`CMEK`, `LEGACY_CMEK`, `OTHER`). */
88 type?: vmwareengine.EncryptionConfigTypeEnum | (string & {});
89};
90
91export type Appliance = {
92 /** Fully qualified domain name. */
93 fqdn?: string;
94 /** Internal IP address. */
95 internalIp?: string;
96 /** Appliance state. */
97 state?: string;
98 /** Appliance version. */
99 version?: string;
100};
101
102export type PrivateCloudProps = {
103 /**
104 * Private cloud id (the `{privateCloud}` segment of
105 * `projects/{project}/locations/{location}/privateClouds/{privateCloud}`).
106 * If omitted, a unique RFC1035 name is generated. Immutable.
107 */
108 privateCloudId?: string;
109 /**
110 * Location. `STANDARD` and `TIME_LIMITED` clouds are zonal
111 * (`us-central1-a`); `STRETCHED` clouds are regional. Immutable.
112 * @default "us-central1-a"
113 */
114 location?: string;
115 /**
116 * Private cloud type.
117 * @default "STANDARD"
118 */
119 type?: vmwareengine.PrivateCloudTypeEnum | (string & {});
120 /**
121 * Management cluster. Required on create. `clusterId` and node type
122 * identity are immutable.
123 */
124 managementCluster?: ManagementCluster;
125 /**
126 * Consumer-project network configuration. `managementCidr` is
127 * required on create. Immutable.
128 */
129 networkConfig?: NetworkConfig;
130 /**
131 * Customer-managed encryption. Immutable.
132 */
133 encryptionConfig?: EncryptionConfig;
134 /**
135 * Human-readable description. Private clouds have no labels field, so
136 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
137 * from attributes. Description is the only in-place update.
138 */
139 description?: string;
140};
141
142export type PrivateCloud = Resource<
143 "GCP.VMwareEngine.PrivateCloud",
144 PrivateCloudProps,
145 {
146 /** Full resource name. */
147 name: string;
148 /** Private cloud id (last path segment). */
149 privateCloudId: string;
150 /** Project id. */
151 project: string;
152 /** Location id (zone or region). */
153 location: string;
154 /** Private cloud type. */
155 type: string | undefined;
156 /** Management cluster configuration. */
157 managementCluster: ManagementCluster | undefined;
158 /** Network configuration, including output-only DNS and layout. */
159 networkConfig: NetworkConfig | undefined;
160 /** Encryption configuration. */
161 encryptionConfig: EncryptionConfig | undefined;
162 /** HCX appliance. */
163 hcx: Appliance | undefined;
164 /** vCenter appliance. */
165 vcenter: Appliance | undefined;
166 /** NSX appliance. */
167 nsx: Appliance | undefined;
168 /** User description with the Alchemy ownership prefix stripped. */
169 description: string | undefined;
170 /** Server-reported state. */
171 state: string | undefined;
172 /** System-generated unique identifier. */
173 uid: string | undefined;
174 /** RFC3339 creation timestamp. */
175 createTime: string | undefined;
176 /** RFC3339 last-update timestamp. */
177 updateTime: string | undefined;
178 /** Time the cloud was scheduled for deletion. */
179 deleteTime: string | undefined;
180 /** Time the cloud will be irreversibly deleted. */
181 expireTime: string | undefined;
182 },
183 never,
184 Providers
185>;
186
187/**
188 * A Google Cloud VMware Engine private cloud. `STANDARD` and
189 * `TIME_LIMITED` clouds are zonal; `STRETCHED` clouds are regional.
190 * Creating a private cloud also creates its management cluster.
191 *
192 * Private clouds have no labels field, so Alchemy stamps ownership into
193 * the description for `list` / nuke. Type, location, management cluster
194 * identity, and network config are immutable. Description updates in
195 * place. Delete uses `delayHours=0` so billing stops immediately.
196 *
197 * Provisioning typically takes 30-120 minutes.
198 *
199 * ### Creating a PrivateCloud
200 * **Example:** Standard three-node cloud
201 * ```typescript
202 * const cloud = yield* GCP.VMwareEngine.PrivateCloud("Sddc", {
203 * location: "us-central1-a",
204 * type: "STANDARD",
205 * networkConfig: {
206 * managementCidr: "192.168.1.0/24",
207 * vmwareEngineNetwork: ven.name,
208 * },
209 * managementCluster: {
210 * clusterId: "mgmt",
211 * nodeTypeConfigs: {
212 * "standard-72": { nodeCount: 3 },
213 * },
214 * },
215 * description: "app sddc",
216 * });
217 * ```
218 *
219 * @resource
220 * @category VMwareEngine
221 */
222export const PrivateCloud = Resource<PrivateCloud>(
223 "GCP.VMwareEngine.PrivateCloud",
224);
225
226const resourceName = (
227 project: string,
228 location: string,
229 privateCloudId: string,
230) => `${parentOf(project, location)}/${COLLECTION}/${privateCloudId}`;
231
232const nodeTypeConfigsOf = (
233 value: vmwareengine.NodeTypeConfigMap | undefined,
234): Record<string, NodeTypeConfig | undefined> | undefined => {
235 if (value === undefined) return undefined;
236 return Object.fromEntries(
237 Object.entries(value).map(([key, config]) => [
238 key,
239 config === undefined
240 ? undefined
241 : {
242 customCoreCount: config.customCoreCount,
243 nodeCount: config.nodeCount,
244 },
245 ]),
246 );
247};
248
249const stretchedOf = (
250 value: vmwareengine.StretchedClusterConfig | undefined,
251): StretchedClusterConfig | undefined => {
252 if (value === undefined) return undefined;
253 return {
254 preferredLocation: value.preferredLocation,
255 secondaryLocation: value.secondaryLocation,
256 };
257};
258
259const managementClusterOf = (
260 value: vmwareengine.ManagementCluster | ManagementCluster | undefined,
261): ManagementCluster | undefined => {
262 if (value === undefined) return undefined;
263 return {
264 clusterId: value.clusterId,
265 nodeTypeConfigs: nodeTypeConfigsOf(value.nodeTypeConfigs),
266 stretchedClusterConfig: stretchedOf(value.stretchedClusterConfig),
267 };
268};
269
270const networkConfigOf = (
271 value: vmwareengine.NetworkConfig | NetworkConfig | undefined,
272): NetworkConfig | undefined => {
273 if (value === undefined) return undefined;
274 return {
275 managementCidr: value.managementCidr,
276 vmwareEngineNetwork: value.vmwareEngineNetwork,
277 managementIpAddressLayoutVersion: value.managementIpAddressLayoutVersion,
278 dnsServerIp: value.dnsServerIp,
279 vmwareEngineNetworkCanonical: value.vmwareEngineNetworkCanonical,
280 };
281};
282
283const encryptionOf = (
284 value: vmwareengine.EncryptionConfig | EncryptionConfig | undefined,
285): EncryptionConfig | undefined => {
286 if (value === undefined) return undefined;
287 return {
288 cryptoKeyName: value.cryptoKeyName,
289 type: value.type,
290 };
291};
292
293const applianceOf = (
294 value: vmwareengine.Hcx | vmwareengine.Vcenter | vmwareengine.Nsx | undefined,
295): Appliance | undefined => {
296 if (value === undefined) return undefined;
297 return {
298 fqdn: value.fqdn,
299 internalIp: value.internalIp,
300 state: value.state,
301 version: value.version,
302 };
303};
304
305const defaultManagementCluster = (
306 value: ManagementCluster | undefined,
307): ManagementCluster => ({
308 clusterId: value?.clusterId ?? DEFAULT_MGMT_CLUSTER_ID,
309 stretchedClusterConfig: value?.stretchedClusterConfig,
310 nodeTypeConfigs: value?.nodeTypeConfigs ?? {
311 [DEFAULT_NODE_TYPE]: { nodeCount: DEFAULT_NODE_COUNT },
312 },
313});
314
315const defaultNetworkConfig = (
316 project: string,
317 value: NetworkConfig | undefined,
318): NetworkConfig => ({
319 managementCidr: value?.managementCidr ?? DEFAULT_MANAGEMENT_CIDR,
320 vmwareEngineNetwork: value?.vmwareEngineNetwork
321 ? expandName(
322 value.vmwareEngineNetwork,
323 project,
324 DEFAULT_GLOBAL,
325 VEN_COLLECTION,
326 )
327 : undefined,
328});
329
330const toAttrs = (item: vmwareengine.PrivateCloud, project: string) => {
331 const name = item.name ?? "";
332 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
333 const ownership = parseOwnership(item.description);
334 return {
335 name,
336 privateCloudId: parsed.id,
337 project: parsed.project || project,
338 location: parsed.location,
339 type: item.type,
340 managementCluster: managementClusterOf(item.managementCluster),
341 networkConfig: networkConfigOf(item.networkConfig),
342 encryptionConfig: encryptionOf(item.encryptionConfig),
343 hcx: applianceOf(item.hcx),
344 vcenter: applianceOf(item.vcenter),
345 nsx: applianceOf(item.nsx),
346 description: ownership.text,
347 state: item.state,
348 uid: item.uid,
349 createTime: item.createTime,
350 updateTime: item.updateTime,
351 deleteTime: item.deleteTime,
352 expireTime: item.expireTime,
353 };
354};
355
356const getByName = (name: string) =>
357 vmwareengine
358 .getProjectsLocationsPrivateClouds({ name })
359 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
360
361export const PrivateCloudProvider = () =>
362 Provider.succeed(PrivateCloud, {
363 stables: [
364 "name",
365 "privateCloudId",
366 "project",
367 "location",
368 "uid",
369 "createTime",
370 ],
371
372 diff: Effect.fn(function* ({ news, olds, output }) {
373 if (!isResolved(news)) return undefined;
374 const previousType = olds?.type ?? output?.type ?? DEFAULT_TYPE;
375 const nextType = news.type ?? previousType;
376 const previousCidr =
377 olds?.networkConfig?.managementCidr ??
378 output?.networkConfig?.managementCidr ??
379 "";
380 const nextCidr = news.networkConfig?.managementCidr ?? previousCidr;
381 const previousVen = canonicalizeLink(
382 olds?.networkConfig?.vmwareEngineNetwork ??
383 output?.networkConfig?.vmwareEngineNetwork,
384 );
385 const nextVen = canonicalizeLink(news.networkConfig?.vmwareEngineNetwork);
386 const previousClusterId =
387 olds?.managementCluster?.clusterId ??
388 output?.managementCluster?.clusterId ??
389 "";
390 const nextClusterId =
391 news.managementCluster?.clusterId ?? previousClusterId;
392 return replaceOnIdentity({
393 previousId: olds?.privateCloudId ?? output?.privateCloudId,
394 nextId: news.privateCloudId
395 ? rfc1035(news.privateCloudId, "privatecloud")
396 : (olds?.privateCloudId ?? output?.privateCloudId),
397 previousLocation: normalizeLocation(
398 olds?.location ?? output?.location,
399 DEFAULT_ZONE,
400 ),
401 nextLocation: normalizeLocation(
402 news.location ?? olds?.location ?? output?.location,
403 DEFAULT_ZONE,
404 ),
405 extra:
406 previousType !== nextType ||
407 (previousCidr.length > 0 && previousCidr !== nextCidr) ||
408 (previousVen.length > 0 &&
409 nextVen.length > 0 &&
410 previousVen !== nextVen) ||
411 (previousClusterId.length > 0 &&
412 nextClusterId.length > 0 &&
413 previousClusterId !== nextClusterId) ||
414 !sameJson(
415 news.encryptionConfig,
416 olds?.encryptionConfig ?? output?.encryptionConfig,
417 ),
418 });
419 }),
420
421 read: Effect.fn(function* ({ id, olds, output }) {
422 const env = yield* GcpEnvironment.current;
423 const privateCloudId = yield* toPhysicalId(
424 id,
425 olds?.privateCloudId,
426 output?.privateCloudId,
427 "privatecloud",
428 );
429 const location = normalizeLocation(
430 olds?.location ?? output?.location,
431 DEFAULT_ZONE,
432 );
433 const name =
434 output?.name ?? resourceName(env.project, location, privateCloudId);
435 const existing = yield* getByName(name);
436 if (existing === undefined) return undefined;
437 const attrs = toAttrs(existing, env.project);
438 const { labels } = parseOwnership(existing.description);
439 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
440 }),
441
442 list: () =>
443 Effect.gen(function* () {
444 const env = yield* GcpEnvironment.current;
445 const items = yield* listAcrossLocations(
446 env.project,
447 env.region,
448 (parent) =>
449 collectPages(
450 vmwareengine.listProjectsLocationsPrivateClouds.pages({
451 parent,
452 pageSize: 1000,
453 }),
454 (page) => page.privateClouds,
455 ),
456 );
457 return items
458 .filter((item) => hasOwnershipMarker(item.description))
459 .map((item) => toAttrs(item, env.project));
460 }),
461
462 reconcile: Effect.fn(function* ({ id, news, output }) {
463 const env = yield* GcpEnvironment.current;
464 const privateCloudId = yield* toPhysicalId(
465 id,
466 news.privateCloudId,
467 output?.privateCloudId,
468 "privatecloud",
469 );
470 const location = normalizeLocation(
471 news.location ?? output?.location,
472 DEFAULT_ZONE,
473 );
474 const name = resourceName(env.project, location, privateCloudId);
475 const ownership = yield* createInternalLabels(id);
476 const desiredDescription = encodeOwnership(ownership, news.description);
477 const type = news.type ?? DEFAULT_TYPE;
478 const managementCluster = defaultManagementCluster(
479 news.managementCluster,
480 );
481 const networkConfig = defaultNetworkConfig(
482 env.project,
483 news.networkConfig,
484 );
485
486 let current = yield* getByName(output?.name ?? name);
487
488 if (current === undefined) {
489 const created = yield* vmwareengine
490 .createProjectsLocationsPrivateClouds({
491 parent: parentOf(env.project, location),
492 privateCloudId,
493 body: {
494 type,
495 description: desiredDescription,
496 managementCluster,
497 networkConfig,
498 encryptionConfig: news.encryptionConfig,
499 },
500 })
501 .pipe(
502 Effect.retry({
503 while: (error) => error._tag === "Conflict",
504 times: 5,
505 schedule: Schedule.spaced("2 seconds"),
506 }),
507 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
508 );
509 if (created !== undefined) {
510 yield* waitForOperation(created);
511 }
512 current = yield* waitUntilPresent(getByName(name), name);
513 }
514
515 if (current === undefined) {
516 return yield* new VmwareengineNotResolved({ name });
517 }
518
519 current = yield* waitUntilReady(
520 getByName(current.name ?? name),
521 current.name ?? name,
522 (item) => item.state,
523 );
524
525 const descriptionChanged =
526 (current.description ?? "") !== desiredDescription;
527 const updateMask = changedFields([["description", descriptionChanged]]);
528
529 if (updateMask.length > 0) {
530 const operation =
531 yield* vmwareengine.patchProjectsLocationsPrivateClouds({
532 name: current.name ?? name,
533 updateMask: updateMask.join(","),
534 body: {
535 name: current.name ?? name,
536 description: desiredDescription,
537 },
538 });
539 yield* waitForOperation(operation);
540 current = yield* waitUntilReady(
541 getByName(current.name ?? name),
542 current.name ?? name,
543 (item) => item.state,
544 );
545 }
546
547 return toAttrs(current, env.project);
548 }),
549
550 delete: Effect.fn(function* ({ output }) {
551 const operation = yield* vmwareengine
552 .deleteProjectsLocationsPrivateClouds({
553 name: output.name,
554 delayHours: 0,
555 force: true,
556 })
557 .pipe(
558 Effect.retry({
559 while: (error) => error._tag === "Conflict",
560 times: 8,
561 schedule: Schedule.spaced("2 seconds"),
562 }),
563 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
564 );
565 if (operation !== undefined) {
566 yield* waitForOperation(operation, { notFoundOk: true });
567 }
568 yield* waitUntilGone(getByName(output.name), output.name);
569 }),
570 });