1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 lastSegment,
4 normalizeRegion,
5 runRegionOp,
6 sameJson,
7 toPhysicalName,
8} from "./internal.ts";
9import type {
10 InstanceTemplateAccessConfig,
11 InstanceTemplateDisk,
12 InstanceTemplateNetworkInterface,
13 InstanceTemplateScheduling,
14 InstanceTemplateServiceAccount,
15} from "./InstanceTemplate.ts";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import * as Schedule from "effect/Schedule";
19import * as Stream from "effect/Stream";
20import { Unowned } from "../../AdoptPolicy.ts";
21import { isResolved } from "../../Diff.ts";
22import * as Provider from "../../Provider.ts";
23import { Resource } from "../../Resource.ts";
24import { tagRecord } from "../../Tags.ts";
25import { GcpEnvironment } from "../Environment.ts";
26import {
27 createInternalLabels,
28 hasAlchemyLabels,
29 stripInternalLabels,
30 toLabels,
31} from "../Labels.ts";
32import type { Providers } from "../Providers.ts";
33
34const DEFAULT_MACHINE_TYPE = "e2-micro";
35const DEFAULT_SOURCE_IMAGE =
36 "projects/debian-cloud/global/images/family/debian-12";
37const DEFAULT_DISK_SIZE_GB = "10";
38const DEFAULT_NETWORK = "global/networks/default";
39
40export type RegionInstanceTemplateAccessConfig = InstanceTemplateAccessConfig;
41export type RegionInstanceTemplateDisk = InstanceTemplateDisk;
42export type RegionInstanceTemplateNetworkInterface =
43 InstanceTemplateNetworkInterface;
44export type RegionInstanceTemplateServiceAccount =
45 InstanceTemplateServiceAccount;
46export type RegionInstanceTemplateScheduling = InstanceTemplateScheduling;
47
48export type RegionInstanceTemplateProps = {
49 /**
50 * Template name. If omitted, a unique RFC1035 name is generated from the
51 * stack, stage, and logical id. Changing it replaces the template.
52 */
53 templateName?: string;
54 /**
55 * Region the template lives in. Immutable — changing it replaces the
56 * template. `US-CENTRAL1` is accepted and normalized to `us-central1`.
57 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
58 */
59 region?: string;
60 /**
61 * Optional description. Changing it replaces the template.
62 */
63 description?: string;
64 /**
65 * Machine type name (not a URL), e.g. `e2-micro`. Regional instance
66 * templates are immutable — changing this replaces the template.
67 * @default "e2-micro"
68 */
69 machineType?: string;
70 /**
71 * Allow sending/receiving packets with non-instance IPs.
72 * @default false
73 */
74 canIpForward?: boolean;
75 /**
76 * Labels applied to instances created from this template. Alchemy
77 * ownership labels are merged in automatically. Changing labels replaces
78 * the template (no in-place update API).
79 */
80 labels?: Record<string, string>;
81 /**
82 * Network tags applied to instances created from this template.
83 */
84 networkTags?: string[];
85 /**
86 * Instance metadata key/value pairs.
87 */
88 metadata?: Record<string, string>;
89 /**
90 * Attached disks. If omitted, a 10 GB Debian 12 boot disk is used.
91 */
92 disks?: RegionInstanceTemplateDisk[];
93 /**
94 * Network interfaces.
95 * @default [{ network: "global/networks/default" }]
96 */
97 networkInterfaces?: RegionInstanceTemplateNetworkInterface[];
98 /**
99 * Service accounts and scopes for created instances.
100 */
101 serviceAccounts?: RegionInstanceTemplateServiceAccount[];
102 /**
103 * Scheduling options for created instances.
104 */
105 scheduling?: RegionInstanceTemplateScheduling;
106};
107
108export type RegionInstanceTemplate = Resource<
109 "GCP.Compute.RegionInstanceTemplate",
110 RegionInstanceTemplateProps,
111 {
112 /** Template name. */
113 templateName: string;
114 /** Project id. */
115 project: string;
116 /** Region short name (`us-central1`). */
117 region: string;
118 /** Description. */
119 description: string | undefined;
120 /** Machine type name. */
121 machineType: string | undefined;
122 /** User labels (Alchemy ownership labels stripped). */
123 labels: Record<string, string>;
124 /** Network tags. */
125 networkTags: string[];
126 /** Whether IP forwarding is enabled. */
127 canIpForward: boolean;
128 /** Boot-disk source image, if set. */
129 sourceImage: string | undefined;
130 /** Server-defined URL. */
131 selfLink: string | undefined;
132 /** Server-assigned numeric id. */
133 templateId: string | undefined;
134 /** RFC3339 creation timestamp. */
135 creationTimestamp: string | undefined;
136 },
137 never,
138 Providers
139>;
140
141/**
142 * A regional Compute Engine instance template.
143 *
144 * Regional instance templates are immutable. Changing machine type, disks,
145 * labels, network interfaces, or other properties replaces the template.
146 * Use the template to create regional managed instance groups.
147 *
148 * ### Creating a Regional Template
149 * **Example:** Generated name with defaults
150 * ```typescript
151 * const template = yield* GCP.Compute.RegionInstanceTemplate("web", {});
152 * ```
153 *
154 * **Example:** Explicit machine type, disk, and labels
155 * ```typescript
156 * const template = yield* GCP.Compute.RegionInstanceTemplate("web", {
157 * region: "us-central1",
158 * machineType: "e2-micro",
159 * labels: { env: "prod" },
160 * disks: [
161 * {
162 * boot: true,
163 * autoDelete: true,
164 * sourceImage:
165 * "projects/debian-cloud/global/images/family/debian-12",
166 * diskSizeGb: 10,
167 * },
168 * ],
169 * });
170 * ```
171 *
172 * @resource
173 * @category Compute
174 */
175export const RegionInstanceTemplate = Resource<RegionInstanceTemplate>(
176 "GCP.Compute.RegionInstanceTemplate",
177);
178
179export class RegionInstanceTemplateNotResolved extends Data.TaggedError(
180 "GCP.Compute.RegionInstanceTemplateNotResolved",
181)<{
182 templateName: string;
183 region: string;
184}> {}
185
186const DEFAULT_DISKS: RegionInstanceTemplateDisk[] = [
187 {
188 boot: true,
189 autoDelete: true,
190 type: "PERSISTENT",
191 sourceImage: DEFAULT_SOURCE_IMAGE,
192 diskSizeGb: DEFAULT_DISK_SIZE_GB,
193 },
194];
195
196const DEFAULT_NICS: RegionInstanceTemplateNetworkInterface[] = [
197 { network: DEFAULT_NETWORK },
198];
199
200const userLabels = (
201 labels: Record<string, string | undefined> | null | undefined,
202): Record<string, string> => stripInternalLabels(tagRecord(labels));
203
204const sortedRecord = (
205 labels: Record<string, string> | undefined,
206): Record<string, string> =>
207 Object.fromEntries(
208 Object.entries(labels ?? {}).sort(([left], [right]) =>
209 left.localeCompare(right),
210 ),
211 );
212
213const resolvedDisks = (props: RegionInstanceTemplateProps) =>
214 props.disks !== undefined && props.disks.length > 0
215 ? props.disks
216 : DEFAULT_DISKS;
217
218const resolvedNics = (props: RegionInstanceTemplateProps) =>
219 props.networkInterfaces !== undefined && props.networkInterfaces.length > 0
220 ? props.networkInterfaces
221 : DEFAULT_NICS;
222
223const fingerprint = (props: RegionInstanceTemplateProps): string =>
224 JSON.stringify({
225 templateName: props.templateName ?? "",
226 region: props.region ? lastSegment(props.region).toLowerCase() : "",
227 description: props.description ?? "",
228 machineType: props.machineType ?? DEFAULT_MACHINE_TYPE,
229 canIpForward: props.canIpForward === true,
230 labels: sortedRecord(props.labels),
231 networkTags: [...(props.networkTags ?? [])].sort(),
232 metadata: sortedRecord(props.metadata),
233 disks: resolvedDisks(props).map((disk) => ({
234 boot: disk.boot === true,
235 autoDelete: disk.autoDelete,
236 deviceName: disk.deviceName ?? "",
237 type: disk.type ?? "",
238 mode: disk.mode ?? "",
239 diskInterface: disk.diskInterface ?? "",
240 diskSizeGb: disk.diskSizeGb !== undefined ? String(disk.diskSizeGb) : "",
241 diskType: disk.diskType ?? "",
242 sourceImage: disk.sourceImage ?? "",
243 sourceSnapshot: disk.sourceSnapshot ?? "",
244 source: disk.source ?? "",
245 diskName: disk.diskName ?? "",
246 labels: sortedRecord(disk.labels),
247 })),
248 networkInterfaces: resolvedNics(props).map((nic) => ({
249 network: nic.network ?? "",
250 subnetwork: nic.subnetwork ?? "",
251 networkIP: nic.networkIP ?? "",
252 nicType: nic.nicType ?? "",
253 stackType: nic.stackType ?? "",
254 accessConfigs: nic.accessConfigs ?? [],
255 })),
256 serviceAccounts: props.serviceAccounts ?? [],
257 scheduling: props.scheduling ?? {},
258 });
259
260const initializeParamsOf = (
261 disk: RegionInstanceTemplateDisk,
262): compute.AttachedDiskInitializeParams | undefined => {
263 if (
264 disk.sourceImage === undefined &&
265 disk.sourceSnapshot === undefined &&
266 disk.diskType === undefined &&
267 disk.diskName === undefined &&
268 disk.labels === undefined &&
269 disk.diskSizeGb === undefined
270 ) {
271 return undefined;
272 }
273 return {
274 sourceImage: disk.sourceImage,
275 sourceSnapshot: disk.sourceSnapshot,
276 diskType: disk.diskType,
277 diskName: disk.diskName,
278 labels: disk.labels,
279 diskSizeGb:
280 disk.diskSizeGb !== undefined ? String(disk.diskSizeGb) : undefined,
281 };
282};
283
284const toDisks = (disks: RegionInstanceTemplateDisk[]): compute.AttachedDisk[] =>
285 disks.map((disk) => ({
286 boot: disk.boot,
287 autoDelete: disk.autoDelete,
288 deviceName: disk.deviceName,
289 type: disk.type,
290 mode: disk.mode,
291 interface: disk.diskInterface,
292 source: disk.source,
293 initializeParams: disk.source ? undefined : initializeParamsOf(disk),
294 }));
295
296const toNics = (
297 nics: RegionInstanceTemplateNetworkInterface[],
298): compute.NetworkInterface[] =>
299 nics.map((nic) => ({
300 network: nic.network,
301 subnetwork: nic.subnetwork,
302 networkIP: nic.networkIP,
303 nicType: nic.nicType,
304 stackType: nic.stackType,
305 accessConfigs: nic.accessConfigs,
306 }));
307
308const toProperties = (
309 news: RegionInstanceTemplateProps,
310 labels: Record<string, string>,
311): compute.InstanceProperties => ({
312 machineType: news.machineType ?? DEFAULT_MACHINE_TYPE,
313 canIpForward: news.canIpForward,
314 labels,
315 tags:
316 news.networkTags !== undefined ? { items: news.networkTags } : undefined,
317 metadata:
318 news.metadata !== undefined
319 ? {
320 items: Object.entries(news.metadata).map(([key, value]) => ({
321 key,
322 value,
323 })),
324 }
325 : undefined,
326 disks: toDisks(resolvedDisks(news)),
327 networkInterfaces: toNics(resolvedNics(news)),
328 serviceAccounts: news.serviceAccounts,
329 scheduling: news.scheduling,
330});
331
332const bootSourceImage = (template: compute.InstanceTemplate) => {
333 const boot = (template.properties?.disks ?? []).find(
334 (disk) => disk.boot === true,
335 );
336 return boot?.initializeParams?.sourceImage;
337};
338
339const toAttrs = (
340 template: compute.InstanceTemplate,
341 project: string,
342): RegionInstanceTemplate["Attributes"] => ({
343 templateName: template.name ?? lastSegment(template.selfLink),
344 project,
345 region: lastSegment(template.region).toLowerCase(),
346 description: template.description,
347 machineType: template.properties?.machineType,
348 labels: userLabels(template.properties?.labels),
349 networkTags: template.properties?.tags?.items ?? [],
350 canIpForward: template.properties?.canIpForward === true,
351 sourceImage: bootSourceImage(template),
352 selfLink: template.selfLink,
353 templateId: template.id,
354 creationTimestamp: template.creationTimestamp,
355});
356
357const getByName = (project: string, region: string, instanceTemplate: string) =>
358 compute
359 .getRegionInstanceTemplates({ project, region, instanceTemplate })
360 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
361
362const awaitResource = (project: string, region: string, templateName: string) =>
363 getByName(project, region, templateName).pipe(
364 Effect.flatMap((template) =>
365 template !== undefined
366 ? Effect.succeed(template)
367 : Effect.fail(
368 new RegionInstanceTemplateNotResolved({ templateName, region }),
369 ),
370 ),
371 Effect.retry({
372 while: (error) =>
373 error._tag === "GCP.Compute.RegionInstanceTemplateNotResolved",
374 times: 8,
375 schedule: Schedule.spaced("1 second"),
376 }),
377 );
378
379export const RegionInstanceTemplateProvider = () =>
380 Provider.succeed(RegionInstanceTemplate, {
381 stables: [
382 "templateName",
383 "project",
384 "region",
385 "templateId",
386 "selfLink",
387 "creationTimestamp",
388 ],
389
390 diff: Effect.fn(function* ({ news, olds, output }) {
391 if (!isResolved(news)) return undefined;
392 const env = yield* GcpEnvironment.current;
393 const previousName = olds?.templateName ?? output?.templateName;
394 const nextName = news.templateName ?? previousName;
395 const previousRegion = normalizeRegion(
396 olds?.region ?? output?.region,
397 env.region,
398 );
399 const nextRegion = normalizeRegion(
400 news.region ?? (previousRegion || env.region),
401 env.region,
402 );
403 if (previousName === undefined && output === undefined) {
404 return undefined;
405 }
406 const nameChanged =
407 news.templateName !== undefined &&
408 previousName !== undefined &&
409 news.templateName !== previousName;
410 const regionChanged =
411 previousRegion.length > 0 && previousRegion !== nextRegion;
412 const propsChanged =
413 olds !== undefined && fingerprint(news) !== fingerprint(olds);
414 const adoptedChanged =
415 olds === undefined &&
416 output !== undefined &&
417 ((news.machineType ?? DEFAULT_MACHINE_TYPE) !==
418 (output.machineType ?? DEFAULT_MACHINE_TYPE) ||
419 (news.description ?? "") !== (output.description ?? "") ||
420 (news.canIpForward === true) !== output.canIpForward ||
421 !sameJson(sortedRecord(news.labels), sortedRecord(output.labels)));
422 if (!nameChanged && !regionChanged && !propsChanged && !adoptedChanged) {
423 return undefined;
424 }
425 return {
426 action: "replace" as const,
427 deleteFirst:
428 previousName !== undefined &&
429 nextName !== undefined &&
430 nextName === previousName,
431 };
432 }),
433
434 read: Effect.fn(function* ({ id, olds, output }) {
435 const env = yield* GcpEnvironment.current;
436 const templateName = yield* toPhysicalName(
437 id,
438 olds?.templateName,
439 output?.templateName,
440 "template",
441 );
442 const region = normalizeRegion(
443 olds?.region ?? output?.region,
444 env.region,
445 );
446 const existing = yield* getByName(env.project, region, templateName);
447 if (existing === undefined) return undefined;
448 const attrs = toAttrs(existing, env.project);
449 return (yield* hasAlchemyLabels(
450 id,
451 tagRecord(existing.properties?.labels),
452 ))
453 ? attrs
454 : Unowned(attrs);
455 }),
456
457 list: () =>
458 Effect.gen(function* () {
459 const env = yield* GcpEnvironment.current;
460 const pages = yield* compute.aggregatedListInstanceTemplates
461 .pages({
462 project: env.project,
463 maxResults: 500,
464 returnPartialSuccess: true,
465 })
466 .pipe(
467 Stream.runCollect,
468 Effect.catchTag("NotFound", () => Effect.succeed([] as never[])),
469 );
470 return Array.from(
471 pages as readonly compute.InstanceTemplateAggregatedList[],
472 ).flatMap((page) =>
473 Object.entries(page.items ?? {}).flatMap(([scope, scoped]) => {
474 if (!scope.startsWith("regions/")) return [];
475 return (scoped?.instanceTemplates ?? [])
476 .filter((template) =>
477 Object.keys(template.properties?.labels ?? {}).some((key) =>
478 key.startsWith("alchemy-"),
479 ),
480 )
481 .map((template) => toAttrs(template, env.project));
482 }),
483 );
484 }),
485
486 reconcile: Effect.fn(function* ({ id, news, output }) {
487 const env = yield* GcpEnvironment.current;
488 const templateName = yield* toPhysicalName(
489 id,
490 news.templateName,
491 output?.templateName,
492 "template",
493 );
494 const region = normalizeRegion(news.region ?? output?.region, env.region);
495 const desiredLabels = {
496 ...toLabels(news.labels),
497 ...(yield* createInternalLabels(id)),
498 };
499
500 let current = yield* getByName(env.project, region, templateName);
501
502 if (current === undefined) {
503 yield* runRegionOp(
504 env.project,
505 region,
506 compute.insertRegionInstanceTemplates({
507 project: env.project,
508 region,
509 body: {
510 name: templateName,
511 description: news.description,
512 properties: toProperties(news, desiredLabels),
513 },
514 }),
515 { ignoreAlreadyExists: true },
516 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
517 current = yield* awaitResource(env.project, region, templateName);
518 }
519
520 if (current === undefined) {
521 return yield* new RegionInstanceTemplateNotResolved({
522 templateName,
523 region,
524 });
525 }
526
527 return toAttrs(current, env.project);
528 }),
529
530 delete: Effect.fn(function* ({ output }) {
531 const env = yield* GcpEnvironment.current;
532 const region = normalizeRegion(output.region, env.region);
533 yield* runRegionOp(
534 env.project,
535 region,
536 compute.deleteRegionInstanceTemplates({
537 project: env.project,
538 region,
539 instanceTemplate: output.templateName,
540 }),
541 { ignoreNotFound: true },
542 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
543 }),
544 });