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