1import { ignoredCodes } from "./internal.ts";
2import * as compute from "@distilled.cloud/gcp/compute_v1";
3import { waitZoneOperation } from "./operations.ts";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 alchemyLabelKeys,
16 createInternalLabels,
17 hasAlchemyLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const DEFAULT_ZONE = "us-central1-a";
22const DEFAULT_MAINTENANCE_POLICY = "DEFAULT";
23const MAX_NAME_LENGTH = 63;
24
25export type NodeGroupMaintenancePolicy =
26 | compute.NodeGroupMaintenancePolicyEnum
27 | (string & {});
28export type NodeGroupMaintenanceInterval =
29 | compute.NodeGroupMaintenanceIntervalEnum
30 | (string & {});
31export type NodeGroupAutoscalingPolicy = compute.NodeGroupAutoscalingPolicy;
32export type NodeGroupMaintenanceWindow = compute.NodeGroupMaintenanceWindow;
33export type NodeGroupShareSettings = compute.ShareSettings;
34
35export type NodeGroupProps = {
36 /**
37 * Group name (RFC1035, 1-63 characters). If omitted, a unique name is
38 * generated from the stack, stage, and logical id. Immutable — changing
39 * it replaces the group.
40 */
41 nodeGroupName?: string;
42 /**
43 * Zone the group lives in. Immutable — changing it replaces the group.
44 * `US-CENTRAL1-A` is accepted and normalized to `us-central1-a`.
45 * @default "us-central1-a"
46 */
47 zone?: string;
48 /**
49 * Node template URL or name used to create nodes. Required. Updated in
50 * place via `setNodeTemplate`.
51 */
52 nodeTemplate: string;
53 /**
54 * Initial number of nodes. Required on create; later size changes use
55 * `addNodes` / `deleteNodes` and are not applied from this field.
56 * @default 0
57 */
58 initialNodeCount?: number;
59 /**
60 * Optional description. Node groups have no labels field, so Alchemy
61 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
62 * in a `[alchemy …]` prefix for `list` / nuke. Updated in place via
63 * `patch`.
64 */
65 description?: string;
66 /**
67 * How instances are handled during host maintenance.
68 * @default "DEFAULT"
69 */
70 maintenancePolicy?: NodeGroupMaintenancePolicy;
71 /**
72 * Daily maintenance window start time (`00:00`, `04:00`, …).
73 */
74 maintenanceWindow?: NodeGroupMaintenanceWindow;
75 /**
76 * Planned-maintenance frequency (`AS_NEEDED` or `RECURRENT`).
77 */
78 maintenanceInterval?: NodeGroupMaintenanceInterval;
79 /**
80 * Autoscaling policy. Updated in place via `patch`.
81 */
82 autoscalingPolicy?: NodeGroupAutoscalingPolicy;
83 /**
84 * Share settings for this node group. Updated in place via `patch`.
85 */
86 shareSettings?: NodeGroupShareSettings;
87};
88
89export type NodeGroup = Resource<
90 "GCP.Compute.NodeGroup",
91 NodeGroupProps,
92 {
93 /** Group name. */
94 nodeGroupName: string;
95 /** Project id. */
96 project: string;
97 /** Zone short name (`us-central1-a`). */
98 zone: string;
99 /** Node template URL. */
100 nodeTemplate: string | undefined;
101 /** User description with the Alchemy ownership prefix stripped. */
102 description: string | undefined;
103 /** Number of nodes currently in the group. */
104 size: number | undefined;
105 /** Maintenance policy. */
106 maintenancePolicy: string | undefined;
107 /** Maintenance window. */
108 maintenanceWindow: NodeGroupMaintenanceWindow | undefined;
109 /** Maintenance interval. */
110 maintenanceInterval: string | undefined;
111 /** Autoscaling policy. */
112 autoscalingPolicy: NodeGroupAutoscalingPolicy | undefined;
113 /** Share settings. */
114 shareSettings: NodeGroupShareSettings | undefined;
115 /** Group status. */
116 status: string | undefined;
117 /** Optimistic-locking fingerprint. */
118 fingerprint: string | undefined;
119 /** Server-assigned numeric id. */
120 nodeGroupId: string | undefined;
121 /** Resource self-link. */
122 selfLink: string | undefined;
123 /** RFC3339 creation timestamp. */
124 creationTimestamp: string | undefined;
125 /** Resource kind. */
126 kind: string | undefined;
127 },
128 never,
129 Providers
130>;
131
132/**
133 * A zonal Compute Engine sole-tenant node group.
134 *
135 * Node groups allocate dedicated physical servers from a node template.
136 * Name and zone are immutable. Description, maintenance policy, window,
137 * interval, autoscaling, and share settings update in place via
138 * `nodeGroups.patch`. The node template is swapped with
139 * `setNodeTemplate`. Compute NodeGroup has no labels field — Alchemy
140 * stamps ownership into the description so nuke can find leaked groups.
141 *
142 * ### Creating a Node Group
143 * **Example:** Generated name from a template
144 * ```typescript
145 * const group = yield* GCP.Compute.NodeGroup("SoleTenant", {
146 * zone: "us-central1-a",
147 * nodeTemplate: template.selfLink,
148 * initialNodeCount: 1,
149 * description: "prod sole tenant",
150 * });
151 * ```
152 *
153 * **Example:** Autoscaled group
154 * ```typescript
155 * const group = yield* GCP.Compute.NodeGroup("SoleTenant", {
156 * nodeTemplate: template.nodeTemplateName,
157 * initialNodeCount: 0,
158 * autoscalingPolicy: { mode: "ON", minNodes: 0, maxNodes: 3 },
159 * });
160 * ```
161 *
162 * @resource
163 * @category Compute
164 */
165export const NodeGroup = Resource<NodeGroup>("GCP.Compute.NodeGroup");
166
167export class NodeGroupNotResolved extends Data.TaggedError(
168 "GCP.Compute.NodeGroupNotResolved",
169)<{
170 nodeGroupName: string;
171 zone: string;
172}> {}
173
174export class NodeGroupStillExists extends Data.TaggedError(
175 "GCP.Compute.NodeGroupStillExists",
176)<{
177 nodeGroupName: string;
178}> {}
179
180const lastSegment = (value: string | undefined): string => {
181 if (value === undefined || value.length === 0) return "";
182 const trimmed = value.replace(/\/+$/, "");
183 const parts = trimmed.split("/");
184 return parts[parts.length - 1] || trimmed;
185};
186
187const normalizeZone = (zone: string | undefined) =>
188 lastSegment(zone ?? DEFAULT_ZONE).toLowerCase();
189
190const regionOfZone = (zone: string) => {
191 const parts = zone.split("-");
192 return parts.length >= 3 ? parts.slice(0, 2).join("-") : zone;
193};
194
195const rfc1035 = (name: string): string => {
196 let next = name
197 .toLowerCase()
198 .replace(/[^a-z0-9-]/g, "-")
199 .replace(/-+/g, "-")
200 .replace(/^-+/, "")
201 .replace(/-+$/, "");
202 if (!/^[a-z]/.test(next)) next = `n${next}`;
203 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
204 return next.length > 0 ? next : "group";
205};
206
207const toName = (id: string, name: string | undefined, existing?: string) =>
208 Effect.gen(function* () {
209 if (name !== undefined) return name;
210 if (existing !== undefined) return existing;
211 return rfc1035(
212 yield* createPhysicalName({
213 id,
214 maxLength: MAX_NAME_LENGTH,
215 lowercase: true,
216 }),
217 );
218 });
219
220const encodeDescription = (
221 labels: Record<string, string>,
222 description: string | undefined,
223): string => {
224 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
225 return description ? `${marker}\n${description}` : marker;
226};
227
228const parseDescription = (
229 description: string | undefined,
230): {
231 labels: Record<string, string>;
232 description: string | undefined;
233} => {
234 if (!description?.startsWith("[alchemy ")) {
235 return { labels: {}, description };
236 }
237 const end = description.indexOf("]");
238 if (end < 0) return { labels: {}, description };
239 const labels: Record<string, string> = {};
240 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
241 const eq = part.indexOf("=");
242 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
243 }
244 const rest = description.slice(end + 1).replace(/^\n/, "");
245 return { labels, description: rest.length > 0 ? rest : undefined };
246};
247
248const hasOwnershipMarker = (description: string | undefined) =>
249 Object.keys(parseDescription(description).labels).some((key) =>
250 key.startsWith("alchemy-"),
251 );
252
253const maintenanceOf = (value: string | undefined) =>
254 (value ?? DEFAULT_MAINTENANCE_POLICY).toUpperCase();
255
256const nodeTemplateUrl = (project: string, zone: string, value: string) => {
257 if (value.includes("/")) return value;
258 return `projects/${project}/regions/${regionOfZone(zone)}/nodeTemplates/${value}`;
259};
260
261const sameJson = (left: unknown, right: unknown) =>
262 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
263
264const toAttrs = (
265 group: compute.NodeGroup,
266 project: string,
267): NodeGroup["Attributes"] => {
268 const parsed = parseDescription(group.description);
269 return {
270 nodeGroupName: group.name ?? "",
271 project,
272 zone: normalizeZone(group.zone),
273 nodeTemplate: group.nodeTemplate,
274 description: parsed.description,
275 size: group.size,
276 maintenancePolicy: group.maintenancePolicy,
277 maintenanceWindow: group.maintenanceWindow,
278 maintenanceInterval: group.maintenanceInterval,
279 autoscalingPolicy: group.autoscalingPolicy,
280 shareSettings: group.shareSettings,
281 status: group.status,
282 fingerprint: group.fingerprint,
283 nodeGroupId: group.id,
284 selfLink: group.selfLink,
285 creationTimestamp: group.creationTimestamp,
286 kind: group.kind,
287 };
288};
289
290const getByName = (project: string, zone: string, nodeGroup: string) =>
291 compute
292 .getNodeGroups({ project, zone, nodeGroup })
293 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
294
295const awaitResource = (project: string, zone: string, nodeGroupName: string) =>
296 getByName(project, zone, nodeGroupName).pipe(
297 Effect.flatMap((group) =>
298 group !== undefined
299 ? Effect.succeed(group)
300 : Effect.fail(new NodeGroupNotResolved({ nodeGroupName, zone })),
301 ),
302 Effect.retry({
303 while: (error) => error._tag === "GCP.Compute.NodeGroupNotResolved",
304 times: 8,
305 schedule: Schedule.spaced("2 seconds"),
306 }),
307 );
308
309const waitUntilGone = (project: string, zone: string, nodeGroupName: string) =>
310 getByName(project, zone, nodeGroupName).pipe(
311 Effect.flatMap((group) =>
312 group === undefined
313 ? Effect.void
314 : Effect.fail(new NodeGroupStillExists({ nodeGroupName })),
315 ),
316 Effect.retry({
317 while: (error) => error._tag === "GCP.Compute.NodeGroupStillExists",
318 times: 10,
319 schedule: Schedule.spaced("2 seconds"),
320 }),
321 Effect.catchTag("GCP.Compute.NodeGroupStillExists", () => Effect.void),
322 );
323
324const runOp = <E extends { readonly _tag: string }, R>(
325 project: string,
326 zone: string,
327 nodeGroupName: string,
328 start: Effect.Effect<compute.Operation, E, R>,
329 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
330) =>
331 start.pipe(
332 Effect.flatMap((operation) =>
333 waitZoneOperation(project, zone, operation, {
334 ignore: ignoredCodes(options),
335 }),
336 ),
337 Effect.retry({
338 while: (error) => error._tag === "Conflict",
339 times: 5,
340 schedule: Schedule.spaced("1 second"),
341 }),
342 );
343
344export const NodeGroupProvider = () =>
345 Provider.succeed(NodeGroup, {
346 stables: [
347 "nodeGroupName",
348 "project",
349 "zone",
350 "nodeGroupId",
351 "selfLink",
352 "creationTimestamp",
353 ],
354
355 diff: Effect.fn(function* ({ news, olds, output }) {
356 if (!isResolved(news)) return undefined;
357 const previousName = olds?.nodeGroupName ?? output?.nodeGroupName;
358 const nextName = news.nodeGroupName ?? previousName;
359 const nameChanged =
360 previousName !== undefined &&
361 nextName !== undefined &&
362 previousName !== nextName;
363 const previousZone = normalizeZone(olds?.zone ?? output?.zone);
364 const nextZone = normalizeZone(news.zone ?? previousZone);
365 if (nameChanged) {
366 return { action: "replace" as const, deleteFirst: false };
367 }
368 if (previousZone !== nextZone) {
369 return { action: "replace" as const, deleteFirst: true };
370 }
371 return undefined;
372 }),
373
374 read: Effect.fn(function* ({ id, olds, output }) {
375 const env = yield* GcpEnvironment.current;
376 const nodeGroupName = yield* toName(
377 id,
378 olds?.nodeGroupName,
379 output?.nodeGroupName,
380 );
381 const zone = normalizeZone(olds?.zone ?? output?.zone);
382 const existing = yield* getByName(env.project, zone, nodeGroupName);
383 if (existing === undefined) return undefined;
384 const attrs = toAttrs(existing, env.project);
385 const { labels } = parseDescription(existing.description);
386 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
387 }),
388
389 list: () =>
390 Effect.gen(function* () {
391 const env = yield* GcpEnvironment.current;
392 const pages = yield* compute.aggregatedListNodeGroups
393 .pages({
394 project: env.project,
395 returnPartialSuccess: true,
396 maxResults: 500,
397 })
398 .pipe(Stream.runCollect);
399 return Array.from(pages).flatMap((page) =>
400 Object.values(page.items ?? {}).flatMap((scoped) =>
401 (scoped?.nodeGroups ?? [])
402 .filter((item) => hasOwnershipMarker(item.description))
403 .map((item) => toAttrs(item, env.project)),
404 ),
405 );
406 }),
407
408 reconcile: Effect.fn(function* ({ id, news, output }) {
409 const env = yield* GcpEnvironment.current;
410 const nodeGroupName = yield* toName(
411 id,
412 news.nodeGroupName,
413 output?.nodeGroupName,
414 );
415 const zone = normalizeZone(news.zone ?? output?.zone);
416 const ownership = yield* createInternalLabels(id);
417 const desiredDescription = encodeDescription(ownership, news.description);
418 const nodeTemplate = nodeTemplateUrl(
419 env.project,
420 zone,
421 news.nodeTemplate,
422 );
423 const maintenancePolicy = maintenanceOf(news.maintenancePolicy);
424 const initialNodeCount = news.initialNodeCount ?? 0;
425
426 let current = yield* getByName(env.project, zone, nodeGroupName);
427
428 if (current === undefined) {
429 yield* compute
430 .insertNodeGroups({
431 project: env.project,
432 zone,
433 initialNodeCount,
434 body: {
435 name: nodeGroupName,
436 description: desiredDescription,
437 nodeTemplate,
438 maintenancePolicy,
439 maintenanceWindow: news.maintenanceWindow,
440 maintenanceInterval: news.maintenanceInterval,
441 autoscalingPolicy: news.autoscalingPolicy,
442 shareSettings: news.shareSettings,
443 },
444 })
445 .pipe(
446 Effect.flatMap((operation) =>
447 waitZoneOperation(env.project, zone, operation, {
448 ignore: ["RESOURCE_ALREADY_EXISTS"],
449 }),
450 ),
451 Effect.catchTag("Conflict", () => Effect.void),
452 );
453 current = yield* awaitResource(env.project, zone, nodeGroupName);
454 }
455
456 if (lastSegment(current.nodeTemplate) !== lastSegment(nodeTemplate)) {
457 yield* runOp(
458 env.project,
459 zone,
460 nodeGroupName,
461 compute.setNodeTemplateNodeGroups({
462 project: env.project,
463 zone,
464 nodeGroup: nodeGroupName,
465 body: { nodeTemplate },
466 }),
467 );
468 current =
469 (yield* getByName(env.project, zone, nodeGroupName)) ?? current;
470 }
471
472 const needsPatch =
473 (current.description ?? "") !== desiredDescription ||
474 maintenanceOf(current.maintenancePolicy) !== maintenancePolicy ||
475 (news.maintenanceInterval !== undefined &&
476 (current.maintenanceInterval ?? "") !== news.maintenanceInterval) ||
477 (news.maintenanceWindow !== undefined &&
478 !sameJson(current.maintenanceWindow, news.maintenanceWindow)) ||
479 (news.autoscalingPolicy !== undefined &&
480 !sameJson(current.autoscalingPolicy, news.autoscalingPolicy)) ||
481 (news.shareSettings !== undefined &&
482 !sameJson(current.shareSettings, news.shareSettings));
483
484 if (needsPatch) {
485 const latest =
486 (yield* getByName(env.project, zone, nodeGroupName)) ?? current;
487 yield* runOp(
488 env.project,
489 zone,
490 nodeGroupName,
491 compute.patchNodeGroups({
492 project: env.project,
493 zone,
494 nodeGroup: nodeGroupName,
495 body: {
496 fingerprint: latest.fingerprint,
497 description: desiredDescription,
498 maintenancePolicy,
499 maintenanceWindow:
500 news.maintenanceWindow ?? current.maintenanceWindow,
501 maintenanceInterval:
502 news.maintenanceInterval ?? current.maintenanceInterval,
503 autoscalingPolicy:
504 news.autoscalingPolicy ?? current.autoscalingPolicy,
505 shareSettings: news.shareSettings ?? current.shareSettings,
506 },
507 }),
508 );
509 current =
510 (yield* getByName(env.project, zone, nodeGroupName)) ?? current;
511 }
512
513 return toAttrs(current, env.project);
514 }),
515
516 delete: Effect.fn(function* ({ output }) {
517 if (!output.nodeGroupName) return;
518 const env = yield* GcpEnvironment.current;
519 const project = output.project || env.project;
520 const zone = normalizeZone(output.zone);
521 yield* compute
522 .deleteNodeGroups({
523 project,
524 zone,
525 nodeGroup: output.nodeGroupName,
526 })
527 .pipe(
528 Effect.flatMap((operation) =>
529 waitZoneOperation(project, zone, operation, {
530 ignore: ["RESOURCE_NOT_FOUND"],
531 }),
532 ),
533 Effect.catchTag("NotFound", () => Effect.void),
534 Effect.retry({
535 while: (error) => error._tag === "Conflict",
536 times: 8,
537 schedule: Schedule.spaced("2 seconds"),
538 }),
539 );
540 yield* waitUntilGone(project, zone, output.nodeGroupName);
541 }),
542 });