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 expandParent,
21 fieldMask,
22 isOwned,
23 listAtLocation,
24 listAtNested,
25 listChildrenOf,
26 normalizeLocation,
27 parseName,
28 parseOwnership,
29 replaceOnIdentity,
30 rfc1035,
31 sameText,
32 textState,
33 toPhysicalId,
34 userLabels,
35 waitForOperation,
36 waitUntilExists,
37 waitUntilGone,
38} from "./internal.ts";
39import type {
40 VmwareNodeConfig,
41 VmwareNodePoolAutoscalingConfig,
42} from "./types.ts";
43
44const COLLECTION = "vmwareNodePools";
45const PARENT_COLLECTION = "vmwareClusters";
46
47export type VmwareClustersVmwareNodePoolProps = {
48 /**
49 * Parent VMware user cluster. Full name
50 * `projects/{project}/locations/{location}/vmwareClusters/{cluster}`
51 * or the cluster id (combined with `location`). Immutable — changing
52 * it replaces the node pool.
53 */
54 vmwareCluster: string;
55 /**
56 * Region used when `vmwareCluster` is a bare id.
57 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
58 */
59 location?: string;
60 /**
61 * Node pool id (the `{vmwareNodePool}` segment). If omitted, a unique
62 * RFC1123 name is generated. Max 40 characters. Immutable — changing
63 * it replaces the pool.
64 */
65 vmwareNodePoolId?: string;
66 /**
67 * Node configuration (image type, replicas, CPU, memory).
68 */
69 config: VmwareNodeConfig;
70 /**
71 * Display name. Node pools have no GCP labels field, so Alchemy stamps
72 * ownership into annotations and a `[alchemy …]` displayName prefix
73 * and strips both from attributes.
74 */
75 displayName?: string;
76 /**
77 * Anthos version for the node pool. Defaults to the user cluster version.
78 */
79 onPremVersion?: string;
80 /**
81 * Node pool autoscaling bounds.
82 */
83 nodePoolAutoscaling?: VmwareNodePoolAutoscalingConfig;
84 /**
85 * Kubernetes-style annotations. Alchemy ownership keys are merged in.
86 */
87 annotations?: Record<string, string>;
88 /**
89 * User labels stored as annotations (keys sanitized like GCP labels).
90 * Alchemy ownership labels are merged in automatically.
91 */
92 labels?: Record<string, string>;
93};
94
95export type VmwareClustersVmwareNodePool = Resource<
96 "GCP.GKEOnPrem.VmwareClustersVmwareNodePool",
97 VmwareClustersVmwareNodePoolProps,
98 {
99 /** Full resource name. */
100 name: string;
101 /** Node pool id (last path segment). */
102 vmwareNodePoolId: string;
103 /** Parent cluster resource name. */
104 vmwareCluster: string;
105 /** Project id. */
106 project: string;
107 /** Location id. */
108 location: string;
109 /** Node configuration. */
110 config: gkeonprem.VmwareNodeConfig | undefined;
111 /** User display name with the Alchemy ownership prefix stripped. */
112 displayName: string | undefined;
113 /** Anthos version. */
114 onPremVersion: string | undefined;
115 /** Autoscaling bounds. */
116 nodePoolAutoscaling: gkeonprem.VmwareNodePoolAutoscalingConfig | undefined;
117 /** User annotations (Alchemy ownership keys stripped). */
118 annotations: Record<string, string>;
119 /** User labels (Alchemy ownership keys stripped). */
120 labels: Record<string, string>;
121 /** Server-reported state. */
122 state: string | undefined;
123 /** Server-generated resource uid. */
124 uid: string | undefined;
125 /** Whether a change is in flight. */
126 reconciling: boolean | undefined;
127 /** Controller error message. */
128 errorMessage: string | undefined;
129 /** RFC3339 creation timestamp. */
130 createTime: string | undefined;
131 /** RFC3339 last-update timestamp. */
132 updateTime: string | undefined;
133 /** RFC3339 deletion timestamp. */
134 deleteTime: string | undefined;
135 /** Server etag. */
136 etag: string | undefined;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * A worker node pool on an Anthos on VMware user cluster.
144 *
145 * Node pools have no GCP labels field, so Alchemy stamps ownership into
146 * annotations and a `[alchemy …]` displayName prefix for `list` / nuke.
147 * Pool id, location, and parent cluster are identity — changing them
148 * replaces the pool. Config, display name, version, and autoscaling
149 * update in place.
150 *
151 * ### Creating a VMware Node Pool
152 * **Example:** Ubuntu worker pool
153 * ```typescript
154 * const pool = yield* GCP.GKEOnPrem.VmwareClustersVmwareNodePool("Workers", {
155 * vmwareCluster: cluster.name,
156 * config: {
157 * imageType: "ubuntu_containerd",
158 * replicas: "3",
159 * cpus: "4",
160 * memoryMb: "8192",
161 * },
162 * displayName: "app workers",
163 * });
164 * ```
165 *
166 * ### Updating a VMware Node Pool
167 * Re-declare the same logical id with changed props; the engine keeps the
168 * physical resource and updates it in place.
169 *
170 * **Example:** Scale replicas
171 * ```typescript
172 * const pool = yield* GCP.GKEOnPrem.VmwareClustersVmwareNodePool("Workers", {
173 * vmwareCluster: cluster.name,
174 * config: {
175 * imageType: "ubuntu_containerd",
176 * replicas: "5",
177 * cpus: "4",
178 * memoryMb: "8192",
179 * },
180 * displayName: "app workers v2",
181 * });
182 * ```
183 *
184 * @resource
185 * @category GKEOnPrem
186 */
187export const VmwareClustersVmwareNodePool =
188 Resource<VmwareClustersVmwareNodePool>(
189 "GCP.GKEOnPrem.VmwareClustersVmwareNodePool",
190 );
191
192const resourceName = (cluster: string, vmwareNodePoolId: string) =>
193 `${cluster}/${COLLECTION}/${vmwareNodePoolId}`;
194
195const toAttrs = (
196 item: gkeonprem.VmwareNodePool,
197 project: string,
198 region: string,
199) => {
200 const name = item.name ?? "";
201 const parsed = parseName(name, COLLECTION, region);
202 const ownership = parseOwnership(item.displayName);
203 const annotations = userLabels(item.annotations);
204 return {
205 name,
206 vmwareNodePoolId: parsed.id,
207 vmwareCluster: parsed.parent,
208 project: parsed.project || project,
209 location: parsed.location,
210 config: item.config,
211 displayName: ownership.text,
212 onPremVersion: item.onPremVersion,
213 nodePoolAutoscaling: item.nodePoolAutoscaling,
214 annotations,
215 labels: annotations,
216 state: textState(item.state),
217 uid: item.uid,
218 reconciling: item.reconciling,
219 errorMessage: item.status?.errorMessage,
220 createTime: item.createTime,
221 updateTime: item.updateTime,
222 deleteTime: item.deleteTime,
223 etag: item.etag,
224 };
225};
226
227const getByName = (name: string) =>
228 gkeonprem
229 .getProjectsLocationsVmwareClustersVmwareNodePools({
230 name,
231 view: "FULL",
232 })
233 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
234
235const listChildren = (parent: string) =>
236 collectPages(
237 gkeonprem.listProjectsLocationsVmwareClustersVmwareNodePools.pages({
238 parent,
239 pageSize: 1000,
240 view: "FULL",
241 }),
242 (page): readonly gkeonprem.VmwareNodePool[] | undefined =>
243 page.vmwareNodePools,
244 );
245
246const listOwned = (project: string, region: string) =>
247 listAtNested(project, region, `${PARENT_COLLECTION}/-`, listChildren).pipe(
248 Effect.flatMap((items) =>
249 items.length > 0
250 ? Effect.succeed(items)
251 : listChildrenOf(
252 listAtLocation(project, region, (parent) =>
253 collectPages(
254 gkeonprem.listProjectsLocationsVmwareClusters.pages({
255 parent,
256 pageSize: 1000,
257 view: "BASIC",
258 }),
259 (page): readonly gkeonprem.VmwareCluster[] | undefined =>
260 page.vmwareClusters,
261 ),
262 ),
263 (cluster: gkeonprem.VmwareCluster) => cluster.name,
264 listChildren,
265 ),
266 ),
267 Effect.map((items) =>
268 items.filter((item: gkeonprem.VmwareNodePool) =>
269 isOwned(item.annotations, item.displayName),
270 ),
271 ),
272 );
273
274export const VmwareClustersVmwareNodePoolProvider = () =>
275 Provider.succeed(VmwareClustersVmwareNodePool, {
276 stables: [
277 "name",
278 "vmwareNodePoolId",
279 "vmwareCluster",
280 "project",
281 "location",
282 "uid",
283 "createTime",
284 ],
285
286 diff: Effect.fn(function* ({ news, olds, output }) {
287 if (!isResolved(news)) return undefined;
288 const env = yield* GcpEnvironment.current;
289 return replaceOnIdentity({
290 previousId: olds?.vmwareNodePoolId ?? output?.vmwareNodePoolId,
291 nextId: news.vmwareNodePoolId
292 ? rfc1035(news.vmwareNodePoolId, "vmwarenodepool", VMWARE_NAME_LENGTH)
293 : (olds?.vmwareNodePoolId ?? output?.vmwareNodePoolId),
294 previousLocation: normalizeLocation(
295 olds?.location ?? output?.location,
296 env.region,
297 ),
298 nextLocation: normalizeLocation(
299 news.location ?? olds?.location ?? output?.location,
300 env.region,
301 ),
302 previousParent: olds?.vmwareCluster ?? output?.vmwareCluster,
303 nextParent: news.vmwareCluster,
304 });
305 }),
306
307 read: Effect.fn(function* ({ id, olds, output }) {
308 const env = yield* GcpEnvironment.current;
309 const vmwareNodePoolId = yield* toPhysicalId(
310 id,
311 olds?.vmwareNodePoolId,
312 output?.vmwareNodePoolId,
313 "vmwarenodepool",
314 VMWARE_NAME_LENGTH,
315 );
316 const location = normalizeLocation(
317 olds?.location ?? output?.location,
318 env.region,
319 );
320 const cluster = expandParent(
321 olds?.vmwareCluster ?? output?.vmwareCluster ?? "",
322 env.project,
323 location,
324 PARENT_COLLECTION,
325 );
326 const name = output?.name ?? resourceName(cluster, vmwareNodePoolId);
327 const existing = yield* getByName(name);
328 if (existing === undefined) return undefined;
329 const attrs = toAttrs(existing, env.project, env.region);
330 const fromName = parseOwnership(existing.displayName).labels;
331 const owned =
332 (yield* hasAlchemyLabels(id, tagRecord(existing.annotations))) ||
333 (yield* hasAlchemyLabels(id, fromName));
334 return owned ? attrs : Unowned(attrs);
335 }),
336
337 list: () =>
338 Effect.gen(function* () {
339 const env = yield* GcpEnvironment.current;
340 const items = yield* listOwned(env.project, env.region);
341 return items.map((item: gkeonprem.VmwareNodePool) =>
342 toAttrs(item, env.project, env.region),
343 );
344 }),
345
346 reconcile: Effect.fn(function* ({ id, news, output }) {
347 const env = yield* GcpEnvironment.current;
348 const vmwareNodePoolId = yield* toPhysicalId(
349 id,
350 news.vmwareNodePoolId,
351 output?.vmwareNodePoolId,
352 "vmwarenodepool",
353 VMWARE_NAME_LENGTH,
354 );
355 const location = normalizeLocation(
356 news.location ?? output?.location,
357 env.region,
358 );
359 const cluster = expandParent(
360 news.vmwareCluster,
361 env.project,
362 location,
363 PARENT_COLLECTION,
364 );
365 const name = resourceName(cluster, vmwareNodePoolId);
366 const ownership = yield* createInternalLabels(id);
367 const annotations = desiredAnnotations(
368 ownership,
369 news.labels,
370 news.annotations,
371 );
372 const displayName = encodeOwnership(ownership, news.displayName);
373 const body: gkeonprem.VmwareNodePool = {
374 config: news.config,
375 displayName,
376 onPremVersion: news.onPremVersion,
377 nodePoolAutoscaling: news.nodePoolAutoscaling,
378 annotations,
379 };
380
381 let current = yield* getByName(output?.name ?? name);
382
383 if (current === undefined) {
384 const created = yield* gkeonprem
385 .createProjectsLocationsVmwareClustersVmwareNodePools({
386 parent: cluster,
387 vmwareNodePoolId,
388 body,
389 })
390 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
391 if (created !== undefined) {
392 yield* waitForOperation(created);
393 }
394 current = yield* waitUntilExists(getByName(name), name);
395 }
396
397 if (current === undefined) {
398 return yield* new ResourceNotResolved({ name });
399 }
400
401 const mask = fieldMask([
402 differs(current.annotations, annotations) && "annotations",
403 !sameText(parseOwnership(current.displayName).text, news.displayName) &&
404 "displayName",
405 differs(current.config, news.config) && "config",
406 differs(current.onPremVersion, news.onPremVersion) && "onPremVersion",
407 differs(current.nodePoolAutoscaling, news.nodePoolAutoscaling) &&
408 "nodePoolAutoscaling",
409 ]);
410
411 if (mask.length > 0) {
412 const operation =
413 yield* gkeonprem.patchProjectsLocationsVmwareClustersVmwareNodePools({
414 name: current.name ?? name,
415 updateMask: mask,
416 body: {
417 ...body,
418 etag: current.etag,
419 },
420 });
421 yield* waitForOperation(operation);
422 current = yield* waitUntilExists(
423 getByName(current.name ?? name),
424 current.name ?? name,
425 );
426 }
427
428 if (current === undefined) {
429 return yield* new ResourceNotResolved({ name });
430 }
431 return toAttrs(current, env.project, env.region);
432 }),
433
434 delete: Effect.fn(function* ({ output }) {
435 const operation = yield* gkeonprem
436 .deleteProjectsLocationsVmwareClustersVmwareNodePools({
437 name: output.name,
438 allowMissing: true,
439 etag: output.etag,
440 })
441 .pipe(
442 Effect.retry({
443 while: (error) => error._tag === "Conflict",
444 times: 8,
445 schedule: Schedule.spaced("2 seconds"),
446 }),
447 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
448 );
449 if (operation !== undefined) {
450 yield* waitForOperation(operation, { notFoundOk: true });
451 }
452 yield* waitUntilGone(getByName(output.name), output.name);
453 }),
454 });