1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } 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 createInternalLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22const DEFAULT_MACHINE_TYPE = "e2-micro";
23const DEFAULT_SOURCE_IMAGE =
24 "projects/debian-cloud/global/images/family/debian-12";
25const DEFAULT_DISK_SIZE_GB = "10";
26const DEFAULT_NETWORK = "global/networks/default";
27const MAX_NAME_LENGTH = 63;
28
29export type InstanceTemplateAccessConfig = {
30 /** Access config type. @default "ONE_TO_ONE_NAT" */
31 type?: string;
32 /** Access config name. */
33 name?: string;
34 /** Network tier (`PREMIUM` or `STANDARD`). */
35 networkTier?: string;
36 /** External IPv4 address. */
37 natIP?: string;
38};
39
40export type InstanceTemplateNetworkInterface = {
41 /**
42 * VPC network URL or name.
43 * @default "global/networks/default"
44 */
45 network?: string;
46 /** Subnetwork URL. */
47 subnetwork?: string;
48 /** Internal IPv4 address. */
49 networkIP?: string;
50 /** NIC type (`GVNIC` or `VIRTIO_NET`). */
51 nicType?: string;
52 /** Stack type (`IPV4_ONLY` or `IPV4_IPV6`). */
53 stackType?: string;
54 /** External access configs. Omit for no external IP. */
55 accessConfigs?: InstanceTemplateAccessConfig[];
56};
57
58export type InstanceTemplateDisk = {
59 /** Whether this is the boot disk. */
60 boot?: boolean;
61 /** Delete the disk when the instance is deleted. */
62 autoDelete?: boolean;
63 /** Device name visible to the guest OS. */
64 deviceName?: string;
65 /** Disk type (`PERSISTENT` or `SCRATCH`). @default "PERSISTENT" */
66 type?: string;
67 /** Attach mode (`READ_WRITE` or `READ_ONLY`). */
68 mode?: string;
69 /** Disk interface (`SCSI` or `NVME`). */
70 diskInterface?: string;
71 /** Disk size in GB. */
72 diskSizeGb?: string | number;
73 /** Persistent disk type (`pd-standard`, `pd-balanced`, `pd-ssd`). */
74 diskType?: string;
75 /** Source image URL or family. */
76 sourceImage?: string;
77 /** Source snapshot URL. */
78 sourceSnapshot?: string;
79 /** Existing disk to attach (name for zonal, URL for regional). */
80 source?: string;
81 /** Disk resource name. */
82 diskName?: string;
83 /** Disk labels. */
84 labels?: Record<string, string>;
85};
86
87export type InstanceTemplateServiceAccount = {
88 /** Service account email. */
89 email?: string;
90 /** OAuth scopes granted to the service account. */
91 scopes?: string[];
92};
93
94export type InstanceTemplateScheduling = {
95 /** Restart the VM if Compute Engine terminates it. */
96 automaticRestart?: boolean;
97 /** Host maintenance behavior (`MIGRATE` or `TERMINATE`). */
98 onHostMaintenance?: string;
99 /** Whether the VM is preemptible. */
100 preemptible?: boolean;
101 /** Provisioning model (`STANDARD` or `SPOT`). */
102 provisioningModel?: string;
103};
104
105export type InstanceTemplateProps = {
106 /**
107 * Template name. If omitted, a unique RFC1035 name is generated from the
108 * stack, stage, and logical id. Must be 1-63 characters matching
109 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Changing it replaces the template.
110 */
111 templateName?: string;
112 /**
113 * Optional description. Changing it replaces the template.
114 */
115 description?: string;
116 /**
117 * Machine type name (not a URL), e.g. `e2-micro`. Instance templates are
118 * immutable — changing this replaces the template.
119 * @default "e2-micro"
120 */
121 machineType?: string;
122 /**
123 * Allow sending/receiving packets with non-instance IPs.
124 * @default false
125 */
126 canIpForward?: boolean;
127 /**
128 * Labels applied to instances created from this template. Alchemy
129 * ownership labels are merged in automatically. Changing labels replaces
130 * the template (no in-place update API).
131 */
132 labels?: Record<string, string>;
133 /**
134 * Network tags applied to instances created from this template.
135 */
136 networkTags?: string[];
137 /**
138 * Instance metadata key/value pairs.
139 */
140 metadata?: Record<string, string>;
141 /**
142 * Attached disks. If omitted, a 10 GB Debian 12 boot disk is used.
143 */
144 disks?: InstanceTemplateDisk[];
145 /**
146 * Network interfaces.
147 * @default [{ network: "global/networks/default" }]
148 */
149 networkInterfaces?: InstanceTemplateNetworkInterface[];
150 /**
151 * Service accounts and scopes for created instances.
152 */
153 serviceAccounts?: InstanceTemplateServiceAccount[];
154 /**
155 * Scheduling options for created instances.
156 */
157 scheduling?: InstanceTemplateScheduling;
158};
159
160export type InstanceTemplate = Resource<
161 "GCP.Compute.InstanceTemplate",
162 InstanceTemplateProps,
163 {
164 /** Template name. */
165 templateName: string;
166 /** Project id. */
167 project: string;
168 /** Description. */
169 description: string | undefined;
170 /** Machine type name. */
171 machineType: string | undefined;
172 /** User labels (Alchemy ownership labels stripped). */
173 labels: Record<string, string>;
174 /** Network tags. */
175 networkTags: string[];
176 /** Whether IP forwarding is enabled. */
177 canIpForward: boolean;
178 /** Boot-disk source image, if set. */
179 sourceImage: string | undefined;
180 /** Server-defined URL. */
181 selfLink: string | undefined;
182 /** Server-assigned numeric id. */
183 templateId: string | undefined;
184 /** RFC3339 creation timestamp. */
185 creationTimestamp: string | undefined;
186 },
187 never,
188 Providers
189>;
190
191/**
192 * A global Compute Engine instance template.
193 *
194 * Instance templates are immutable. Changing machine type, disks, labels,
195 * network interfaces, or other properties replaces the template. Use the
196 * template to create VMs, managed instance groups, and reservations.
197 *
198 * ### Creating a Template
199 * **Example:** Generated name with defaults
200 * ```typescript
201 * const template = yield* GCP.Compute.InstanceTemplate("web", {});
202 * ```
203 *
204 * **Example:** Explicit machine type, disk, and labels
205 * ```typescript
206 * const template = yield* GCP.Compute.InstanceTemplate("web", {
207 * machineType: "e2-micro",
208 * labels: { env: "prod" },
209 * disks: [
210 * {
211 * boot: true,
212 * autoDelete: true,
213 * sourceImage:
214 * "projects/debian-cloud/global/images/family/debian-12",
215 * diskSizeGb: 10,
216 * },
217 * ],
218 * networkInterfaces: [{ network: "global/networks/default" }],
219 * });
220 * ```
221 *
222 * @resource
223 * @category Compute
224 */
225export const InstanceTemplate = Resource<InstanceTemplate>(
226 "GCP.Compute.InstanceTemplate",
227);
228
229export class InstanceTemplateNotResolved extends Data.TaggedError(
230 "GCP.Compute.InstanceTemplateNotResolved",
231)<{
232 templateName: string;
233}> {}
234
235export class InstanceTemplateStillExists extends Data.TaggedError(
236 "GCP.Compute.InstanceTemplateStillExists",
237)<{
238 templateName: string;
239}> {}
240
241const DEFAULT_DISKS: InstanceTemplateDisk[] = [
242 {
243 boot: true,
244 autoDelete: true,
245 type: "PERSISTENT",
246 sourceImage: DEFAULT_SOURCE_IMAGE,
247 diskSizeGb: DEFAULT_DISK_SIZE_GB,
248 },
249];
250
251const DEFAULT_NICS: InstanceTemplateNetworkInterface[] = [
252 { network: DEFAULT_NETWORK },
253];
254
255const userLabels = (
256 labels: Record<string, string | undefined> | null | undefined,
257): Record<string, string> => stripInternalLabels(tagRecord(labels));
258
259const rfc1035Name = (name: string) => {
260 let next = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
261 if (!/^[a-z]/.test(next)) next = `t${next}`;
262 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
263 return next.length > 0 ? next : "template";
264};
265
266const toName = (id: string, name: string | undefined, existing?: string) =>
267 Effect.gen(function* () {
268 if (name !== undefined) return name;
269 if (existing !== undefined) return existing;
270 return rfc1035Name(
271 yield* createPhysicalName({
272 id,
273 maxLength: MAX_NAME_LENGTH,
274 lowercase: true,
275 }),
276 );
277 });
278
279const sortedRecord = (
280 labels: Record<string, string> | undefined,
281): Record<string, string> =>
282 Object.fromEntries(
283 Object.entries(labels ?? {}).sort(([left], [right]) =>
284 left.localeCompare(right),
285 ),
286 );
287
288const resolvedDisks = (props: InstanceTemplateProps) =>
289 props.disks !== undefined && props.disks.length > 0
290 ? props.disks
291 : DEFAULT_DISKS;
292
293const resolvedNics = (props: InstanceTemplateProps) =>
294 props.networkInterfaces !== undefined && props.networkInterfaces.length > 0
295 ? props.networkInterfaces
296 : DEFAULT_NICS;
297
298const fingerprint = (props: InstanceTemplateProps): string =>
299 JSON.stringify({
300 templateName: props.templateName ?? "",
301 description: props.description ?? "",
302 machineType: props.machineType ?? DEFAULT_MACHINE_TYPE,
303 canIpForward: props.canIpForward === true,
304 labels: sortedRecord(props.labels),
305 networkTags: [...(props.networkTags ?? [])].sort(),
306 metadata: sortedRecord(props.metadata),
307 disks: resolvedDisks(props).map((disk) => ({
308 boot: disk.boot === true,
309 autoDelete: disk.autoDelete,
310 deviceName: disk.deviceName ?? "",
311 type: disk.type ?? "",
312 mode: disk.mode ?? "",
313 diskInterface: disk.diskInterface ?? "",
314 diskSizeGb: disk.diskSizeGb !== undefined ? String(disk.diskSizeGb) : "",
315 diskType: disk.diskType ?? "",
316 sourceImage: disk.sourceImage ?? "",
317 sourceSnapshot: disk.sourceSnapshot ?? "",
318 source: disk.source ?? "",
319 diskName: disk.diskName ?? "",
320 labels: sortedRecord(disk.labels),
321 })),
322 networkInterfaces: resolvedNics(props).map((nic) => ({
323 network: nic.network ?? "",
324 subnetwork: nic.subnetwork ?? "",
325 networkIP: nic.networkIP ?? "",
326 nicType: nic.nicType ?? "",
327 stackType: nic.stackType ?? "",
328 accessConfigs: nic.accessConfigs ?? [],
329 })),
330 serviceAccounts: props.serviceAccounts ?? [],
331 scheduling: props.scheduling ?? {},
332 });
333
334const initializeParamsOf = (
335 disk: InstanceTemplateDisk,
336): compute.AttachedDiskInitializeParams | undefined => {
337 if (
338 disk.sourceImage === undefined &&
339 disk.sourceSnapshot === undefined &&
340 disk.diskType === undefined &&
341 disk.diskName === undefined &&
342 disk.labels === undefined &&
343 disk.diskSizeGb === undefined
344 ) {
345 return undefined;
346 }
347 return {
348 sourceImage: disk.sourceImage,
349 sourceSnapshot: disk.sourceSnapshot,
350 diskType: disk.diskType,
351 diskName: disk.diskName,
352 labels: disk.labels,
353 diskSizeGb:
354 disk.diskSizeGb !== undefined ? String(disk.diskSizeGb) : undefined,
355 };
356};
357
358const toDisks = (disks: InstanceTemplateDisk[]): compute.AttachedDisk[] =>
359 disks.map((disk) => ({
360 boot: disk.boot,
361 autoDelete: disk.autoDelete,
362 deviceName: disk.deviceName,
363 type: disk.type,
364 mode: disk.mode,
365 interface: disk.diskInterface,
366 source: disk.source,
367 initializeParams: disk.source ? undefined : initializeParamsOf(disk),
368 }));
369
370const toNics = (
371 nics: InstanceTemplateNetworkInterface[],
372): compute.NetworkInterface[] =>
373 nics.map((nic) => ({
374 network: nic.network,
375 subnetwork: nic.subnetwork,
376 networkIP: nic.networkIP,
377 nicType: nic.nicType,
378 stackType: nic.stackType,
379 accessConfigs: nic.accessConfigs,
380 }));
381
382const toProperties = (
383 news: InstanceTemplateProps,
384 labels: Record<string, string>,
385): compute.InstanceProperties => ({
386 machineType: news.machineType ?? DEFAULT_MACHINE_TYPE,
387 canIpForward: news.canIpForward,
388 labels,
389 tags:
390 news.networkTags !== undefined ? { items: news.networkTags } : undefined,
391 metadata:
392 news.metadata !== undefined
393 ? {
394 items: Object.entries(news.metadata).map(([key, value]) => ({
395 key,
396 value,
397 })),
398 }
399 : undefined,
400 disks: toDisks(resolvedDisks(news)),
401 networkInterfaces: toNics(resolvedNics(news)),
402 serviceAccounts: news.serviceAccounts,
403 scheduling: news.scheduling,
404});
405
406const bootSourceImage = (template: compute.InstanceTemplate) => {
407 const boot = (template.properties?.disks ?? []).find(
408 (disk) => disk.boot === true,
409 );
410 return boot?.initializeParams?.sourceImage;
411};
412
413const toAttrs = (template: compute.InstanceTemplate, project: string) => ({
414 templateName: template.name ?? "",
415 project,
416 description: template.description,
417 machineType: template.properties?.machineType,
418 labels: userLabels(template.properties?.labels),
419 networkTags: template.properties?.tags?.items ?? [],
420 canIpForward: template.properties?.canIpForward === true,
421 sourceImage: bootSourceImage(template),
422 selfLink: template.selfLink,
423 templateId: template.id,
424 creationTimestamp: template.creationTimestamp,
425});
426
427const getByName = (project: string, instanceTemplate: string) =>
428 compute
429 .getInstanceTemplates({ project, instanceTemplate })
430 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
431
432const waitUntilPresent = (project: string, templateName: string) =>
433 getByName(project, templateName).pipe(
434 Effect.flatMap((template) =>
435 template !== undefined
436 ? Effect.succeed(template)
437 : Effect.fail(new InstanceTemplateNotResolved({ templateName })),
438 ),
439 Effect.retry({
440 while: (error) =>
441 error._tag === "GCP.Compute.InstanceTemplateNotResolved",
442 times: 8,
443 schedule: Schedule.spaced("1 second"),
444 }),
445 Effect.catchTag("GCP.Compute.InstanceTemplateNotResolved", () =>
446 Effect.succeed(undefined),
447 ),
448 );
449
450const waitUntilGone = (project: string, templateName: string) =>
451 getByName(project, templateName).pipe(
452 Effect.flatMap((template) =>
453 template === undefined
454 ? Effect.void
455 : Effect.fail(new InstanceTemplateStillExists({ templateName })),
456 ),
457 Effect.retry({
458 while: (error) =>
459 error._tag === "GCP.Compute.InstanceTemplateStillExists",
460 times: 10,
461 schedule: Schedule.spaced("1 second"),
462 }),
463 Effect.catchTag(
464 "GCP.Compute.InstanceTemplateStillExists",
465 () => Effect.void,
466 ),
467 );
468
469export const InstanceTemplateProvider = () =>
470 Provider.succeed(InstanceTemplate, {
471 stables: [
472 "templateName",
473 "project",
474 "templateId",
475 "selfLink",
476 "creationTimestamp",
477 ],
478
479 diff: Effect.fn(function* ({ news, olds, output }) {
480 if (!isResolved(news)) return undefined;
481 const previousName = olds?.templateName ?? output?.templateName;
482 const nextName = news.templateName ?? previousName;
483 if (previousName === undefined && output === undefined) {
484 return undefined;
485 }
486 const nameChanged =
487 news.templateName !== undefined &&
488 previousName !== undefined &&
489 news.templateName !== previousName;
490 const propsChanged =
491 olds !== undefined && fingerprint(news) !== fingerprint(olds);
492 const adoptedChanged =
493 olds === undefined &&
494 output !== undefined &&
495 ((news.machineType ?? DEFAULT_MACHINE_TYPE) !==
496 (output.machineType ?? DEFAULT_MACHINE_TYPE) ||
497 (news.description ?? "") !== (output.description ?? "") ||
498 (news.canIpForward === true) !== output.canIpForward ||
499 JSON.stringify(sortedRecord(news.labels)) !==
500 JSON.stringify(sortedRecord(output.labels)));
501 if (!nameChanged && !propsChanged && !adoptedChanged) {
502 return undefined;
503 }
504 return {
505 action: "replace" as const,
506 deleteFirst:
507 previousName !== undefined &&
508 nextName !== undefined &&
509 nextName === previousName,
510 };
511 }),
512
513 read: Effect.fn(function* ({ id, olds, output }) {
514 const env = yield* GcpEnvironment.current;
515 const templateName = yield* toName(
516 id,
517 olds?.templateName,
518 output?.templateName,
519 );
520 const existing = yield* getByName(env.project, templateName);
521 if (existing === undefined) return undefined;
522 const attrs = toAttrs(existing, env.project);
523 return (yield* hasAlchemyLabels(
524 id,
525 tagRecord(existing.properties?.labels),
526 ))
527 ? attrs
528 : Unowned(attrs);
529 }),
530
531 list: () =>
532 Effect.gen(function* () {
533 const env = yield* GcpEnvironment.current;
534 return yield* compute.listInstanceTemplates
535 .items({ project: env.project, maxResults: 500 })
536 .pipe(
537 Stream.filter((template) =>
538 Object.keys(template.properties?.labels ?? {}).some((key) =>
539 key.startsWith("alchemy-"),
540 ),
541 ),
542 Stream.map((template) => toAttrs(template, env.project)),
543 Stream.runCollect,
544 Effect.map((chunk) => Array.from(chunk)),
545 );
546 }),
547
548 reconcile: Effect.fn(function* ({ id, news, output }) {
549 const env = yield* GcpEnvironment.current;
550 const templateName = yield* toName(
551 id,
552 news.templateName,
553 output?.templateName,
554 );
555 const desiredLabels = {
556 ...toLabels(news.labels),
557 ...(yield* createInternalLabels(id)),
558 };
559
560 let current = yield* getByName(env.project, templateName);
561
562 if (current === undefined) {
563 yield* compute
564 .insertInstanceTemplates({
565 project: env.project,
566 body: {
567 name: templateName,
568 description: news.description,
569 properties: toProperties(news, desiredLabels),
570 },
571 })
572 .pipe(
573 Effect.flatMap((operation) =>
574 waitGlobalOperation(env.project, operation, {
575 ignore: ["RESOURCE_ALREADY_EXISTS"],
576 }),
577 ),
578 Effect.catchTag("Conflict", () => Effect.void),
579 );
580 current = yield* waitUntilPresent(env.project, templateName);
581 }
582
583 if (current === undefined) {
584 return yield* new InstanceTemplateNotResolved({ templateName });
585 }
586
587 return toAttrs(current, env.project);
588 }),
589
590 delete: Effect.fn(function* ({ output }) {
591 const env = yield* GcpEnvironment.current;
592 const operation = yield* compute
593 .deleteInstanceTemplates({
594 project: env.project,
595 instanceTemplate: output.templateName,
596 })
597 .pipe(
598 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
599 Effect.retry({
600 while: (error) => error._tag === "Conflict",
601 times: 8,
602 schedule: Schedule.spaced("2 seconds"),
603 }),
604 );
605 if (operation !== undefined) {
606 yield* waitGlobalOperation(env.project, operation, {
607 ignore: ["RESOURCE_NOT_FOUND"],
608 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
609 }
610 yield* waitUntilGone(env.project, output.templateName);
611 }),
612 });