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 { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_ZONE = "us-central1-a";
21
22export type FutureReservationTimeWindow = compute.FutureReservationTimeWindow;
23export type FutureReservationSpecificSKUProperties =
24 compute.FutureReservationSpecificSKUProperties;
25export type FutureReservationCommitmentInfo =
26 compute.FutureReservationCommitmentInfo;
27
28export type FutureReservationProps = {
29 /**
30 * Future reservation name (RFC1035, 1-63 chars). If omitted, a unique
31 * name is generated from the stack, stage, and logical id. Changing it
32 * replaces the reservation.
33 */
34 futureReservationName?: string;
35 /**
36 * Zone of the reservation (e.g. `us-central1-a`). Immutable — changing
37 * it replaces the reservation.
38 * @default "us-central1-a"
39 */
40 zone?: string;
41 /**
42 * Optional description. Future reservations have no labels field —
43 * Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
44 * nuke.
45 */
46 description?: string;
47 /**
48 * Delivery window (`startTime` RFC3339 plus `endTime` or `duration`).
49 */
50 timeWindow?: FutureReservationTimeWindow;
51 /**
52 * Specific SKU properties (machine type + total count). Mutually
53 * exclusive with aggregate accelerator reservations.
54 */
55 specificSkuProperties?: FutureReservationSpecificSKUProperties;
56 /**
57 * Planning state. `DRAFT` keeps the reservation off the procurement
58 * queue; `SUBMITTED` starts evaluation.
59 * @default "DRAFT"
60 */
61 planningStatus?: compute.FutureReservationPlanningStatusEnum | (string & {});
62 /**
63 * Name prefix for auto-created reservations at delivery (max 20 chars).
64 */
65 namePrefix?: string;
66 /**
67 * Existing reservation name that receives capacity at delivery.
68 */
69 reservationName?: string;
70 /**
71 * Auto-delete auto-created reservations at end time. Compute requires this
72 * field on insert.
73 * @default false
74 */
75 autoDeleteAutoCreatedReservations?: boolean;
76 /**
77 * Only VMs that target the reservation by name can consume it.
78 */
79 specificReservationRequired?: boolean;
80 /**
81 * Reservation mode (`DEFAULT` or `CALENDAR`).
82 */
83 reservationMode?:
84 | compute.FutureReservationReservationModeEnum
85 | (string & {});
86 /**
87 * Deployment type (`DENSE` or unspecified).
88 */
89 deploymentType?: compute.FutureReservationDeploymentTypeEnum | (string & {});
90 /**
91 * Maintenance scheduling (`INDEPENDENT` or `GROUPED`).
92 */
93 schedulingType?: compute.FutureReservationSchedulingTypeEnum | (string & {});
94 /**
95 * Commitment attached at delivery, if any.
96 */
97 commitmentInfo?: FutureReservationCommitmentInfo;
98};
99
100export type FutureReservation = Resource<
101 "GCP.Compute.FutureReservation",
102 FutureReservationProps,
103 {
104 /** Future reservation name. */
105 futureReservationName: string;
106 /** Project id. */
107 project: string;
108 /** Zone short name. */
109 zone: string;
110 /** User description with the Alchemy ownership prefix stripped. */
111 description: string | undefined;
112 /** Delivery window. */
113 timeWindow: FutureReservationTimeWindow | undefined;
114 /** Specific SKU properties. */
115 specificSkuProperties: FutureReservationSpecificSKUProperties | undefined;
116 /** Planning status. */
117 planningStatus: string | undefined;
118 /** Name prefix for auto-created reservations. */
119 namePrefix: string | undefined;
120 /** Target reservation name. */
121 reservationName: string | undefined;
122 /** Procurement / amendment status. */
123 status: compute.FutureReservationStatus | undefined;
124 /** Server-assigned numeric id. */
125 futureReservationId: string | undefined;
126 /** Server-defined URL. */
127 selfLink: string | undefined;
128 /** RFC3339 creation timestamp. */
129 creationTimestamp: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A zonal Compute Engine future reservation.
137 *
138 * Future reservations lock capacity for a later delivery window. Name and
139 * zone replace the resource; description, planning status, and the time
140 * window update in place via `futureReservations.patch`. Compute has no
141 * labels field, so Alchemy stamps ownership into the description.
142 *
143 * Creating a reservation typically requires quota and, for `CALENDAR`
144 * mode, GPU/TPU SKUs. Draft reservations (`planningStatus: "DRAFT"`) stay
145 * off the procurement queue.
146 *
147 * ### Creating a Future Reservation
148 * **Example:** Draft n2 capacity
149 * ```typescript
150 * const reservation = yield* GCP.Compute.FutureReservation("burst", {
151 * planningStatus: "DRAFT",
152 * timeWindow: {
153 * startTime: "2030-06-01T00:00:00Z",
154 * endTime: "2030-06-08T00:00:00Z",
155 * },
156 * specificSkuProperties: {
157 * totalCount: "1",
158 * instanceProperties: { machineType: "n2-standard-2" },
159 * },
160 * });
161 * ```
162 *
163 * @resource
164 * @category Compute
165 */
166export const FutureReservation = Resource<FutureReservation>(
167 "GCP.Compute.FutureReservation",
168);
169
170export class FutureReservationNotResolved extends Data.TaggedError(
171 "GCP.Compute.FutureReservationNotResolved",
172)<{
173 futureReservationName: string;
174 zone: string;
175}> {}
176
177const lastSegment = (value: string | undefined): string => {
178 if (value === undefined || value.length === 0) return "";
179 const parts = value.replace(/\/+$/, "").split("/");
180 return parts[parts.length - 1] || value;
181};
182
183const zoneOf = (value: string | undefined): string =>
184 lastSegment(value) || DEFAULT_ZONE;
185
186const rfc1035 = (name: string): string => {
187 let next = name
188 .toLowerCase()
189 .replace(/[^a-z0-9-]/g, "-")
190 .replace(/-+/g, "-")
191 .replace(/^-+/, "")
192 .replace(/-+$/, "");
193 if (!/^[a-z]/.test(next)) {
194 next = `f${next}`;
195 }
196 next = next.slice(0, 63).replace(/-+$/, "");
197 return next.length > 0 ? next : "futurereservation";
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: 63,
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) {
236 labels[part.slice(0, eq)] = part.slice(eq + 1);
237 }
238 }
239 const rest = description.slice(end + 1).replace(/^\n/, "");
240 return { labels, description: rest.length > 0 ? rest : undefined };
241};
242
243const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
244
245const toBody = (
246 name: string,
247 props: FutureReservationProps,
248 ownership: Record<string, string>,
249): compute.FutureReservation => ({
250 name,
251 description: encodeDescription(ownership, props.description),
252 timeWindow: props.timeWindow,
253 specificSkuProperties: props.specificSkuProperties,
254 planningStatus: props.planningStatus ?? "DRAFT",
255 namePrefix: props.namePrefix,
256 reservationName: props.reservationName,
257 autoDeleteAutoCreatedReservations:
258 props.autoDeleteAutoCreatedReservations ?? false,
259 specificReservationRequired: props.specificReservationRequired,
260 reservationMode: props.reservationMode,
261 deploymentType: props.deploymentType,
262 schedulingType: props.schedulingType,
263 commitmentInfo: props.commitmentInfo,
264});
265
266const toAttrs = (
267 reservation: compute.FutureReservation,
268 project: string,
269): FutureReservation["Attributes"] => {
270 const parsed = parseDescription(reservation.description);
271 return {
272 futureReservationName: reservation.name ?? reservation.id ?? "",
273 project,
274 zone: zoneOf(reservation.zone),
275 description: parsed.description,
276 timeWindow: reservation.timeWindow,
277 specificSkuProperties: reservation.specificSkuProperties,
278 planningStatus: reservation.planningStatus,
279 namePrefix: reservation.namePrefix,
280 reservationName: reservation.reservationName,
281 status: reservation.status,
282 futureReservationId: reservation.id,
283 selfLink: reservation.selfLink,
284 creationTimestamp: reservation.creationTimestamp,
285 };
286};
287
288const needsUpdate = (
289 current: compute.FutureReservation,
290 desired: compute.FutureReservation,
291) => {
292 if ((current.description ?? "") !== (desired.description ?? "")) return true;
293 if (
294 (current.planningStatus ?? "DRAFT") !== (desired.planningStatus ?? "DRAFT")
295 ) {
296 return true;
297 }
298 if (jsonOf(current.timeWindow) !== jsonOf(desired.timeWindow)) return true;
299 if (
300 jsonOf(current.specificSkuProperties) !==
301 jsonOf(desired.specificSkuProperties)
302 ) {
303 return true;
304 }
305 if ((current.namePrefix ?? "") !== (desired.namePrefix ?? "")) return true;
306 if ((current.reservationName ?? "") !== (desired.reservationName ?? "")) {
307 return true;
308 }
309 if (
310 (current.autoDeleteAutoCreatedReservations ?? false) !==
311 (desired.autoDeleteAutoCreatedReservations ?? false)
312 ) {
313 return true;
314 }
315 if (
316 (current.specificReservationRequired ?? false) !==
317 (desired.specificReservationRequired ?? false)
318 ) {
319 return true;
320 }
321 return false;
322};
323
324const getByName = (project: string, zone: string, futureReservation: string) =>
325 compute
326 .getFutureReservations({ project, zone, futureReservation })
327 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
328
329const awaitResource = (
330 project: string,
331 zone: string,
332 futureReservationName: string,
333) =>
334 getByName(project, zone, futureReservationName).pipe(
335 Effect.repeat({
336 schedule: Schedule.spaced("1 second"),
337 until: (item) => item !== undefined,
338 times: 8,
339 }),
340 );
341
342export const FutureReservationProvider = () =>
343 Provider.succeed(FutureReservation, {
344 stables: [
345 "futureReservationName",
346 "project",
347 "zone",
348 "futureReservationId",
349 "selfLink",
350 "creationTimestamp",
351 ],
352
353 diff: Effect.fn(function* ({ news, olds, output }) {
354 if (!isResolved(news)) return undefined;
355 const previousName =
356 olds?.futureReservationName ?? output?.futureReservationName;
357 const nextName = news.futureReservationName;
358 const previousZone = zoneOf(olds?.zone ?? output?.zone);
359 const nextZone = zoneOf(news.zone ?? output?.zone);
360 if (
361 (previousName !== undefined &&
362 nextName !== undefined &&
363 previousName !== nextName) ||
364 previousZone !== nextZone
365 ) {
366 return {
367 action: "replace" as const,
368 deleteFirst:
369 previousName !== undefined &&
370 nextName !== undefined &&
371 previousName === nextName &&
372 previousZone === nextZone,
373 };
374 }
375 return undefined;
376 }),
377
378 read: Effect.fn(function* ({ id, olds, output }) {
379 const env = yield* GcpEnvironment.current;
380 const futureReservationName = yield* toName(
381 id,
382 olds?.futureReservationName,
383 output?.futureReservationName,
384 );
385 const zone = zoneOf(olds?.zone ?? output?.zone);
386 const existing = yield* getByName(
387 env.project,
388 zone,
389 futureReservationName,
390 );
391 if (existing === undefined) return undefined;
392 const attrs = toAttrs(existing, env.project);
393 const { labels } = parseDescription(existing.description);
394 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
395 }),
396
397 list: () =>
398 Effect.gen(function* () {
399 const env = yield* GcpEnvironment.current;
400 const pages = yield* compute.aggregatedListFutureReservations
401 .pages({
402 project: env.project,
403 returnPartialSuccess: true,
404 maxResults: 500,
405 })
406 .pipe(Stream.runCollect);
407 return Array.from(pages).flatMap((page) =>
408 Object.values(page.items ?? {}).flatMap((scoped) =>
409 (scoped?.futureReservations ?? [])
410 .filter((item) => {
411 const { labels } = parseDescription(item.description);
412 return Object.keys(labels).some((key) =>
413 key.startsWith("alchemy-"),
414 );
415 })
416 .map((item) => toAttrs(item, env.project)),
417 ),
418 );
419 }),
420
421 reconcile: Effect.fn(function* ({ id, news, output }) {
422 const env = yield* GcpEnvironment.current;
423 const futureReservationName = yield* toName(
424 id,
425 news.futureReservationName,
426 output?.futureReservationName,
427 );
428 const zone = zoneOf(news.zone ?? output?.zone);
429 const ownership = yield* createInternalLabels(id);
430 const desired = toBody(futureReservationName, news, ownership);
431
432 let current = yield* getByName(env.project, zone, futureReservationName);
433
434 if (current === undefined) {
435 yield* compute
436 .insertFutureReservations({
437 project: env.project,
438 zone,
439 body: desired,
440 })
441 .pipe(
442 Effect.flatMap((operation) =>
443 waitZoneOperation(env.project, zone, operation, {
444 ignore: ["RESOURCE_ALREADY_EXISTS"],
445 }),
446 ),
447 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
448 );
449 current = yield* awaitResource(
450 env.project,
451 zone,
452 futureReservationName,
453 );
454 }
455
456 if (current === undefined) {
457 return yield* new FutureReservationNotResolved({
458 futureReservationName,
459 zone,
460 });
461 }
462
463 if (needsUpdate(current, desired)) {
464 yield* compute
465 .updateFutureReservations({
466 project: env.project,
467 zone,
468 futureReservation: futureReservationName,
469 body: desired,
470 })
471 .pipe(
472 Effect.flatMap((operation) =>
473 waitZoneOperation(env.project, zone, operation),
474 ),
475 );
476 current = yield* getByName(env.project, zone, futureReservationName);
477 if (current === undefined) {
478 return yield* new FutureReservationNotResolved({
479 futureReservationName,
480 zone,
481 });
482 }
483 }
484
485 return toAttrs(current, env.project);
486 }),
487
488 delete: Effect.fn(function* ({ output }) {
489 const env = yield* GcpEnvironment.current;
490 const zone = zoneOf(output.zone);
491 const operation = yield* compute
492 .deleteFutureReservations({
493 project: env.project,
494 zone,
495 futureReservation: output.futureReservationName,
496 })
497 .pipe(
498 Effect.retry({
499 while: (error) => error._tag === "Conflict",
500 schedule: Schedule.spaced("2 seconds"),
501 times: 8,
502 }),
503 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
504 );
505 if (operation !== undefined) {
506 yield* waitZoneOperation(env.project, zone, operation).pipe(
507 Effect.catchTag("NotFound", () => Effect.void),
508 );
509 }
510 }),
511 });