1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.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 MAX_NAME_LENGTH = 63;
22
23export type NodeTemplateCpuOvercommitType =
24 | compute.NodeTemplateCpuOvercommitTypeEnum
25 | (string & {});
26export type NodeTemplateLocalDisk = compute.LocalDisk;
27export type NodeTemplateAccelerator = compute.AcceleratorConfig;
28export type NodeTemplateNodeTypeFlexibility =
29 compute.NodeTemplateNodeTypeFlexibility;
30export type NodeTemplateServerBinding = compute.ServerBinding;
31
32export type NodeTemplateProps = {
33 /**
34 * Template name (RFC1035, 1-63 characters). If omitted, a unique name
35 * is generated from the stack, stage, and logical id. Immutable —
36 * changing it replaces the template.
37 */
38 nodeTemplateName?: string;
39 /**
40 * Region the template lives in. Immutable — changing it replaces the
41 * template. `US-CENTRAL1` is accepted and normalized to `us-central1`.
42 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
43 */
44 region?: string;
45 /**
46 * Optional description. Node templates have no labels field, so Alchemy
47 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
48 * in a `[alchemy …]` prefix for `list` / nuke. Description is set at
49 * create time — changing it replaces the template.
50 */
51 description?: string;
52 /**
53 * Sole-tenant node type (for example `n2-node-80-640`). Mutually
54 * exclusive with `nodeTypeFlexibility`. Immutable — changing it
55 * replaces the template.
56 */
57 nodeType?: string;
58 /**
59 * Flexible vCPU / memory / local-SSD requirements. Mutually exclusive
60 * with `nodeType`. Immutable — changing it replaces the template.
61 */
62 nodeTypeFlexibility?: NodeTemplateNodeTypeFlexibility;
63 /**
64 * CPU overcommit (`NONE` or `ENABLED`). Immutable — changing it
65 * replaces the template.
66 */
67 cpuOvercommitType?: NodeTemplateCpuOvercommitType;
68 /**
69 * Node affinity labels used when scheduling instances. Immutable —
70 * changing them replaces the template.
71 */
72 nodeAffinityLabels?: Record<string, string>;
73 /**
74 * Local disks attached to each node. Immutable — changing them
75 * replaces the template.
76 */
77 disks?: NodeTemplateLocalDisk[];
78 /**
79 * Accelerators attached to each node. Immutable — changing them
80 * replaces the template.
81 */
82 accelerators?: NodeTemplateAccelerator[];
83 /**
84 * Physical-server binding. Immutable — changing it replaces the
85 * template.
86 */
87 serverBinding?: NodeTemplateServerBinding;
88};
89
90export type NodeTemplate = Resource<
91 "GCP.Compute.NodeTemplate",
92 NodeTemplateProps,
93 {
94 /** Template name. */
95 nodeTemplateName: string;
96 /** Project id. */
97 project: string;
98 /** Region short name (`us-central1`). */
99 region: string;
100 /** User description with the Alchemy ownership prefix stripped. */
101 description: string | undefined;
102 /** Sole-tenant node type. */
103 nodeType: string | undefined;
104 /** Flexible node-type requirements. */
105 nodeTypeFlexibility: NodeTemplateNodeTypeFlexibility | undefined;
106 /** CPU overcommit. */
107 cpuOvercommitType: string | undefined;
108 /** Node affinity labels. */
109 nodeAffinityLabels: Record<string, string>;
110 /** Local disks. */
111 disks: ReadonlyArray<NodeTemplateLocalDisk>;
112 /** Accelerators. */
113 accelerators: ReadonlyArray<NodeTemplateAccelerator>;
114 /** Server binding. */
115 serverBinding: NodeTemplateServerBinding | undefined;
116 /** Template status. */
117 status: string | undefined;
118 /** Status message. */
119 statusMessage: string | undefined;
120 /** Server-assigned numeric id. */
121 nodeTemplateId: string | undefined;
122 /** Resource self-link. */
123 selfLink: string | undefined;
124 /** RFC3339 creation timestamp. */
125 creationTimestamp: string | undefined;
126 /** Resource kind. */
127 kind: string | undefined;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A regional Compute Engine sole-tenant node template.
135 *
136 * Node templates define the properties of sole-tenant nodes created in a
137 * node group (node type, CPU overcommit, affinity labels, local disks).
138 * There is no in-place update API — changing any property replaces the
139 * template. Compute NodeTemplate has no labels field — Alchemy stamps
140 * ownership into the description so nuke can find leaked templates.
141 *
142 * ### Creating a Node Template
143 * **Example:** Generated name with a node type
144 * ```typescript
145 * const template = yield* GCP.Compute.NodeTemplate("SoleTenant", {
146 * region: "us-central1",
147 * nodeType: "n2-node-80-640",
148 * description: "prod sole tenant",
149 * });
150 * ```
151 *
152 * **Example:** Flexible node type
153 * ```typescript
154 * const template = yield* GCP.Compute.NodeTemplate("SoleTenant", {
155 * nodeTypeFlexibility: { cpus: "80", memory: "640GB" },
156 * cpuOvercommitType: "ENABLED",
157 * });
158 * ```
159 *
160 * @resource
161 * @category Compute
162 */
163export const NodeTemplate = Resource<NodeTemplate>("GCP.Compute.NodeTemplate");
164
165export class NodeTemplateNotResolved extends Data.TaggedError(
166 "GCP.Compute.NodeTemplateNotResolved",
167)<{
168 nodeTemplateName: string;
169 region: string;
170}> {}
171
172export class NodeTemplateStillExists extends Data.TaggedError(
173 "GCP.Compute.NodeTemplateStillExists",
174)<{
175 nodeTemplateName: string;
176}> {}
177
178const lastSegment = (value: string | undefined): string => {
179 if (value === undefined || value.length === 0) return "";
180 const trimmed = value.replace(/\/+$/, "");
181 const parts = trimmed.split("/");
182 return parts[parts.length - 1] || trimmed;
183};
184
185const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
186 lastSegment(region ?? defaultRegion).toLowerCase();
187
188const rfc1035 = (name: string): string => {
189 let next = name
190 .toLowerCase()
191 .replace(/[^a-z0-9-]/g, "-")
192 .replace(/-+/g, "-")
193 .replace(/^-+/, "")
194 .replace(/-+$/, "");
195 if (!/^[a-z]/.test(next)) next = `t${next}`;
196 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
197 return next.length > 0 ? next : "template";
198};
199
200const toName = (id: string, name: string | undefined, existing?: string) =>
201 Effect.gen(function* () {
202 if (name !== undefined) return name;
203 if (existing !== undefined) return existing;
204 return rfc1035(
205 yield* createPhysicalName({
206 id,
207 maxLength: MAX_NAME_LENGTH,
208 lowercase: true,
209 }),
210 );
211 });
212
213const encodeDescription = (
214 labels: Record<string, string>,
215 description: string | undefined,
216): string => {
217 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
218 return description ? `${marker}\n${description}` : marker;
219};
220
221const parseDescription = (
222 description: string | undefined,
223): {
224 labels: Record<string, string>;
225 description: string | undefined;
226} => {
227 if (!description?.startsWith("[alchemy ")) {
228 return { labels: {}, description };
229 }
230 const end = description.indexOf("]");
231 if (end < 0) return { labels: {}, description };
232 const labels: Record<string, string> = {};
233 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
234 const eq = part.indexOf("=");
235 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
236 }
237 const rest = description.slice(end + 1).replace(/^\n/, "");
238 return { labels, description: rest.length > 0 ? rest : undefined };
239};
240
241const hasOwnershipMarker = (description: string | undefined) =>
242 Object.keys(parseDescription(description).labels).some((key) =>
243 key.startsWith("alchemy-"),
244 );
245
246const sameJson = (left: unknown, right: unknown) =>
247 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
248
249const toAttrs = (
250 template: compute.NodeTemplate,
251 project: string,
252): NodeTemplate["Attributes"] => {
253 const parsed = parseDescription(template.description);
254 return {
255 nodeTemplateName: template.name ?? "",
256 project,
257 region: lastSegment(template.region).toLowerCase(),
258 description: parsed.description,
259 nodeType: template.nodeType,
260 nodeTypeFlexibility: template.nodeTypeFlexibility,
261 cpuOvercommitType: template.cpuOvercommitType,
262 nodeAffinityLabels: tagRecord(template.nodeAffinityLabels),
263 disks: template.disks ?? [],
264 accelerators: template.accelerators ?? [],
265 serverBinding: template.serverBinding,
266 status: template.status,
267 statusMessage: template.statusMessage,
268 nodeTemplateId: template.id,
269 selfLink: template.selfLink,
270 creationTimestamp: template.creationTimestamp,
271 kind: template.kind,
272 };
273};
274
275const getByName = (project: string, region: string, nodeTemplate: string) =>
276 compute
277 .getNodeTemplates({ project, region, nodeTemplate })
278 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
279
280const awaitResource = (
281 project: string,
282 region: string,
283 nodeTemplateName: string,
284) =>
285 getByName(project, region, nodeTemplateName).pipe(
286 Effect.flatMap((template) =>
287 template !== undefined
288 ? Effect.succeed(template)
289 : Effect.fail(
290 new NodeTemplateNotResolved({ nodeTemplateName, region }),
291 ),
292 ),
293 Effect.retry({
294 while: (error) => error._tag === "GCP.Compute.NodeTemplateNotResolved",
295 times: 8,
296 schedule: Schedule.spaced("1 second"),
297 }),
298 );
299
300const waitUntilGone = (
301 project: string,
302 region: string,
303 nodeTemplateName: string,
304) =>
305 getByName(project, region, nodeTemplateName).pipe(
306 Effect.flatMap((template) =>
307 template === undefined
308 ? Effect.void
309 : Effect.fail(new NodeTemplateStillExists({ nodeTemplateName })),
310 ),
311 Effect.retry({
312 while: (error) => error._tag === "GCP.Compute.NodeTemplateStillExists",
313 times: 10,
314 schedule: Schedule.spaced("2 seconds"),
315 }),
316 Effect.catchTag("GCP.Compute.NodeTemplateStillExists", () => Effect.void),
317 );
318
319export const NodeTemplateProvider = () =>
320 Provider.succeed(NodeTemplate, {
321 stables: [
322 "nodeTemplateName",
323 "project",
324 "region",
325 "nodeTemplateId",
326 "selfLink",
327 "nodeType",
328 "creationTimestamp",
329 ],
330
331 diff: Effect.fn(function* ({ news, olds, output }) {
332 if (!isResolved(news)) return undefined;
333 const env = yield* GcpEnvironment.current;
334 const previousName = olds?.nodeTemplateName ?? output?.nodeTemplateName;
335 const nextName = news.nodeTemplateName ?? previousName;
336 const nameChanged =
337 previousName !== undefined &&
338 nextName !== undefined &&
339 previousName !== nextName;
340 const previousRegion = normalizeRegion(
341 olds?.region ?? output?.region,
342 env.region,
343 );
344 const nextRegion = normalizeRegion(
345 news.region ?? previousRegion,
346 env.region,
347 );
348
349 const previousType = olds?.nodeType ?? output?.nodeType ?? "";
350 const nextType = news.nodeType ?? previousType;
351 const previousOvercommit =
352 olds?.cpuOvercommitType ?? output?.cpuOvercommitType;
353 const nextOvercommit = news.cpuOvercommitType ?? previousOvercommit;
354 const previousBinding = olds?.serverBinding ?? output?.serverBinding;
355 const nextBinding = news.serverBinding ?? previousBinding;
356 const previousFlex =
357 olds?.nodeTypeFlexibility ?? output?.nodeTypeFlexibility;
358 const nextFlex = news.nodeTypeFlexibility ?? previousFlex;
359 const previousAffinity =
360 olds?.nodeAffinityLabels ?? output?.nodeAffinityLabels ?? {};
361 const nextAffinity = news.nodeAffinityLabels ?? previousAffinity;
362 const previousDisks = olds?.disks ?? output?.disks ?? [];
363 const nextDisks = news.disks ?? previousDisks;
364 const previousAccel = olds?.accelerators ?? output?.accelerators ?? [];
365 const nextAccel = news.accelerators ?? previousAccel;
366 const previousDescription =
367 olds?.description ?? output?.description ?? "";
368 const nextDescription = news.description ?? previousDescription;
369
370 const immutableChanged =
371 previousType !== nextType ||
372 previousOvercommit !== nextOvercommit ||
373 !sameJson(previousBinding, nextBinding) ||
374 !sameJson(previousFlex, nextFlex) ||
375 !sameJson(previousAffinity, nextAffinity) ||
376 !sameJson(previousDisks, nextDisks) ||
377 !sameJson(previousAccel, nextAccel) ||
378 previousDescription !== nextDescription;
379
380 if (nameChanged || previousRegion !== nextRegion) {
381 return { action: "replace" as const, deleteFirst: false };
382 }
383 if (immutableChanged) {
384 return { action: "replace" as const, deleteFirst: true };
385 }
386 return undefined;
387 }),
388
389 read: Effect.fn(function* ({ id, olds, output }) {
390 const env = yield* GcpEnvironment.current;
391 const nodeTemplateName = yield* toName(
392 id,
393 olds?.nodeTemplateName,
394 output?.nodeTemplateName,
395 );
396 const region = normalizeRegion(
397 olds?.region ?? output?.region,
398 env.region,
399 );
400 const existing = yield* getByName(env.project, region, nodeTemplateName);
401 if (existing === undefined) return undefined;
402 const attrs = toAttrs(existing, env.project);
403 const { labels } = parseDescription(existing.description);
404 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
405 }),
406
407 list: () =>
408 Effect.gen(function* () {
409 const env = yield* GcpEnvironment.current;
410 const pages = yield* compute.aggregatedListNodeTemplates
411 .pages({
412 project: env.project,
413 returnPartialSuccess: true,
414 maxResults: 500,
415 })
416 .pipe(Stream.runCollect);
417 return Array.from(pages).flatMap((page) =>
418 Object.values(page.items ?? {}).flatMap((scoped) =>
419 (scoped?.nodeTemplates ?? [])
420 .filter((item) => hasOwnershipMarker(item.description))
421 .map((item) => toAttrs(item, env.project)),
422 ),
423 );
424 }),
425
426 reconcile: Effect.fn(function* ({ id, news, output }) {
427 const env = yield* GcpEnvironment.current;
428 const nodeTemplateName = yield* toName(
429 id,
430 news.nodeTemplateName,
431 output?.nodeTemplateName,
432 );
433 const region = normalizeRegion(news.region ?? output?.region, env.region);
434 const ownership = yield* createInternalLabels(id);
435 const desiredDescription = encodeDescription(ownership, news.description);
436
437 let current = yield* getByName(env.project, region, nodeTemplateName);
438
439 if (current === undefined) {
440 yield* compute
441 .insertNodeTemplates({
442 project: env.project,
443 region,
444 body: {
445 name: nodeTemplateName,
446 description: desiredDescription,
447 nodeType: news.nodeType,
448 nodeTypeFlexibility: news.nodeTypeFlexibility,
449 cpuOvercommitType: news.cpuOvercommitType,
450 nodeAffinityLabels: news.nodeAffinityLabels,
451 disks: news.disks,
452 accelerators: news.accelerators,
453 serverBinding: news.serverBinding,
454 },
455 })
456 .pipe(
457 Effect.flatMap((operation) =>
458 waitRegionOperation(env.project, region, operation, {
459 ignore: ["RESOURCE_ALREADY_EXISTS"],
460 }),
461 ),
462 Effect.catchTag("Conflict", () => Effect.void),
463 );
464 current = yield* awaitResource(env.project, region, nodeTemplateName);
465 }
466
467 return toAttrs(current, env.project);
468 }),
469
470 delete: Effect.fn(function* ({ output }) {
471 if (!output.nodeTemplateName) return;
472 const env = yield* GcpEnvironment.current;
473 const project = output.project || env.project;
474 const region = normalizeRegion(output.region, env.region);
475 yield* compute
476 .deleteNodeTemplates({
477 project,
478 region,
479 nodeTemplate: output.nodeTemplateName,
480 })
481 .pipe(
482 Effect.flatMap((operation) =>
483 waitRegionOperation(project, region, operation, {
484 ignore: ["RESOURCE_NOT_FOUND"],
485 }),
486 ),
487 Effect.catchTag("NotFound", () => Effect.void),
488 Effect.retry({
489 while: (error) => error._tag === "Conflict",
490 times: 8,
491 schedule: Schedule.spaced("2 seconds"),
492 }),
493 );
494 yield* waitUntilGone(project, region, output.nodeTemplateName);
495 }),
496 });