1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } 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 DEFAULT_ZONE = "us-central1-a";
22const MAX_NAME_LENGTH = 63;
23
24export type ReservationInstanceProperties =
25 compute.AllocationSpecificSKUAllocationReservedInstanceProperties;
26export type ReservationShareSettings = compute.ShareSettings;
27export type ReservationSharingPolicy =
28 compute.AllocationReservationSharingPolicy;
29export type ReservationAggregate = compute.AllocationAggregateReservation;
30export type ReservationDuration = compute.Duration;
31export type ReservationDeploymentType =
32 | compute.ReservationDeploymentTypeEnum
33 | (string & {});
34export type ReservationSchedulingType =
35 | compute.ReservationSchedulingTypeEnum
36 | (string & {});
37export type ReservationProtectionTier =
38 | compute.ReservationProtectionTierEnum
39 | (string & {});
40export type ReservationEarlyAccessMaintenance =
41 | compute.ReservationEarlyAccessMaintenanceEnum
42 | (string & {});
43export type ReservationConfidentialComputeType =
44 | compute.ReservationConfidentialComputeTypeEnum
45 | (string & {});
46
47export type ReservationSpecificSku = {
48 /**
49 * Number of reserved VM instances. Mutable in place via
50 * `reservations.resize`.
51 */
52 count: number | string;
53 /**
54 * Machine shape of each reserved VM. Immutable — changing it replaces
55 * the reservation. Mutually exclusive with `sourceInstanceTemplate`.
56 */
57 instanceProperties?: ReservationInstanceProperties;
58 /**
59 * Instance template URL used instead of `instanceProperties`. Immutable
60 * — changing it replaces the reservation.
61 */
62 sourceInstanceTemplate?: string;
63};
64
65export type ReservationProps = {
66 /**
67 * Reservation name (RFC1035, 1-63 characters). If omitted, a unique name
68 * is generated from the stack, stage, and logical id. Changing the name
69 * replaces the reservation.
70 */
71 reservationName?: string;
72 /**
73 * Zone the reservation lives in (e.g. `us-central1-a`). Immutable —
74 * changing it replaces the reservation.
75 * @default "us-central1-a"
76 */
77 zone?: string;
78 /**
79 * Optional description. Compute reservations have no labels field, so
80 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
81 * is stored in a `[alchemy …]` prefix for `list` / nuke. Changing it
82 * replaces the reservation.
83 */
84 description?: string;
85 /**
86 * Specific-SKU reservation (count + machine shape or instance template).
87 * Machine shape and template are immutable; `count` resizes in place.
88 */
89 specificReservation?: ReservationSpecificSku;
90 /**
91 * When true, only VMs that target this reservation by name can consume
92 * it. Immutable — changing it replaces the reservation.
93 * @default false
94 */
95 specificReservationRequired?: boolean;
96 /**
97 * Share settings for a shared reservation.
98 */
99 shareSettings?: ReservationShareSettings;
100 /**
101 * Resource policies attached to this reservation (placement).
102 */
103 resourcePolicies?: Record<string, string>;
104 /**
105 * RFC3339 time when Compute Engine auto-deletes the reservation.
106 */
107 deleteAtTime?: string;
108 /**
109 * Duration after create when Compute Engine auto-deletes the
110 * reservation.
111 */
112 deleteAfterDuration?: ReservationDuration;
113 /**
114 * Allow unplanned (emergent) maintenance for reserved VMs.
115 */
116 enableEmergentMaintenance?: boolean;
117 /**
118 * Early-access maintenance enrollment (`NO_EARLY_ACCESS`, `WAVE1`,
119 * `WAVE2`).
120 */
121 earlyAccessMaintenance?: ReservationEarlyAccessMaintenance;
122 /**
123 * Workload protection tier.
124 */
125 protectionTier?: ReservationProtectionTier;
126 /**
127 * Deployment strategy. Immutable — changing it replaces the reservation.
128 */
129 deploymentType?: ReservationDeploymentType;
130 /**
131 * Maintenance scheduling type.
132 */
133 schedulingType?: ReservationSchedulingType;
134 /**
135 * Confidential compute type.
136 */
137 confidentialComputeType?: ReservationConfidentialComputeType;
138 /**
139 * Aggregate (accelerator) reservation. Immutable — switching between
140 * specific and aggregate replaces the reservation.
141 */
142 aggregateReservation?: ReservationAggregate;
143 /**
144 * Sharing policy with Google Cloud managed services.
145 */
146 reservationSharingPolicy?: ReservationSharingPolicy;
147};
148
149export type Reservation = Resource<
150 "GCP.Compute.Reservation",
151 ReservationProps,
152 {
153 /** Reservation name. */
154 reservationName: string;
155 /** Server-assigned numeric id. */
156 reservationId: string | undefined;
157 /** Project id. */
158 project: string;
159 /** Zone short name (`us-central1-a`). */
160 zone: string;
161 /** User description with the Alchemy ownership prefix stripped. */
162 description: string | undefined;
163 /** Server-reported status (`READY`, `CREATING`, `DELETING`, …). */
164 status: string | undefined;
165 /** Specific-SKU reservation, if any. */
166 specificReservation: compute.AllocationSpecificSKUReservation | undefined;
167 /** Whether consumption requires targeting this reservation by name. */
168 specificReservationRequired: boolean;
169 /** Share settings, if any. */
170 shareSettings: ReservationShareSettings | undefined;
171 /** Attached resource policies. */
172 resourcePolicies: Record<string, string>;
173 /** Parent commitment URL, if any. */
174 commitment: string | undefined;
175 /** Compute Engine self-link. */
176 selfLink: string | undefined;
177 /** RFC3339 creation timestamp. */
178 creationTimestamp: string | undefined;
179 },
180 never,
181 Providers
182>;
183
184/**
185 * A zonal Compute Engine capacity reservation.
186 *
187 * Reservations hold VM capacity in a zone even when the reserved VMs are
188 * not running. Compute Engine has no labels on this resource, so Alchemy
189 * stamps ownership into the description (`[alchemy alchemy-stack=…
190 * alchemy-stage=… alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find
191 * them.
192 *
193 * Name, zone, machine shape, `specificReservationRequired`, and
194 * `deploymentType` are immutable — changing them replaces the reservation.
195 * `specificReservation.count` resizes in place. Description and share
196 * settings update in place via `reservations.patch`.
197 *
198 * ### Creating a Reservation
199 * **Example:** One `n1-standard-1` in the default zone
200 * ```typescript
201 * const reserved = yield* GCP.Compute.Reservation("Burst", {
202 * specificReservation: {
203 * count: 1,
204 * instanceProperties: { machineType: "n1-standard-1" },
205 * },
206 * specificReservationRequired: true,
207 * });
208 * ```
209 *
210 * **Example:** Named reservation with a description
211 * ```typescript
212 * const reserved = yield* GCP.Compute.Reservation("Burst", {
213 * reservationName: "app-burst",
214 * zone: "us-central1-a",
215 * description: "on-demand burst capacity",
216 * specificReservation: {
217 * count: 2,
218 * instanceProperties: { machineType: "n1-standard-1" },
219 * },
220 * });
221 * ```
222 *
223 * ### Resizing a Reservation
224 * **Example:** Grow count in place
225 * ```typescript
226 * const reserved = yield* GCP.Compute.Reservation("Burst", {
227 * reservationName: "app-burst",
228 * specificReservation: {
229 * count: 4,
230 * instanceProperties: { machineType: "n1-standard-1" },
231 * },
232 * });
233 * ```
234 *
235 * @resource
236 * @category Compute
237 */
238export const Reservation = Resource<Reservation>("GCP.Compute.Reservation");
239
240export class ReservationNotResolved extends Data.TaggedError(
241 "GCP.Compute.ReservationNotResolved",
242)<{
243 reservationName: string;
244 zone: string;
245}> {}
246
247export class ReservationNotReady extends Data.TaggedError(
248 "GCP.Compute.ReservationNotReady",
249)<{
250 reservationName: string;
251 status: string;
252}> {}
253
254export class ReservationFailed extends Data.TaggedError(
255 "GCP.Compute.ReservationFailed",
256)<{
257 reservationName: string;
258 status: string;
259}> {}
260
261export class ReservationStillExists extends Data.TaggedError(
262 "GCP.Compute.ReservationStillExists",
263)<{
264 reservationName: string;
265 status: string;
266}> {}
267
268const lastSegment = (value: string | undefined): string => {
269 if (value === undefined || value.length === 0) return "";
270 const trimmed = value.replace(/\/+$/, "");
271 const parts = trimmed.split("/");
272 return parts[parts.length - 1] || trimmed;
273};
274
275const normalizeZone = (zone: string | undefined) =>
276 lastSegment(zone ?? DEFAULT_ZONE).toLowerCase();
277
278const toName = (id: string, name: string | undefined, existing?: string) =>
279 Effect.gen(function* () {
280 if (name !== undefined) return name;
281 if (existing !== undefined) return existing;
282 const generated = yield* createPhysicalName({
283 id,
284 maxLength: MAX_NAME_LENGTH,
285 lowercase: true,
286 });
287 return /^[a-z]/.test(generated)
288 ? generated
289 : `r${generated}`.slice(0, MAX_NAME_LENGTH);
290 });
291
292const encodeDescription = (
293 labels: Record<string, string>,
294 description: string | undefined,
295): string => {
296 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
297 return description ? `${marker}\n${description}` : marker;
298};
299
300const parseDescription = (
301 description: string | undefined,
302): {
303 labels: Record<string, string>;
304 description: string | undefined;
305} => {
306 if (!description?.startsWith("[alchemy ")) {
307 return { labels: {}, description };
308 }
309 const end = description.indexOf("]");
310 if (end < 0) return { labels: {}, description };
311 const labels: Record<string, string> = {};
312 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
313 const eq = part.indexOf("=");
314 if (eq > 0) {
315 labels[part.slice(0, eq)] = part.slice(eq + 1);
316 }
317 }
318 const rest = description.slice(end + 1).replace(/^\n/, "");
319 return { labels, description: rest.length > 0 ? rest : undefined };
320};
321
322const hasOwnershipMarker = (description: string | undefined) =>
323 Object.keys(parseDescription(description).labels).some((key) =>
324 key.startsWith("alchemy-"),
325 );
326
327const countOf = (value: number | string | undefined): string | undefined => {
328 if (value === undefined) return undefined;
329 return String(value);
330};
331
332const toBody = (
333 reservationName: string,
334 props: ReservationProps,
335 ownership: Record<string, string>,
336): compute.Reservation => ({
337 name: reservationName,
338 description: encodeDescription(ownership, props.description),
339 specificReservation:
340 props.specificReservation !== undefined
341 ? {
342 count: countOf(props.specificReservation.count),
343 instanceProperties: props.specificReservation.instanceProperties,
344 sourceInstanceTemplate:
345 props.specificReservation.sourceInstanceTemplate,
346 }
347 : undefined,
348 specificReservationRequired: props.specificReservationRequired === true,
349 shareSettings: props.shareSettings,
350 resourcePolicies: props.resourcePolicies,
351 deleteAtTime: props.deleteAtTime,
352 deleteAfterDuration: props.deleteAfterDuration,
353 enableEmergentMaintenance: props.enableEmergentMaintenance,
354 earlyAccessMaintenance: props.earlyAccessMaintenance,
355 protectionTier: props.protectionTier,
356 deploymentType: props.deploymentType,
357 schedulingType: props.schedulingType,
358 confidentialComputeType: props.confidentialComputeType,
359 aggregateReservation: props.aggregateReservation,
360 reservationSharingPolicy: props.reservationSharingPolicy,
361});
362
363const toAttrs = (
364 reservation: compute.Reservation,
365 project: string,
366): Reservation["Attributes"] => {
367 const parsed = parseDescription(reservation.description);
368 return {
369 reservationName: reservation.name ?? reservation.id ?? "",
370 reservationId: reservation.id,
371 project,
372 zone: normalizeZone(reservation.zone),
373 description: parsed.description,
374 status: reservation.status,
375 specificReservation: reservation.specificReservation,
376 specificReservationRequired:
377 reservation.specificReservationRequired === true,
378 shareSettings: reservation.shareSettings,
379 resourcePolicies: tagRecord(reservation.resourcePolicies),
380 commitment: reservation.commitment,
381 selfLink: reservation.selfLink,
382 creationTimestamp: reservation.creationTimestamp,
383 };
384};
385
386const sameJson = (left: unknown, right: unknown) =>
387 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
388
389const getByName = (project: string, zone: string, reservation: string) =>
390 compute
391 .getReservations({ project, zone, reservation })
392 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
393
394const waitReservationReady = (
395 project: string,
396 zone: string,
397 reservationName: string,
398) =>
399 getByName(project, zone, reservationName).pipe(
400 Effect.flatMap((reservation) =>
401 reservation?.status === "INVALID"
402 ? Effect.fail(
403 new ReservationFailed({
404 reservationName,
405 status: "INVALID",
406 }),
407 )
408 : Effect.succeed(reservation),
409 ),
410 Effect.filterOrFail(
411 (reservation): reservation is compute.Reservation =>
412 reservation !== undefined && reservation.status === "READY",
413 (reservation) =>
414 new ReservationNotReady({
415 reservationName,
416 status: reservation?.status ?? "MISSING",
417 }),
418 ),
419 Effect.retry({
420 while: (error) => error._tag === "GCP.Compute.ReservationNotReady",
421 times: 150,
422 schedule: Schedule.spaced("2 seconds"),
423 }),
424 );
425
426const waitReservationGone = (
427 project: string,
428 zone: string,
429 reservationName: string,
430) =>
431 getByName(project, zone, reservationName).pipe(
432 Effect.flatMap((reservation) =>
433 reservation === undefined
434 ? Effect.void
435 : Effect.fail(
436 new ReservationStillExists({
437 reservationName,
438 status: reservation.status ?? "UNKNOWN",
439 }),
440 ),
441 ),
442 Effect.retry({
443 while: (error) => error instanceof ReservationStillExists,
444 times: 150,
445 schedule: Schedule.spaced("2 seconds"),
446 }),
447 );
448
449export const ReservationProvider = () =>
450 Provider.succeed(Reservation, {
451 stables: [
452 "reservationName",
453 "reservationId",
454 "project",
455 "zone",
456 "selfLink",
457 "creationTimestamp",
458 ],
459
460 diff: Effect.fn(function* ({ news, olds, output }) {
461 if (!isResolved(news)) return undefined;
462
463 const previousName = olds?.reservationName ?? output?.reservationName;
464 const nextName = news.reservationName ?? previousName;
465 const nameChanged =
466 previousName !== undefined &&
467 nextName !== undefined &&
468 previousName !== nextName;
469
470 const previousZone = normalizeZone(olds?.zone ?? output?.zone);
471 const nextZone = normalizeZone(news.zone ?? output?.zone);
472 const zoneChanged = previousZone !== nextZone;
473
474 const previousRequired =
475 olds?.specificReservationRequired ??
476 output?.specificReservationRequired ??
477 false;
478 const nextRequired = news.specificReservationRequired === true;
479
480 const previousMachine =
481 olds?.specificReservation?.instanceProperties?.machineType ??
482 output?.specificReservation?.instanceProperties?.machineType;
483 const nextMachine =
484 news.specificReservation?.instanceProperties?.machineType;
485 const machineChanged =
486 nextMachine !== undefined &&
487 previousMachine !== undefined &&
488 previousMachine !== nextMachine;
489
490 const previousTemplate =
491 olds?.specificReservation?.sourceInstanceTemplate ??
492 output?.specificReservation?.sourceInstanceTemplate;
493 const nextTemplate = news.specificReservation?.sourceInstanceTemplate;
494 const templateChanged =
495 (previousTemplate ?? "") !== (nextTemplate ?? "") &&
496 (previousTemplate !== undefined || nextTemplate !== undefined);
497
498 const previousIsAggregate = olds?.aggregateReservation !== undefined;
499 const nextIsAggregate = news.aggregateReservation !== undefined;
500 const previousIsSpecific =
501 (olds?.specificReservation ?? output?.specificReservation) !==
502 undefined;
503 const nextIsSpecific = news.specificReservation !== undefined;
504 const kindChanged =
505 (previousIsAggregate && nextIsSpecific) ||
506 (previousIsSpecific && nextIsAggregate);
507
508 const deploymentChanged =
509 olds?.deploymentType !== undefined &&
510 news.deploymentType !== undefined &&
511 olds.deploymentType !== news.deploymentType;
512
513 // `reservations.update` accepts a description mask but leaves the
514 // stored description unchanged, so a new description replaces.
515 const descriptionChanged =
516 olds !== undefined &&
517 (olds.description ?? "") !== (news.description ?? "");
518
519 if (
520 nameChanged ||
521 zoneChanged ||
522 previousRequired !== nextRequired ||
523 machineChanged ||
524 templateChanged ||
525 kindChanged ||
526 deploymentChanged ||
527 descriptionChanged
528 ) {
529 return {
530 action: "replace" as const,
531 deleteFirst:
532 previousName !== undefined &&
533 nextName === previousName &&
534 previousZone === nextZone,
535 };
536 }
537 return undefined;
538 }),
539
540 read: Effect.fn(function* ({ id, olds, output }) {
541 const env = yield* GcpEnvironment.current;
542 const reservationName = yield* toName(
543 id,
544 olds?.reservationName,
545 output?.reservationName,
546 );
547 const zone = normalizeZone(olds?.zone ?? output?.zone);
548 const existing = yield* getByName(env.project, zone, reservationName);
549 if (existing === undefined) return undefined;
550 const attrs = toAttrs(existing, env.project);
551 const { labels } = parseDescription(existing.description);
552 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
553 }),
554
555 list: () =>
556 Effect.gen(function* () {
557 const env = yield* GcpEnvironment.current;
558 const pages = yield* compute.aggregatedListReservations
559 .pages({
560 project: env.project,
561 returnPartialSuccess: true,
562 maxResults: 500,
563 })
564 .pipe(Stream.runCollect);
565 return Array.from(pages).flatMap((page) =>
566 Object.values(page.items ?? {}).flatMap((scoped) =>
567 (scoped?.reservations ?? [])
568 .filter((reservation) =>
569 hasOwnershipMarker(reservation.description),
570 )
571 .map((reservation) => toAttrs(reservation, env.project)),
572 ),
573 );
574 }),
575
576 reconcile: Effect.fn(function* ({ id, news, output }) {
577 const env = yield* GcpEnvironment.current;
578 const reservationName = yield* toName(
579 id,
580 news.reservationName,
581 output?.reservationName,
582 );
583 const zone = normalizeZone(news.zone ?? output?.zone);
584 const ownership = yield* createInternalLabels(id);
585 const desired = toBody(reservationName, news, ownership);
586
587 let current = yield* getByName(env.project, zone, reservationName);
588 if (current?.status === "DELETING") {
589 yield* waitReservationGone(env.project, zone, reservationName);
590 current = undefined;
591 }
592
593 if (current === undefined) {
594 const inserted = yield* compute
595 .insertReservations({
596 project: env.project,
597 zone,
598 body: desired,
599 })
600 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
601 if (inserted !== undefined) {
602 yield* waitZoneOperation(env.project, zone, inserted, {
603 ignore: ["RESOURCE_ALREADY_EXISTS"],
604 });
605 }
606 current = yield* waitReservationReady(
607 env.project,
608 zone,
609 reservationName,
610 );
611 }
612
613 if (current === undefined) {
614 return yield* new ReservationNotResolved({
615 reservationName,
616 zone,
617 });
618 }
619
620 if (current.status === "CREATING" || current.status === "UPDATING") {
621 current = yield* waitReservationReady(
622 env.project,
623 zone,
624 reservationName,
625 );
626 }
627
628 if (current === undefined) {
629 return yield* new ReservationNotResolved({
630 reservationName,
631 zone,
632 });
633 }
634
635 const descriptionChanged =
636 (current.description ?? "") !== (desired.description ?? "");
637 // Compute echoes a default `{ shareType: "LOCAL" }` when unset.
638 const shareChanged =
639 desired.shareSettings !== undefined &&
640 !sameJson(current.shareSettings, desired.shareSettings);
641 const emergentChanged =
642 (current.enableEmergentMaintenance ?? false) !==
643 (desired.enableEmergentMaintenance ?? false);
644
645 if (descriptionChanged || shareChanged || emergentChanged) {
646 const patched = yield* compute.updateReservations({
647 project: env.project,
648 zone,
649 reservation: reservationName,
650 updateMask: [
651 descriptionChanged ? "description" : undefined,
652 shareChanged ? "shareSettings" : undefined,
653 emergentChanged ? "enableEmergentMaintenance" : undefined,
654 ]
655 .filter((field): field is string => field !== undefined)
656 .join(","),
657 // `reservations.update` rejects a body without `name`.
658 body: {
659 name: reservationName,
660 description: desired.description,
661 shareSettings: desired.shareSettings,
662 enableEmergentMaintenance: desired.enableEmergentMaintenance,
663 },
664 });
665 yield* waitZoneOperation(env.project, zone, patched);
666 current =
667 (yield* getByName(env.project, zone, reservationName)) ??
668 (yield* waitReservationReady(env.project, zone, reservationName));
669 }
670
671 if (current === undefined) {
672 return yield* new ReservationNotResolved({
673 reservationName,
674 zone,
675 });
676 }
677
678 const desiredCount = desired.specificReservation?.count;
679 const observedCount = current.specificReservation?.count;
680 if (
681 desiredCount !== undefined &&
682 observedCount !== undefined &&
683 desiredCount !== observedCount
684 ) {
685 const resized = yield* compute.resizeReservations({
686 project: env.project,
687 zone,
688 reservation: reservationName,
689 body: { specificSkuCount: desiredCount },
690 });
691 yield* waitZoneOperation(env.project, zone, resized);
692 current = yield* waitReservationReady(
693 env.project,
694 zone,
695 reservationName,
696 );
697 }
698
699 if (current === undefined) {
700 return yield* new ReservationNotResolved({
701 reservationName,
702 zone,
703 });
704 }
705
706 return toAttrs(current, env.project);
707 }),
708
709 delete: Effect.fn(function* ({ output }) {
710 const env = yield* GcpEnvironment.current;
711 const project = output.project || env.project;
712 const zone = normalizeZone(output.zone);
713 const deleted = yield* compute
714 .deleteReservations({
715 project,
716 zone,
717 reservation: output.reservationName,
718 })
719 .pipe(
720 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
721 Effect.retry({
722 while: (error) => error._tag === "Conflict",
723 times: 8,
724 schedule: Schedule.spaced("2 seconds"),
725 }),
726 );
727 if (deleted !== undefined) {
728 yield* waitZoneOperation(project, zone, deleted, {
729 ignore: ["RESOURCE_NOT_FOUND"],
730 });
731 }
732 yield* waitReservationGone(project, zone, output.reservationName);
733 }),
734 });