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 fieldMask,
20 isOwned,
21 listAtLocation,
22 membershipName,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 parseOwnership,
27 replaceOnIdentity,
28 rfc1035,
29 sameText,
30 textState,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36} from "./internal.ts";
37import type {
38 BareMetalClusterOperationsConfig,
39 BareMetalClusterUpgradePolicy,
40 BareMetalControlPlaneConfig,
41 BareMetalLoadBalancerConfig,
42 BareMetalMaintenanceConfig,
43 BareMetalNetworkConfig,
44 BareMetalNodeAccessConfig,
45 BareMetalOsEnvironmentConfig,
46 BareMetalProxyConfig,
47 BareMetalSecurityConfig,
48 BareMetalStorageConfig,
49 BareMetalWorkloadNodeConfig,
50 BinaryAuthorization,
51} from "./types.ts";
52
53const COLLECTION = "bareMetalClusters";
54
55export type BareMetalClusterProps = {
56 /**
57 * Cluster id (the `{bareMetalCluster}` segment). If omitted, a unique
58 * RFC1035 name is generated from the stack, stage, and logical id.
59 * Immutable — changing it replaces the cluster.
60 */
61 bareMetalClusterId?: 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 bare metal version for the user cluster.
76 */
77 bareMetalVersion: string;
78 /**
79 * Control plane configuration, including the control plane node pool.
80 */
81 controlPlane: BareMetalControlPlaneConfig;
82 /**
83 * Local PV storage configuration.
84 */
85 storage: BareMetalStorageConfig;
86 /**
87 * Cluster network configuration (island-mode CIDRs, SR-IOV, …).
88 */
89 networkConfig: BareMetalNetworkConfig;
90 /**
91 * Load balancer configuration (MetalLB, BGP, or manual).
92 */
93 loadBalancer: BareMetalLoadBalancerConfig;
94 /**
95 * Human-readable description. Clusters have no GCP labels field, so
96 * Alchemy stamps ownership into annotations and a `[alchemy …]`
97 * description prefix and strips both from attributes.
98 */
99 description?: string;
100 /**
101 * Security / bootstrap RBAC configuration.
102 */
103 securityConfig?: BareMetalSecurityConfig;
104 /**
105 * HTTP(S) proxy used by cluster machines.
106 */
107 proxy?: BareMetalProxyConfig;
108 /**
109 * Cluster upgrade policy.
110 */
111 upgradePolicy?: BareMetalClusterUpgradePolicy;
112 /**
113 * SSH login user for node machines.
114 */
115 nodeAccessConfig?: BareMetalNodeAccessConfig;
116 /**
117 * CIDRs whose nodes are placed into maintenance.
118 */
119 maintenanceConfig?: BareMetalMaintenanceConfig;
120 /**
121 * Binary Authorization evaluation mode.
122 */
123 binaryAuthorization?: BinaryAuthorization;
124 /**
125 * Workload node defaults (max pods, container runtime).
126 */
127 nodeConfig?: BareMetalWorkloadNodeConfig;
128 /**
129 * Observability: application logs and metrics.
130 */
131 clusterOperations?: BareMetalClusterOperationsConfig;
132 /**
133 * OS environment settings (package repo).
134 */
135 osEnvironmentConfig?: BareMetalOsEnvironmentConfig;
136 /**
137 * Kubernetes-style annotations. Alchemy ownership keys are merged in.
138 */
139 annotations?: Record<string, string>;
140 /**
141 * User labels stored as annotations (keys sanitized like GCP labels).
142 * Alchemy ownership labels are merged in automatically.
143 */
144 labels?: Record<string, string>;
145};
146
147export type BareMetalCluster = Resource<
148 "GCP.GKEOnPrem.BareMetalCluster",
149 BareMetalClusterProps,
150 {
151 /** Full resource name. */
152 name: string;
153 /** Cluster id (last path segment). */
154 bareMetalClusterId: string;
155 /** Project id. */
156 project: string;
157 /** Location id. */
158 location: string;
159 /** Admin cluster Hub membership. */
160 adminClusterMembership: string | undefined;
161 /** Output-only admin cluster resource name. */
162 adminClusterName: string | undefined;
163 /** Anthos on-prem version. */
164 bareMetalVersion: string | undefined;
165 /** Control plane configuration. */
166 controlPlane: gkeonprem.BareMetalControlPlaneConfig | undefined;
167 /** Storage configuration. */
168 storage: gkeonprem.BareMetalStorageConfig | undefined;
169 /** Network configuration. */
170 networkConfig: gkeonprem.BareMetalNetworkConfig | undefined;
171 /** Load balancer configuration. */
172 loadBalancer: gkeonprem.BareMetalLoadBalancerConfig | undefined;
173 /** User description with the Alchemy ownership prefix stripped. */
174 description: string | undefined;
175 /** Security configuration. */
176 securityConfig: gkeonprem.BareMetalSecurityConfig | undefined;
177 /** Proxy configuration. */
178 proxy: gkeonprem.BareMetalProxyConfig | undefined;
179 /** Upgrade policy. */
180 upgradePolicy: gkeonprem.BareMetalClusterUpgradePolicy | undefined;
181 /** Node access configuration. */
182 nodeAccessConfig: gkeonprem.BareMetalNodeAccessConfig | undefined;
183 /** Maintenance configuration. */
184 maintenanceConfig: gkeonprem.BareMetalMaintenanceConfig | undefined;
185 /** Binary Authorization configuration. */
186 binaryAuthorization: gkeonprem.BinaryAuthorization | undefined;
187 /** Workload node defaults. */
188 nodeConfig: gkeonprem.BareMetalWorkloadNodeConfig | undefined;
189 /** Cluster operations configuration. */
190 clusterOperations: gkeonprem.BareMetalClusterOperationsConfig | undefined;
191 /** OS environment configuration. */
192 osEnvironmentConfig: gkeonprem.BareMetalOsEnvironmentConfig | undefined;
193 /** User annotations (Alchemy ownership keys stripped). */
194 annotations: Record<string, string>;
195 /** User labels (Alchemy ownership keys stripped). */
196 labels: Record<string, string>;
197 /** Output-only Kubernetes API endpoint. */
198 endpoint: string | undefined;
199 /** Output-only Fleet membership. */
200 fleet: gkeonprem.Fleet | undefined;
201 /** Local CR name on the admin cluster. */
202 localName: string | undefined;
203 /** Local namespace on the admin cluster. */
204 localNamespace: string | undefined;
205 /** Server-reported state. */
206 state: string | undefined;
207 /** Server-generated resource uid. */
208 uid: string | undefined;
209 /** Whether a change is in flight. */
210 reconciling: boolean | undefined;
211 /** Controller error message. */
212 errorMessage: string | undefined;
213 /** RFC3339 creation timestamp. */
214 createTime: string | undefined;
215 /** RFC3339 last-update timestamp. */
216 updateTime: string | undefined;
217 /** RFC3339 deletion timestamp. */
218 deleteTime: string | undefined;
219 /** Server etag. */
220 etag: string | undefined;
221 },
222 never,
223 Providers
224>;
225
226/**
227 * An Anthos on bare metal user cluster registered with the GKE On-Prem
228 * API. Creating a cluster requires a connected admin cluster (Hub
229 * membership) and on-prem hardware.
230 *
231 * Clusters have no GCP labels field, so Alchemy stamps ownership into
232 * annotations and a `[alchemy …]` description prefix for `list` / nuke.
233 * Cluster id, location, and admin membership are identity — changing
234 * them replaces the cluster. Other fields update in place.
235 *
236 * ### Creating a Bare Metal Cluster
237 * **Example:** User cluster with MetalLB
238 * ```typescript
239 * const cluster = yield* GCP.GKEOnPrem.BareMetalCluster("Workload", {
240 * adminClusterMembership:
241 * "projects/my-project/locations/global/memberships/admin",
242 * bareMetalVersion: "1.28.0-gke.1",
243 * controlPlane: {
244 * controlPlaneNodePoolConfig: {
245 * nodePoolConfig: {
246 * nodeConfigs: [{ nodeIp: "10.200.0.2" }],
247 * },
248 * },
249 * },
250 * storage: {
251 * lvpShareConfig: {
252 * lvpConfig: { path: "/mnt/localpv-share", storageClass: "local-shared" },
253 * },
254 * lvpNodeMountsConfig: {
255 * path: "/mnt/localpv-disk",
256 * storageClass: "local-disks",
257 * },
258 * },
259 * networkConfig: {
260 * islandModeCidr: {
261 * serviceAddressCidrBlocks: ["10.96.0.0/12"],
262 * podAddressCidrBlocks: ["192.168.0.0/16"],
263 * },
264 * },
265 * loadBalancer: {
266 * vipConfig: { controlPlaneVip: "10.200.0.8", ingressVip: "10.200.0.9" },
267 * metalLbConfig: {
268 * addressPools: [{ pool: "pool-1", addresses: ["10.200.0.10-10.200.0.20"] }],
269 * },
270 * },
271 * description: "app user cluster",
272 * });
273 * ```
274 *
275 * ### Updating a Bare Metal Cluster
276 * Re-declare the same logical id with changed props; the engine keeps the
277 * physical resource and updates it in place.
278 *
279 * **Example:** Description and version
280 * ```typescript
281 * const cluster = yield* GCP.GKEOnPrem.BareMetalCluster("Workload", {
282 * adminClusterMembership:
283 * "projects/my-project/locations/global/memberships/admin",
284 * bareMetalVersion: "1.29.0-gke.1",
285 * controlPlane: {
286 * controlPlaneNodePoolConfig: {
287 * nodePoolConfig: {
288 * nodeConfigs: [{ nodeIp: "10.200.0.2" }],
289 * },
290 * },
291 * },
292 * storage: {
293 * lvpShareConfig: {
294 * lvpConfig: { path: "/mnt/localpv-share", storageClass: "local-shared" },
295 * },
296 * lvpNodeMountsConfig: {
297 * path: "/mnt/localpv-disk",
298 * storageClass: "local-disks",
299 * },
300 * },
301 * networkConfig: {
302 * islandModeCidr: {
303 * serviceAddressCidrBlocks: ["10.96.0.0/12"],
304 * podAddressCidrBlocks: ["192.168.0.0/16"],
305 * },
306 * },
307 * loadBalancer: {
308 * vipConfig: { controlPlaneVip: "10.200.0.8", ingressVip: "10.200.0.9" },
309 * metalLbConfig: {
310 * addressPools: [{ pool: "pool-1", addresses: ["10.200.0.10-10.200.0.20"] }],
311 * },
312 * },
313 * description: "app user cluster v2",
314 * });
315 * ```
316 *
317 * @resource
318 * @category GKEOnPrem
319 */
320export const BareMetalCluster = Resource<BareMetalCluster>(
321 "GCP.GKEOnPrem.BareMetalCluster",
322);
323
324const resourceName = (
325 project: string,
326 location: string,
327 bareMetalClusterId: string,
328) => `${parentOf(project, location)}/${COLLECTION}/${bareMetalClusterId}`;
329
330const toAttrs = (
331 item: gkeonprem.BareMetalCluster,
332 project: string,
333 region: string,
334) => {
335 const name = item.name ?? "";
336 const parsed = parseName(name, COLLECTION, region);
337 const ownership = parseOwnership(item.description);
338 const annotations = userLabels(item.annotations);
339 return {
340 name,
341 bareMetalClusterId: parsed.id,
342 project: parsed.project || project,
343 location: parsed.location,
344 adminClusterMembership: item.adminClusterMembership,
345 adminClusterName: item.adminClusterName,
346 bareMetalVersion: item.bareMetalVersion,
347 controlPlane: item.controlPlane,
348 storage: item.storage,
349 networkConfig: item.networkConfig,
350 loadBalancer: item.loadBalancer,
351 description: ownership.text,
352 securityConfig: item.securityConfig,
353 proxy: item.proxy,
354 upgradePolicy: item.upgradePolicy,
355 nodeAccessConfig: item.nodeAccessConfig,
356 maintenanceConfig: item.maintenanceConfig,
357 binaryAuthorization: item.binaryAuthorization,
358 nodeConfig: item.nodeConfig,
359 clusterOperations: item.clusterOperations,
360 osEnvironmentConfig: item.osEnvironmentConfig,
361 annotations,
362 labels: annotations,
363 endpoint: item.endpoint,
364 fleet: item.fleet,
365 localName: item.localName,
366 localNamespace: item.localNamespace,
367 state: textState(item.state),
368 uid: item.uid,
369 reconciling: item.reconciling,
370 errorMessage: item.status?.errorMessage,
371 createTime: item.createTime,
372 updateTime: item.updateTime,
373 deleteTime: item.deleteTime,
374 etag: item.etag,
375 };
376};
377
378const getByName = (name: string) =>
379 gkeonprem
380 .getProjectsLocationsBareMetalClusters({ name, view: "FULL" })
381 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
382
383const listOwned = (project: string, region: string) =>
384 listAtLocation(project, region, (parent) =>
385 collectPages(
386 gkeonprem.listProjectsLocationsBareMetalClusters.pages({
387 parent,
388 pageSize: 1000,
389 view: "FULL",
390 }),
391 (page): readonly gkeonprem.BareMetalCluster[] | undefined =>
392 page.bareMetalClusters,
393 ),
394 ).pipe(
395 Effect.map((items) =>
396 items.filter((item: gkeonprem.BareMetalCluster) =>
397 isOwned(item.annotations, item.description),
398 ),
399 ),
400 );
401
402const toBody = (
403 news: BareMetalClusterProps,
404 annotations: Record<string, string>,
405 description: string,
406 membership: string,
407): gkeonprem.BareMetalCluster => ({
408 adminClusterMembership: membership,
409 bareMetalVersion: news.bareMetalVersion,
410 controlPlane: news.controlPlane,
411 storage: news.storage,
412 networkConfig: news.networkConfig,
413 loadBalancer: news.loadBalancer,
414 description,
415 securityConfig: news.securityConfig,
416 proxy: news.proxy,
417 upgradePolicy: news.upgradePolicy,
418 nodeAccessConfig: news.nodeAccessConfig,
419 maintenanceConfig: news.maintenanceConfig,
420 binaryAuthorization: news.binaryAuthorization,
421 nodeConfig: news.nodeConfig,
422 clusterOperations: news.clusterOperations,
423 osEnvironmentConfig: news.osEnvironmentConfig,
424 annotations,
425});
426
427export const BareMetalClusterProvider = () =>
428 Provider.succeed(BareMetalCluster, {
429 stables: [
430 "name",
431 "bareMetalClusterId",
432 "project",
433 "location",
434 "uid",
435 "createTime",
436 ],
437
438 diff: Effect.fn(function* ({ news, olds, output }) {
439 if (!isResolved(news)) return undefined;
440 const env = yield* GcpEnvironment.current;
441 const previousMembership =
442 olds?.adminClusterMembership ?? output?.adminClusterMembership;
443 const nextMembership = news.adminClusterMembership;
444 return replaceOnIdentity({
445 previousId: olds?.bareMetalClusterId ?? output?.bareMetalClusterId,
446 nextId: news.bareMetalClusterId
447 ? rfc1035(news.bareMetalClusterId, "baremetalcluster")
448 : (olds?.bareMetalClusterId ?? output?.bareMetalClusterId),
449 previousLocation: normalizeLocation(
450 olds?.location ?? output?.location,
451 env.region,
452 ),
453 nextLocation: normalizeLocation(
454 news.location ?? olds?.location ?? output?.location,
455 env.region,
456 ),
457 extra:
458 previousMembership !== undefined &&
459 nextMembership !== undefined &&
460 previousMembership !== nextMembership &&
461 !previousMembership.endsWith(`/${nextMembership}`),
462 });
463 }),
464
465 read: Effect.fn(function* ({ id, olds, output }) {
466 const env = yield* GcpEnvironment.current;
467 const bareMetalClusterId = yield* toPhysicalId(
468 id,
469 olds?.bareMetalClusterId,
470 output?.bareMetalClusterId,
471 "baremetalcluster",
472 );
473 const location = normalizeLocation(
474 olds?.location ?? output?.location,
475 env.region,
476 );
477 const name =
478 output?.name ?? resourceName(env.project, location, bareMetalClusterId);
479 const existing = yield* getByName(name);
480 if (existing === undefined) return undefined;
481 const attrs = toAttrs(existing, env.project, env.region);
482 const fromDescription = parseOwnership(existing.description).labels;
483 const owned =
484 (yield* hasAlchemyLabels(id, tagRecord(existing.annotations))) ||
485 (yield* hasAlchemyLabels(id, fromDescription));
486 return owned ? attrs : Unowned(attrs);
487 }),
488
489 list: () =>
490 Effect.gen(function* () {
491 const env = yield* GcpEnvironment.current;
492 const items = yield* listOwned(env.project, env.region);
493 return items.map((item: gkeonprem.BareMetalCluster) =>
494 toAttrs(item, env.project, env.region),
495 );
496 }),
497
498 reconcile: Effect.fn(function* ({ id, news, output }) {
499 const env = yield* GcpEnvironment.current;
500 const bareMetalClusterId = yield* toPhysicalId(
501 id,
502 news.bareMetalClusterId,
503 output?.bareMetalClusterId,
504 "baremetalcluster",
505 );
506 const location = normalizeLocation(
507 news.location ?? output?.location,
508 env.region,
509 );
510 const name = resourceName(env.project, location, bareMetalClusterId);
511 const ownership = yield* createInternalLabels(id);
512 const annotations = desiredAnnotations(
513 ownership,
514 news.labels,
515 news.annotations,
516 );
517 const description = encodeOwnership(ownership, news.description);
518 const membership = membershipName(
519 news.adminClusterMembership,
520 env.project,
521 );
522 const body = toBody(news, annotations, description, membership);
523
524 let current = yield* getByName(output?.name ?? name);
525
526 if (current === undefined) {
527 const created = yield* gkeonprem
528 .createProjectsLocationsBareMetalClusters({
529 parent: parentOf(env.project, location),
530 bareMetalClusterId,
531 body,
532 })
533 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
534 if (created !== undefined) {
535 yield* waitForOperation(created);
536 }
537 current = yield* waitUntilExists(getByName(name), name);
538 }
539
540 if (current === undefined) {
541 return yield* new ResourceNotResolved({ name });
542 }
543
544 const mask = fieldMask([
545 differs(current.annotations, annotations) && "annotations",
546 !sameText(parseOwnership(current.description).text, news.description) &&
547 "description",
548 differs(current.bareMetalVersion, news.bareMetalVersion) &&
549 "bareMetalVersion",
550 differs(current.controlPlane, news.controlPlane) && "controlPlane",
551 differs(current.storage, news.storage) && "storage",
552 differs(current.networkConfig, news.networkConfig) && "networkConfig",
553 differs(current.loadBalancer, news.loadBalancer) && "loadBalancer",
554 differs(current.securityConfig, news.securityConfig) &&
555 "securityConfig",
556 differs(current.proxy, news.proxy) && "proxy",
557 differs(current.upgradePolicy, news.upgradePolicy) && "upgradePolicy",
558 differs(current.nodeAccessConfig, news.nodeAccessConfig) &&
559 "nodeAccessConfig",
560 differs(current.maintenanceConfig, news.maintenanceConfig) &&
561 "maintenanceConfig",
562 differs(current.binaryAuthorization, news.binaryAuthorization) &&
563 "binaryAuthorization",
564 differs(current.nodeConfig, news.nodeConfig) && "nodeConfig",
565 differs(current.clusterOperations, news.clusterOperations) &&
566 "clusterOperations",
567 differs(current.osEnvironmentConfig, news.osEnvironmentConfig) &&
568 "osEnvironmentConfig",
569 ]);
570
571 if (mask.length > 0) {
572 const operation =
573 yield* gkeonprem.patchProjectsLocationsBareMetalClusters({
574 name: current.name ?? name,
575 updateMask: mask,
576 body: {
577 ...body,
578 etag: current.etag,
579 },
580 });
581 yield* waitForOperation(operation);
582 current = yield* waitUntilExists(
583 getByName(current.name ?? name),
584 current.name ?? name,
585 );
586 }
587
588 if (current === undefined) {
589 return yield* new ResourceNotResolved({ name });
590 }
591 return toAttrs(current, env.project, env.region);
592 }),
593
594 delete: Effect.fn(function* ({ output }) {
595 const operation = yield* gkeonprem
596 .deleteProjectsLocationsBareMetalClusters({
597 name: output.name,
598 allowMissing: true,
599 force: true,
600 etag: output.etag,
601 })
602 .pipe(
603 Effect.retry({
604 while: (error) => error._tag === "Conflict",
605 times: 8,
606 schedule: Schedule.spaced("2 seconds"),
607 }),
608 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
609 );
610 if (operation !== undefined) {
611 yield* waitForOperation(operation, { notFoundOk: true });
612 }
613 yield* waitUntilGone(getByName(output.name), output.name);
614 }),
615 });