1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 encodeDescription,
4 hasOwnershipMarker,
5 lastSegment,
6 normalizeRegion,
7 parseDescription,
8 runRegionOp,
9 toPhysicalName,
10} from "./internal.ts";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import * as Stream from "effect/Stream";
15import { Unowned } from "../../AdoptPolicy.ts";
16import { isResolved } from "../../Diff.ts";
17import * as Provider from "../../Provider.ts";
18import { Resource } from "../../Resource.ts";
19import { GcpEnvironment } from "../Environment.ts";
20import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23export type RegionInstanceGroupManagerResizeRequestDuration = {
24 /** Whole seconds. */
25 seconds?: string;
26 /** Fractional seconds at nanosecond resolution. */
27 nanos?: number;
28};
29
30export type RegionInstanceGroupManagerResizeRequestProps = {
31 /**
32 * Resize-request name (RFC1035, 1-63 characters). If omitted, a unique
33 * name is generated from the stack, stage, and logical id. Changing it
34 * replaces the request.
35 */
36 requestName?: string;
37 /**
38 * Region of the parent managed instance group. Immutable. `US-CENTRAL1`
39 * is accepted and normalized to `us-central1`.
40 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
41 */
42 region?: string;
43 /**
44 * Parent regional managed instance group name or URL. Immutable —
45 * changing it replaces the request.
46 */
47 instanceGroupManager: string;
48 /**
49 * Number of instances to create. The group's target size increases by
50 * this number. Immutable — changing it replaces the request.
51 */
52 resizeBy: number;
53 /**
54 * Optional description. Resize requests have no labels field, so
55 * Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
56 * nuke.
57 */
58 description?: string;
59 /**
60 * Requested run duration for VMs created by this request. At the end of
61 * the duration the instance is deleted. Compute requires it on insert.
62 * Immutable.
63 */
64 requestedRunDuration: RegionInstanceGroupManagerResizeRequestDuration;
65};
66
67export type RegionInstanceGroupManagerResizeRequest = Resource<
68 "GCP.Compute.RegionInstanceGroupManagerResizeRequest",
69 RegionInstanceGroupManagerResizeRequestProps,
70 {
71 /** Resize-request name. */
72 requestName: string;
73 /** Project id. */
74 project: string;
75 /** Region short name (`us-central1`). */
76 region: string;
77 /** Parent managed instance group name. */
78 instanceGroupManager: string;
79 /** Requested instance count delta. */
80 resizeBy: number;
81 /** User description with the Alchemy ownership prefix stripped. */
82 description: string | undefined;
83 /** Requested run duration, if set. */
84 requestedRunDuration:
85 | RegionInstanceGroupManagerResizeRequestDuration
86 | undefined;
87 /** Current request state. */
88 state: string | undefined;
89 /** Server-defined URL. */
90 selfLink: string | undefined;
91 /** Server-defined URL including the numeric id. */
92 selfLinkWithId: string | undefined;
93 /** Server-assigned numeric id. */
94 requestId: string | undefined;
95 /** RFC3339 creation timestamp. */
96 creationTimestamp: string | undefined;
97 /** Resource kind. */
98 kind: string | undefined;
99 },
100 never,
101 Providers
102>;
103
104/**
105 * A regional managed instance group resize request.
106 *
107 * Resize requests provision (or queue) additional VMs on a regional MIG.
108 * They are immutable after create — changing `resizeBy`, duration, the
109 * parent group, or the name replaces the request. Delete cancels an
110 * in-flight request first when the API requires it.
111 *
112 * ### Creating a Resize Request
113 * **Example:** Queue one extra VM
114 * ```typescript
115 * const request = yield* GCP.Compute.RegionInstanceGroupManagerResizeRequest(
116 * "Burst",
117 * {
118 * instanceGroupManager: manager.managerName,
119 * resizeBy: 1,
120 * requestedRunDuration: { seconds: "3600" },
121 * description: "burst capacity",
122 * },
123 * );
124 * ```
125 *
126 * **Example:** Timed run
127 * ```typescript
128 * const request = yield* GCP.Compute.RegionInstanceGroupManagerResizeRequest(
129 * "Burst",
130 * {
131 * instanceGroupManager: manager.managerName,
132 * resizeBy: 1,
133 * requestedRunDuration: { seconds: "3600" },
134 * },
135 * );
136 * ```
137 *
138 * @resource
139 * @category Compute
140 */
141export const RegionInstanceGroupManagerResizeRequest =
142 Resource<RegionInstanceGroupManagerResizeRequest>(
143 "GCP.Compute.RegionInstanceGroupManagerResizeRequest",
144 );
145
146export class RegionInstanceGroupManagerResizeRequestNotResolved extends Data.TaggedError(
147 "GCP.Compute.RegionInstanceGroupManagerResizeRequestNotResolved",
148)<{
149 requestName: string;
150 instanceGroupManager: string;
151 region: string;
152}> {}
153
154const managerNameOf = (value: string) => lastSegment(value);
155
156const toAttrs = (
157 request: compute.InstanceGroupManagerResizeRequest,
158 project: string,
159 instanceGroupManager: string,
160): RegionInstanceGroupManagerResizeRequest["Attributes"] => {
161 const parsed = parseDescription(request.description);
162 return {
163 requestName: request.name ?? lastSegment(request.selfLink),
164 project,
165 region: lastSegment(request.region).toLowerCase(),
166 instanceGroupManager: managerNameOf(instanceGroupManager),
167 resizeBy: request.resizeBy ?? 0,
168 description: parsed.description,
169 requestedRunDuration: request.requestedRunDuration,
170 state: request.state,
171 selfLink: request.selfLink,
172 selfLinkWithId: request.selfLinkWithId,
173 requestId: request.id,
174 creationTimestamp: request.creationTimestamp,
175 kind: request.kind,
176 };
177};
178
179const getByName = (
180 project: string,
181 region: string,
182 instanceGroupManager: string,
183 resizeRequest: string,
184) =>
185 compute
186 .getRegionInstanceGroupManagerResizeRequests({
187 project,
188 region,
189 instanceGroupManager,
190 resizeRequest,
191 })
192 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
193
194const awaitResource = (
195 project: string,
196 region: string,
197 instanceGroupManager: string,
198 requestName: string,
199) =>
200 getByName(project, region, instanceGroupManager, requestName).pipe(
201 Effect.flatMap((request) =>
202 request !== undefined
203 ? Effect.succeed(request)
204 : Effect.fail(
205 new RegionInstanceGroupManagerResizeRequestNotResolved({
206 requestName,
207 instanceGroupManager,
208 region,
209 }),
210 ),
211 ),
212 Effect.retry({
213 while: (error) =>
214 error._tag ===
215 "GCP.Compute.RegionInstanceGroupManagerResizeRequestNotResolved",
216 times: 8,
217 schedule: Schedule.spaced("1 second"),
218 }),
219 );
220
221const needsCancel = (state: string | undefined) => {
222 const current = (state ?? "").toUpperCase();
223 return current === "ACCEPTED" || current === "CREATING";
224};
225
226export const RegionInstanceGroupManagerResizeRequestProvider = () =>
227 Provider.succeed(RegionInstanceGroupManagerResizeRequest, {
228 stables: [
229 "requestName",
230 "project",
231 "region",
232 "instanceGroupManager",
233 "requestId",
234 "selfLink",
235 "creationTimestamp",
236 ],
237
238 diff: Effect.fn(function* ({ news, olds, output }) {
239 if (!isResolved(news)) return undefined;
240 const env = yield* GcpEnvironment.current;
241 const previousName = olds?.requestName ?? output?.requestName;
242 const nextName = news.requestName ?? previousName;
243 const previousRegion = normalizeRegion(
244 olds?.region ?? output?.region,
245 env.region,
246 );
247 const nextRegion = normalizeRegion(
248 news.region ?? (previousRegion || env.region),
249 env.region,
250 );
251 const previousManager = managerNameOf(
252 olds?.instanceGroupManager ?? output?.instanceGroupManager ?? "",
253 );
254 const nextManager = managerNameOf(news.instanceGroupManager);
255 const nameChanged =
256 previousName !== undefined &&
257 nextName !== undefined &&
258 previousName !== nextName;
259 const regionChanged =
260 previousRegion.length > 0 && previousRegion !== nextRegion;
261 const managerChanged =
262 previousManager.length > 0 && previousManager !== nextManager;
263 const resizeChanged =
264 (olds?.resizeBy ?? output?.resizeBy) !== undefined &&
265 news.resizeBy !== (olds?.resizeBy ?? output?.resizeBy);
266 if (nameChanged || regionChanged || managerChanged || resizeChanged) {
267 return {
268 action: "replace" as const,
269 deleteFirst:
270 !nameChanged || nextName === undefined || nextName === previousName,
271 };
272 }
273 return undefined;
274 }),
275
276 read: Effect.fn(function* ({ id, olds, output }) {
277 const env = yield* GcpEnvironment.current;
278 const requestName = yield* toPhysicalName(
279 id,
280 olds?.requestName,
281 output?.requestName,
282 "resize",
283 );
284 const region = normalizeRegion(
285 olds?.region ?? output?.region,
286 env.region,
287 );
288 const instanceGroupManager = managerNameOf(
289 olds?.instanceGroupManager ?? output?.instanceGroupManager ?? "",
290 );
291 if (instanceGroupManager.length === 0) return undefined;
292 const existing = yield* getByName(
293 env.project,
294 region,
295 instanceGroupManager,
296 requestName,
297 );
298 if (existing === undefined) return undefined;
299 const attrs = toAttrs(existing, env.project, instanceGroupManager);
300 const { labels } = parseDescription(existing.description);
301 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
302 }),
303
304 list: () =>
305 Effect.gen(function* () {
306 const env = yield* GcpEnvironment.current;
307 const pages = yield* compute.aggregatedListInstanceGroupManagers
308 .pages({
309 project: env.project,
310 maxResults: 500,
311 returnPartialSuccess: true,
312 })
313 .pipe(
314 Stream.runCollect,
315 Effect.catchTag("NotFound", () => Effect.succeed([] as never[])),
316 );
317 const managers = Array.from(
318 pages as readonly compute.InstanceGroupManagerAggregatedList[],
319 ).flatMap((page) =>
320 Object.entries(page.items ?? {}).flatMap(([scope, scoped]) => {
321 if (!scope.startsWith("regions/")) return [];
322 return (scoped?.instanceGroupManagers ?? [])
323 .filter((manager) => lastSegment(manager.region).length > 0)
324 .map((manager) => ({
325 name: manager.name ?? lastSegment(manager.selfLink),
326 region: lastSegment(manager.region).toLowerCase(),
327 }));
328 }),
329 );
330 const listed: RegionInstanceGroupManagerResizeRequest["Attributes"][] =
331 [];
332 for (const manager of managers) {
333 const chunk =
334 yield* compute.listRegionInstanceGroupManagerResizeRequests
335 .items({
336 project: env.project,
337 region: manager.region,
338 instanceGroupManager: manager.name,
339 maxResults: 500,
340 returnPartialSuccess: true,
341 })
342 .pipe(
343 Stream.filter((item) => hasOwnershipMarker(item.description)),
344 Stream.map((item) => toAttrs(item, env.project, manager.name)),
345 Stream.runCollect,
346 Effect.map((items) => Array.from(items)),
347 Effect.catchTag("NotFound", () =>
348 Effect.succeed(
349 [] as RegionInstanceGroupManagerResizeRequest["Attributes"][],
350 ),
351 ),
352 );
353 listed.push(...chunk);
354 }
355 return listed;
356 }),
357
358 reconcile: Effect.fn(function* ({ id, news, output }) {
359 const env = yield* GcpEnvironment.current;
360 const requestName = yield* toPhysicalName(
361 id,
362 news.requestName,
363 output?.requestName,
364 "resize",
365 );
366 const region = normalizeRegion(news.region ?? output?.region, env.region);
367 const instanceGroupManager = managerNameOf(news.instanceGroupManager);
368 const ownership = yield* createInternalLabels(id);
369 const desiredDescription = encodeDescription(ownership, news.description);
370
371 let current = yield* getByName(
372 env.project,
373 region,
374 instanceGroupManager,
375 requestName,
376 );
377
378 if (current === undefined) {
379 yield* runRegionOp(
380 env.project,
381 region,
382 compute.insertRegionInstanceGroupManagerResizeRequests({
383 project: env.project,
384 region,
385 instanceGroupManager,
386 body: {
387 name: requestName,
388 description: desiredDescription,
389 resizeBy: news.resizeBy,
390 requestedRunDuration: news.requestedRunDuration,
391 },
392 }),
393 { ignoreAlreadyExists: true },
394 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
395 current = yield* awaitResource(
396 env.project,
397 region,
398 instanceGroupManager,
399 requestName,
400 );
401 }
402
403 if (current === undefined) {
404 return yield* new RegionInstanceGroupManagerResizeRequestNotResolved({
405 requestName,
406 instanceGroupManager,
407 region,
408 });
409 }
410
411 return toAttrs(current, env.project, instanceGroupManager);
412 }),
413
414 delete: Effect.fn(function* ({ output }) {
415 const env = yield* GcpEnvironment.current;
416 const region = normalizeRegion(output.region, env.region);
417 const instanceGroupManager = managerNameOf(output.instanceGroupManager);
418 const current = yield* getByName(
419 env.project,
420 region,
421 instanceGroupManager,
422 output.requestName,
423 );
424 if (current !== undefined && needsCancel(current.state)) {
425 yield* runRegionOp(
426 env.project,
427 region,
428 compute.cancelRegionInstanceGroupManagerResizeRequests({
429 project: env.project,
430 region,
431 instanceGroupManager,
432 resizeRequest: output.requestName,
433 }),
434 { ignoreNotFound: true },
435 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
436 }
437 yield* runRegionOp(
438 env.project,
439 region,
440 compute.deleteRegionInstanceGroupManagerResizeRequests({
441 project: env.project,
442 region,
443 instanceGroupManager,
444 resizeRequest: output.requestName,
445 }),
446 { ignoreNotFound: true },
447 ).pipe(
448 Effect.retry({
449 while: (error) => error._tag === "Conflict",
450 times: 8,
451 schedule: Schedule.spaced("2 seconds"),
452 }),
453 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
454 );
455 }),
456 });