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 InstanceGroupManagerResizeRequestDuration = compute.Duration;
23
24export type InstanceGroupManagerResizeRequestProps = {
25 /**
26 * Resize request name (RFC1035, 1-63 chars). If omitted, a unique name
27 * is generated from the stack, stage, and logical id. Changing it
28 * replaces the request.
29 */
30 resizeRequestName?: string;
31 /**
32 * Zone of the managed instance group (e.g. `us-central1-a`). Immutable.
33 * @default "us-central1-a"
34 */
35 zone?: string;
36 /**
37 * Managed instance group name or URL. Immutable — changing it replaces
38 * the request.
39 */
40 instanceGroupManager: string;
41 /**
42 * Number of VMs to create. The group's target size increases by this
43 * amount. Immutable.
44 */
45 resizeBy: number;
46 /**
47 * Optional description. Resize requests have no labels field — Alchemy
48 * ownership is stored in a `[alchemy …]` prefix for `list` / nuke.
49 */
50 description?: string;
51 /**
52 * How long the created VMs run; they are deleted when it elapses. Compute
53 * requires it on insert. Immutable.
54 */
55 requestedRunDuration: InstanceGroupManagerResizeRequestDuration;
56};
57
58export type InstanceGroupManagerResizeRequest = Resource<
59 "GCP.Compute.InstanceGroupManagerResizeRequest",
60 InstanceGroupManagerResizeRequestProps,
61 {
62 /** Resize request name. */
63 resizeRequestName: string;
64 /** Project id. */
65 project: string;
66 /** Zone short name. */
67 zone: string;
68 /** Managed instance group name. */
69 instanceGroupManager: string;
70 /** Number of VMs requested. */
71 resizeBy: number | undefined;
72 /** User description with the Alchemy ownership prefix stripped. */
73 description: string | undefined;
74 /** Requested run duration. */
75 requestedRunDuration: InstanceGroupManagerResizeRequestDuration | undefined;
76 /** Current state (`ACCEPTED`, `SUCCEEDED`, …). */
77 state: string | undefined;
78 /** Server-assigned numeric id. */
79 resizeRequestId: string | undefined;
80 /** Server-defined URL. */
81 selfLink: string | undefined;
82 /** RFC3339 creation timestamp. */
83 creationTimestamp: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A zonal queued resize request on a managed instance group.
91 *
92 * Resize requests provision additional MIG VMs immediately or by queueing
93 * until capacity is available (typically GPU / queued-provisioning
94 * machine types). Name, zone, MIG, `resizeBy`, and run duration replace
95 * the request — there is no in-place update. Compute has no labels
96 * field, so Alchemy stamps ownership into the description.
97 *
98 * Delete cancels an `ACCEPTED` / `CREATING` request first, then removes
99 * the record.
100 *
101 * ### Creating a Resize Request
102 * **Example:** Queue one extra VM
103 * ```typescript
104 * const template = yield* GCP.Compute.InstanceTemplate("web", {});
105 * const group = yield* GCP.Compute.InstanceGroupManager("web", {
106 * instanceTemplate: template.templateName,
107 * targetSize: 0,
108 * });
109 * const request = yield* GCP.Compute.InstanceGroupManagerResizeRequest(
110 * "burst",
111 * {
112 * instanceGroupManager: group.managerName,
113 * zone: group.zone,
114 * resizeBy: 1,
115 * requestedRunDuration: { seconds: "3600" },
116 * },
117 * );
118 * ```
119 *
120 * @resource
121 * @category Compute
122 */
123export const InstanceGroupManagerResizeRequest =
124 Resource<InstanceGroupManagerResizeRequest>(
125 "GCP.Compute.InstanceGroupManagerResizeRequest",
126 );
127
128export class InstanceGroupManagerResizeRequestNotResolved extends Data.TaggedError(
129 "GCP.Compute.InstanceGroupManagerResizeRequestNotResolved",
130)<{
131 resizeRequestName: string;
132 instanceGroupManager: string;
133 zone: string;
134}> {}
135
136const lastSegment = (value: string | undefined): string => {
137 if (value === undefined || value.length === 0) return "";
138 const parts = value.replace(/\/+$/, "").split("/");
139 return parts[parts.length - 1] || value;
140};
141
142const zoneOf = (value: string | undefined): string =>
143 lastSegment(value) || DEFAULT_ZONE;
144
145const rfc1035 = (name: string): string => {
146 let next = name
147 .toLowerCase()
148 .replace(/[^a-z0-9-]/g, "-")
149 .replace(/-+/g, "-")
150 .replace(/^-+/, "")
151 .replace(/-+$/, "");
152 if (!/^[a-z]/.test(next)) {
153 next = `r${next}`;
154 }
155 next = next.slice(0, 63).replace(/-+$/, "");
156 return next.length > 0 ? next : "resizerequest";
157};
158
159const toName = (id: string, name: string | undefined, existing?: string) =>
160 Effect.gen(function* () {
161 if (name !== undefined) return name;
162 if (existing !== undefined) return existing;
163 return rfc1035(
164 yield* createPhysicalName({
165 id,
166 maxLength: 63,
167 lowercase: true,
168 }),
169 );
170 });
171
172const encodeDescription = (
173 labels: Record<string, string>,
174 description: string | undefined,
175): string => {
176 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
177 return description ? `${marker}\n${description}` : marker;
178};
179
180const parseDescription = (
181 description: string | undefined,
182): {
183 labels: Record<string, string>;
184 description: string | undefined;
185} => {
186 if (!description?.startsWith("[alchemy ")) {
187 return { labels: {}, description };
188 }
189 const end = description.indexOf("]");
190 if (end < 0) return { labels: {}, description };
191 const labels: Record<string, string> = {};
192 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
193 const eq = part.indexOf("=");
194 if (eq > 0) {
195 labels[part.slice(0, eq)] = part.slice(eq + 1);
196 }
197 }
198 const rest = description.slice(end + 1).replace(/^\n/, "");
199 return { labels, description: rest.length > 0 ? rest : undefined };
200};
201
202const toAttrs = (
203 request: compute.InstanceGroupManagerResizeRequest,
204 project: string,
205 instanceGroupManager: string,
206): InstanceGroupManagerResizeRequest["Attributes"] => {
207 const parsed = parseDescription(request.description);
208 return {
209 resizeRequestName: request.name ?? request.id ?? "",
210 project,
211 zone: zoneOf(request.zone),
212 instanceGroupManager: lastSegment(instanceGroupManager),
213 resizeBy: request.resizeBy,
214 description: parsed.description,
215 requestedRunDuration: request.requestedRunDuration,
216 state: request.state,
217 resizeRequestId: request.id,
218 selfLink: request.selfLink,
219 creationTimestamp: request.creationTimestamp,
220 };
221};
222
223const getByName = (
224 project: string,
225 zone: string,
226 instanceGroupManager: string,
227 resizeRequest: string,
228) =>
229 compute
230 .getInstanceGroupManagerResizeRequests({
231 project,
232 zone,
233 instanceGroupManager,
234 resizeRequest,
235 })
236 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
237
238const awaitResource = (
239 project: string,
240 zone: string,
241 instanceGroupManager: string,
242 resizeRequestName: string,
243) =>
244 getByName(project, zone, instanceGroupManager, resizeRequestName).pipe(
245 Effect.repeat({
246 schedule: Schedule.spaced("1 second"),
247 until: (item) => item !== undefined,
248 times: 8,
249 }),
250 );
251
252const terminalStates = new Set(["SUCCEEDED", "FAILED", "CANCELLED"]);
253
254export const InstanceGroupManagerResizeRequestProvider = () =>
255 Provider.succeed(InstanceGroupManagerResizeRequest, {
256 stables: [
257 "resizeRequestName",
258 "project",
259 "zone",
260 "instanceGroupManager",
261 "resizeBy",
262 "resizeRequestId",
263 "selfLink",
264 "creationTimestamp",
265 ],
266
267 diff: Effect.fn(function* ({ news, olds, output }) {
268 if (!isResolved(news)) return undefined;
269 const previousName = olds?.resizeRequestName ?? output?.resizeRequestName;
270 const nextName = news.resizeRequestName;
271 const previousZone = zoneOf(olds?.zone ?? output?.zone);
272 const nextZone = zoneOf(news.zone ?? output?.zone);
273 const previousMig = lastSegment(
274 olds?.instanceGroupManager ?? output?.instanceGroupManager,
275 );
276 const nextMig = lastSegment(news.instanceGroupManager);
277 const previousBy = olds?.resizeBy ?? output?.resizeBy;
278 const nextBy = news.resizeBy;
279 if (
280 (previousName !== undefined &&
281 nextName !== undefined &&
282 previousName !== nextName) ||
283 previousZone !== nextZone ||
284 previousMig !== nextMig ||
285 (previousBy !== undefined && previousBy !== nextBy)
286 ) {
287 return {
288 action: "replace" as const,
289 deleteFirst:
290 previousName !== undefined &&
291 nextName !== undefined &&
292 previousName === nextName &&
293 previousZone === nextZone &&
294 previousMig === nextMig,
295 };
296 }
297 return undefined;
298 }),
299
300 read: Effect.fn(function* ({ id, olds, output }) {
301 const env = yield* GcpEnvironment.current;
302 const resizeRequestName = yield* toName(
303 id,
304 olds?.resizeRequestName,
305 output?.resizeRequestName,
306 );
307 const zone = zoneOf(olds?.zone ?? output?.zone);
308 const instanceGroupManager = lastSegment(
309 olds?.instanceGroupManager ?? output?.instanceGroupManager,
310 );
311 if (!instanceGroupManager) return undefined;
312 const existing = yield* getByName(
313 env.project,
314 zone,
315 instanceGroupManager,
316 resizeRequestName,
317 );
318 if (existing === undefined) return undefined;
319 const attrs = toAttrs(existing, env.project, instanceGroupManager);
320 const { labels } = parseDescription(existing.description);
321 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
322 }),
323
324 list: () =>
325 Effect.gen(function* () {
326 const env = yield* GcpEnvironment.current;
327 const pages = yield* compute.aggregatedListInstanceGroupManagers
328 .pages({
329 project: env.project,
330 maxResults: 500,
331 returnPartialSuccess: true,
332 })
333 .pipe(Stream.runCollect);
334 const managers = Array.from(pages).flatMap((page) =>
335 Object.entries(page.items ?? {}).flatMap(([scope, scoped]) => {
336 if (!scope.startsWith("zones/")) return [];
337 const zone = scope.slice("zones/".length);
338 return (scoped?.instanceGroupManagers ?? [])
339 .filter((manager) => manager.name)
340 .map((manager) => ({
341 zone,
342 name: manager.name ?? "",
343 }));
344 }),
345 );
346 const perManager = yield* Effect.forEach(
347 managers,
348 ({ zone, name }) =>
349 compute.listInstanceGroupManagerResizeRequests
350 .items({
351 project: env.project,
352 zone,
353 instanceGroupManager: name,
354 maxResults: 500,
355 })
356 .pipe(
357 Stream.filter((request) => {
358 const { labels } = parseDescription(request.description);
359 return Object.keys(labels).some((key) =>
360 key.startsWith("alchemy-"),
361 );
362 }),
363 Stream.map((request) => toAttrs(request, env.project, name)),
364 Stream.runCollect,
365 Effect.map((chunk) => Array.from(chunk)),
366 Effect.catchTag("NotFound", () => Effect.succeed([])),
367 ),
368 { concurrency: 4 },
369 );
370 return perManager.flat();
371 }),
372
373 reconcile: Effect.fn(function* ({ id, news, output }) {
374 const env = yield* GcpEnvironment.current;
375 const resizeRequestName = yield* toName(
376 id,
377 news.resizeRequestName,
378 output?.resizeRequestName,
379 );
380 const zone = zoneOf(news.zone ?? output?.zone);
381 const instanceGroupManager = lastSegment(news.instanceGroupManager);
382 const ownership = yield* createInternalLabels(id);
383 const description = encodeDescription(ownership, news.description);
384
385 let current = yield* getByName(
386 env.project,
387 zone,
388 instanceGroupManager,
389 resizeRequestName,
390 );
391
392 if (current === undefined) {
393 yield* compute
394 .insertInstanceGroupManagerResizeRequests({
395 project: env.project,
396 zone,
397 instanceGroupManager,
398 body: {
399 name: resizeRequestName,
400 description,
401 resizeBy: news.resizeBy,
402 requestedRunDuration: news.requestedRunDuration,
403 },
404 })
405 .pipe(
406 Effect.flatMap((operation) =>
407 waitZoneOperation(env.project, zone, operation, {
408 ignore: ["RESOURCE_ALREADY_EXISTS"],
409 }),
410 ),
411 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
412 );
413 current = yield* awaitResource(
414 env.project,
415 zone,
416 instanceGroupManager,
417 resizeRequestName,
418 );
419 }
420
421 if (current === undefined) {
422 return yield* new InstanceGroupManagerResizeRequestNotResolved({
423 resizeRequestName,
424 instanceGroupManager,
425 zone,
426 });
427 }
428
429 return toAttrs(current, env.project, instanceGroupManager);
430 }),
431
432 delete: Effect.fn(function* ({ output }) {
433 const env = yield* GcpEnvironment.current;
434 const zone = zoneOf(output.zone);
435 const instanceGroupManager = lastSegment(output.instanceGroupManager);
436 const existing = yield* getByName(
437 env.project,
438 zone,
439 instanceGroupManager,
440 output.resizeRequestName,
441 );
442 if (existing === undefined) return;
443
444 if (!terminalStates.has(existing.state ?? "")) {
445 const cancelled = yield* compute
446 .cancelInstanceGroupManagerResizeRequests({
447 project: env.project,
448 zone,
449 instanceGroupManager,
450 resizeRequest: output.resizeRequestName,
451 })
452 .pipe(
453 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
454 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
455 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
456 );
457 if (cancelled !== undefined) {
458 yield* waitZoneOperation(env.project, zone, cancelled).pipe(
459 Effect.catchTag("NotFound", () => Effect.void),
460 );
461 }
462 yield* getByName(
463 env.project,
464 zone,
465 instanceGroupManager,
466 output.resizeRequestName,
467 ).pipe(
468 Effect.repeat({
469 schedule: Schedule.spaced("1 second"),
470 until: (item) =>
471 item === undefined || terminalStates.has(item.state ?? ""),
472 times: 10,
473 }),
474 );
475 }
476
477 const operation = yield* compute
478 .deleteInstanceGroupManagerResizeRequests({
479 project: env.project,
480 zone,
481 instanceGroupManager,
482 resizeRequest: output.resizeRequestName,
483 })
484 .pipe(
485 Effect.retry({
486 while: (error) => error._tag === "Conflict",
487 schedule: Schedule.spaced("2 seconds"),
488 times: 8,
489 }),
490 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
491 );
492 if (operation !== undefined) {
493 yield* waitZoneOperation(env.project, zone, operation).pipe(
494 Effect.catchTag("NotFound", () => Effect.void),
495 );
496 }
497 }),
498 });