1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } 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_COOL_DOWN_SEC = 60;
23const DEFAULT_MIN_REPLICAS = 0;
24const DEFAULT_MODE = "ON";
25const DEFAULT_CPU_TARGET = 0.6;
26const MAX_NAME_LENGTH = 63;
27
28export type RegionAutoscalerAutoscalingPolicy = {
29 /**
30 * Maximum number of instances the autoscaler may scale out to. Required.
31 */
32 maxNumReplicas: number;
33 /**
34 * Minimum number of instances the autoscaler may scale in to.
35 * @default 0
36 */
37 minNumReplicas?: number;
38 /**
39 * Initialization period in seconds before metrics are trusted.
40 * @default 60
41 */
42 coolDownPeriodSec?: number;
43 /**
44 * Stabilization period in seconds before scale-in.
45 */
46 stabilizationPeriodSec?: number;
47 /**
48 * Operating mode.
49 * @default "ON"
50 */
51 mode?: "OFF" | "ON" | "ONLY_SCALE_OUT" | "ONLY_UP" | (string & {});
52 /**
53 * CPU utilization signal. When no signal is set, GCP defaults to
54 * CPU `0.6`.
55 */
56 cpuUtilization?: compute.AutoscalingPolicyCpuUtilization;
57 /** Load-balancing utilization signal. */
58 loadBalancingUtilization?: compute.AutoscalingPolicyLoadBalancingUtilization;
59 /** Custom Cloud Monitoring metric signals. */
60 customMetricUtilizations?: compute.AutoscalingPolicyCustomMetricUtilization[];
61 /** Throttle abrupt scale-in. */
62 scaleInControl?: compute.AutoscalingPolicyScaleInControl;
63 /** Named scaling schedules (up to 128). */
64 scalingSchedules?: Record<
65 string,
66 compute.AutoscalingPolicyScalingSchedule | undefined
67 >;
68};
69
70export type RegionAutoscalerProps = {
71 /**
72 * Autoscaler name (RFC1035, 1-63 characters). If omitted, a unique name
73 * is generated from the stack, stage, and logical id. Changing the name
74 * replaces the autoscaler.
75 */
76 autoscalerName?: string;
77 /**
78 * Region of the managed instance group (e.g. `us-central1`). Immutable —
79 * changing it replaces the autoscaler. `US-CENTRAL1` is accepted and
80 * normalized to `us-central1`.
81 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
82 */
83 region?: string;
84 /**
85 * URL or name of the regional managed instance group to scale
86 * (`projects/{project}/regions/{region}/instanceGroupManagers/{name}`).
87 */
88 target: string;
89 /**
90 * Optional description. Compute autoscalers have no labels field, so
91 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
92 * and user labels are stored in a `[alchemy …]` prefix for `list` / nuke.
93 */
94 description?: string;
95 /**
96 * User labels. Encoded into the description marker alongside Alchemy
97 * ownership labels (the API has no `labels` field).
98 */
99 labels?: Record<string, string>;
100 /**
101 * Autoscaling policy. `maxNumReplicas` is required; other fields take
102 * GCP defaults when omitted.
103 */
104 autoscalingPolicy: RegionAutoscalerAutoscalingPolicy;
105};
106
107export type RegionAutoscaler = Resource<
108 "GCP.Compute.RegionAutoscaler",
109 RegionAutoscalerProps,
110 {
111 /** Autoscaler name. */
112 autoscalerName: string;
113 /** Server-assigned numeric id. */
114 autoscalerId: string | undefined;
115 /** Project id. */
116 project: string;
117 /** Region short name (`us-central1`). */
118 region: string;
119 /** Target managed instance group URL. */
120 target: string;
121 /** User-facing description (ownership marker stripped). */
122 description: string | undefined;
123 /** User labels decoded from the description marker. */
124 labels: Record<string, string>;
125 /** Autoscaling policy currently applied. */
126 autoscalingPolicy: compute.AutoscalingPolicy | undefined;
127 /** Server-reported status (`PENDING`, `ACTIVE`, `ERROR`, `DELETING`). */
128 status: string | undefined;
129 /** Human-readable status details. */
130 statusDetails: ReadonlyArray<compute.AutoscalerStatusDetails>;
131 /** Recommended MIG size, if the autoscaler has produced one. */
132 recommendedSize: number | undefined;
133 /** Compute Engine self-link. */
134 selfLink: string | undefined;
135 /** RFC3339 creation timestamp. */
136 creationTimestamp: string | undefined;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * A regional Compute Engine autoscaler for a managed instance group.
144 *
145 * Changing `autoscalerName` or `region` replaces the autoscaler. Policy,
146 * target, description, and labels update in place via
147 * `regionAutoscalers.patch`. Compute Engine has no labels on this
148 * resource, so Alchemy stamps ownership into the description (`[alchemy
149 * alchemy-stack=… alchemy-stage=… alchemy-id=…]`) so `list` /
150 * `pnpm nuke:gcp` can find them.
151 *
152 * ### Creating a Region Autoscaler
153 * **Example:** CPU policy with generated name
154 * ```typescript
155 * const scaler = yield* GCP.Compute.RegionAutoscaler("Web", {
156 * target: mig.selfLink,
157 * autoscalingPolicy: {
158 * minNumReplicas: 1,
159 * maxNumReplicas: 5,
160 * coolDownPeriodSec: 60,
161 * cpuUtilization: { utilizationTarget: 0.6 },
162 * },
163 * });
164 * ```
165 *
166 * **Example:** Named autoscaler, labels, and OFF mode
167 * ```typescript
168 * const scaler = yield* GCP.Compute.RegionAutoscaler("Web", {
169 * autoscalerName: "web-scaler",
170 * region: "us-central1",
171 * target:
172 * "projects/{project}/regions/us-central1/instanceGroupManagers/web",
173 * description: "scale the web MIG",
174 * labels: { env: "prod" },
175 * autoscalingPolicy: {
176 * minNumReplicas: 0,
177 * maxNumReplicas: 3,
178 * mode: "OFF",
179 * cpuUtilization: { utilizationTarget: 0.5 },
180 * },
181 * });
182 * ```
183 *
184 * ### Updating Policy
185 * **Example:** Raise the replica cap and CPU target
186 * ```typescript
187 * const scaler = yield* GCP.Compute.RegionAutoscaler("Web", {
188 * autoscalerName: "web-scaler",
189 * target: mig.selfLink,
190 * autoscalingPolicy: {
191 * minNumReplicas: 1,
192 * maxNumReplicas: 10,
193 * cpuUtilization: { utilizationTarget: 0.5 },
194 * },
195 * });
196 * ```
197 *
198 * @resource
199 * @category Compute
200 */
201export const RegionAutoscaler = Resource<RegionAutoscaler>(
202 "GCP.Compute.RegionAutoscaler",
203);
204
205export class RegionAutoscalerNotResolved extends Data.TaggedError(
206 "GCP.Compute.RegionAutoscalerNotResolved",
207)<{
208 autoscalerName: string;
209 region: string;
210}> {}
211
212export class RegionAutoscalerStillExists extends Data.TaggedError(
213 "GCP.Compute.RegionAutoscalerStillExists",
214)<{
215 autoscalerName: string;
216 region: string;
217}> {}
218
219const lastSegment = (value: string | undefined): string => {
220 if (value === undefined || value.length === 0) return "";
221 const parts = value.split("/").filter((part) => part.length > 0);
222 return parts[parts.length - 1] ?? value;
223};
224
225const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
226 lastSegment(region ?? defaultRegion).toLowerCase();
227
228const rfc1035Name = (name: string) => {
229 let next = name
230 .toLowerCase()
231 .replace(/[^a-z0-9-]/g, "-")
232 .replace(/-+/g, "-")
233 .replace(/^-+|-+$/g, "");
234 if (!/^[a-z]/.test(next)) {
235 next = `a${next}`;
236 }
237 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
238 return next.length > 0 ? next : "autoscaler";
239};
240
241const toName = (id: string, name: string | undefined, existing?: string) =>
242 Effect.gen(function* () {
243 return rfc1035Name(
244 name ??
245 existing ??
246 (yield* createPhysicalName({
247 id,
248 maxLength: MAX_NAME_LENGTH,
249 lowercase: true,
250 })),
251 );
252 });
253
254const toTargetUrl = (project: string, region: string, target: string) =>
255 target.includes("/")
256 ? target
257 : `projects/${project}/regions/${region}/instanceGroupManagers/${target}`;
258
259const encodeDescription = (
260 user: string | undefined,
261 labels: Record<string, string>,
262): string => {
263 const packed = Object.entries(labels)
264 .sort(([left], [right]) => left.localeCompare(right))
265 .map(([key, value]) => `${key}=${value}`)
266 .join(" ");
267 const marker = `[alchemy ${packed}]`;
268 const trimmed = user?.trim();
269 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
270};
271
272const parseDescription = (
273 description: string | undefined,
274): { labels: Record<string, string>; user: string | undefined } => {
275 if (!description?.startsWith("[alchemy ")) {
276 return { labels: {}, user: description };
277 }
278 const end = description.indexOf("]");
279 if (end < 0) return { labels: {}, user: description };
280 const labels: Record<string, string> = {};
281 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
282 const eq = part.indexOf("=");
283 if (eq > 0) {
284 labels[part.slice(0, eq)] = part.slice(eq + 1);
285 }
286 }
287 const rest = description
288 .slice(end + 1)
289 .replace(/^\n/, "")
290 .trim();
291 return {
292 labels,
293 user: rest.length > 0 ? rest : undefined,
294 };
295};
296
297const hasOwnershipMarker = (description: string | undefined) =>
298 Object.keys(parseDescription(description).labels).some((key) =>
299 key.startsWith("alchemy-"),
300 );
301
302const userLabels = (
303 labels: Record<string, string | undefined> | null | undefined,
304): Record<string, string> => stripInternalLabels(tagRecord(labels));
305
306const toAttrs = (autoscaler: compute.Autoscaler, project: string) => {
307 const decoded = parseDescription(autoscaler.description);
308 return {
309 autoscalerName: autoscaler.name ?? lastSegment(autoscaler.selfLink),
310 autoscalerId: autoscaler.id,
311 project,
312 region: lastSegment(autoscaler.region).toLowerCase(),
313 target: autoscaler.target ?? "",
314 description: decoded.user,
315 labels: userLabels(decoded.labels),
316 autoscalingPolicy: autoscaler.autoscalingPolicy,
317 status: autoscaler.status,
318 statusDetails: autoscaler.statusDetails ?? [],
319 recommendedSize: autoscaler.recommendedSize,
320 selfLink: autoscaler.selfLink,
321 creationTimestamp: autoscaler.creationTimestamp,
322 };
323};
324
325const hasSignal = (policy: RegionAutoscalerAutoscalingPolicy) =>
326 policy.cpuUtilization !== undefined ||
327 policy.loadBalancingUtilization !== undefined ||
328 (policy.customMetricUtilizations?.length ?? 0) > 0;
329
330const desiredPolicy = (
331 policy: RegionAutoscalerAutoscalingPolicy,
332): compute.AutoscalingPolicy => ({
333 maxNumReplicas: policy.maxNumReplicas,
334 minNumReplicas: policy.minNumReplicas ?? DEFAULT_MIN_REPLICAS,
335 coolDownPeriodSec: policy.coolDownPeriodSec ?? DEFAULT_COOL_DOWN_SEC,
336 stabilizationPeriodSec: policy.stabilizationPeriodSec,
337 mode: policy.mode ?? DEFAULT_MODE,
338 cpuUtilization:
339 policy.cpuUtilization ??
340 (hasSignal(policy) ? undefined : { utilizationTarget: DEFAULT_CPU_TARGET }),
341 loadBalancingUtilization: policy.loadBalancingUtilization,
342 customMetricUtilizations: policy.customMetricUtilizations,
343 scaleInControl: policy.scaleInControl,
344 scalingSchedules: policy.scalingSchedules,
345});
346
347const sortedValue = (value: unknown): unknown => {
348 if (Array.isArray(value)) return value.map(sortedValue);
349 if (value !== null && typeof value === "object") {
350 return Object.fromEntries(
351 Object.entries(value as Record<string, unknown>)
352 .filter(([, entry]) => entry !== undefined)
353 .sort(([left], [right]) => left.localeCompare(right))
354 .map(([key, entry]) => [key, sortedValue(entry)]),
355 );
356 }
357 return value;
358};
359
360const canonCpu = (cpu: compute.AutoscalingPolicyCpuUtilization | undefined) => {
361 if (cpu === undefined) return undefined;
362 const predictive =
363 cpu.predictiveMethod === undefined || cpu.predictiveMethod === "NONE"
364 ? undefined
365 : cpu.predictiveMethod;
366 return {
367 utilizationTarget: cpu.utilizationTarget,
368 predictiveMethod: predictive,
369 };
370};
371
372const canonPolicy = (policy: compute.AutoscalingPolicy | undefined) => {
373 if (policy === undefined) return undefined;
374 return sortedValue({
375 maxNumReplicas: policy.maxNumReplicas,
376 minNumReplicas: policy.minNumReplicas ?? DEFAULT_MIN_REPLICAS,
377 coolDownPeriodSec: policy.coolDownPeriodSec ?? DEFAULT_COOL_DOWN_SEC,
378 stabilizationPeriodSec: policy.stabilizationPeriodSec,
379 mode: policy.mode ?? DEFAULT_MODE,
380 cpuUtilization: canonCpu(policy.cpuUtilization),
381 loadBalancingUtilization: policy.loadBalancingUtilization
382 ? { utilizationTarget: policy.loadBalancingUtilization.utilizationTarget }
383 : undefined,
384 customMetricUtilizations: (policy.customMetricUtilizations ?? []).map(
385 (metric) => ({
386 metric: metric.metric,
387 filter: metric.filter,
388 utilizationTarget: metric.utilizationTarget,
389 utilizationTargetType: metric.utilizationTargetType,
390 singleInstanceAssignment: metric.singleInstanceAssignment,
391 }),
392 ),
393 scaleInControl: policy.scaleInControl
394 ? {
395 maxScaledInReplicas: policy.scaleInControl.maxScaledInReplicas
396 ? {
397 fixed: policy.scaleInControl.maxScaledInReplicas.fixed,
398 percent: policy.scaleInControl.maxScaledInReplicas.percent,
399 }
400 : undefined,
401 timeWindowSec: policy.scaleInControl.timeWindowSec,
402 }
403 : undefined,
404 scalingSchedules: policy.scalingSchedules,
405 });
406};
407
408const samePolicy = (
409 observed: compute.AutoscalingPolicy | undefined,
410 desired: compute.AutoscalingPolicy,
411) =>
412 JSON.stringify(canonPolicy(observed)) ===
413 JSON.stringify(canonPolicy(desired));
414
415const getByName = (project: string, region: string, autoscaler: string) =>
416 compute
417 .getRegionAutoscalers({ project, region, autoscaler })
418 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
419
420const waitPresent = (project: string, region: string, autoscalerName: string) =>
421 getByName(project, region, autoscalerName).pipe(
422 Effect.flatMap((autoscaler) =>
423 autoscaler === undefined
424 ? Effect.fail(
425 new RegionAutoscalerNotResolved({ autoscalerName, region }),
426 )
427 : Effect.succeed(autoscaler),
428 ),
429 Effect.retry({
430 while: (error) =>
431 error._tag === "GCP.Compute.RegionAutoscalerNotResolved",
432 times: 8,
433 schedule: Schedule.exponential("250 millis"),
434 }),
435 Effect.catchTag("GCP.Compute.RegionAutoscalerNotResolved", () =>
436 Effect.succeed(undefined),
437 ),
438 );
439
440const waitGone = (project: string, region: string, autoscalerName: string) =>
441 getByName(project, region, autoscalerName).pipe(
442 Effect.flatMap((autoscaler) =>
443 autoscaler === undefined
444 ? Effect.void
445 : Effect.fail(
446 new RegionAutoscalerStillExists({ autoscalerName, region }),
447 ),
448 ),
449 Effect.retry({
450 while: (error) =>
451 error._tag === "GCP.Compute.RegionAutoscalerStillExists",
452 times: 8,
453 schedule: Schedule.spaced("1 second"),
454 }),
455 );
456
457export const RegionAutoscalerProvider = () =>
458 Provider.succeed(RegionAutoscaler, {
459 stables: [
460 "autoscalerName",
461 "autoscalerId",
462 "project",
463 "region",
464 "selfLink",
465 "creationTimestamp",
466 ],
467
468 diff: Effect.fn(function* ({ news, olds, output }) {
469 if (!isResolved(news)) return undefined;
470 const env = yield* GcpEnvironment.current;
471 const previousName = olds?.autoscalerName ?? output?.autoscalerName;
472 const nextName = news.autoscalerName ?? previousName;
473 const previousRegion = normalizeRegion(
474 olds?.region ?? output?.region,
475 env.region,
476 );
477 const nextRegion = normalizeRegion(
478 news.region ?? previousRegion,
479 env.region,
480 );
481 const nameChanged =
482 previousName !== undefined &&
483 nextName !== undefined &&
484 previousName !== nextName;
485 const regionChanged = previousRegion !== nextRegion;
486 if (!nameChanged && !regionChanged) {
487 return undefined;
488 }
489 return {
490 action: "replace" as const,
491 // A MIG can only have one autoscaler; always delete the old row first.
492 deleteFirst: true,
493 };
494 }),
495
496 read: Effect.fn(function* ({ id, olds, output }) {
497 const env = yield* GcpEnvironment.current;
498 const autoscalerName = yield* toName(
499 id,
500 olds?.autoscalerName,
501 output?.autoscalerName,
502 );
503 const region = normalizeRegion(
504 olds?.region ?? output?.region,
505 env.region,
506 );
507 const existing = yield* getByName(env.project, region, autoscalerName);
508 if (existing === undefined) return undefined;
509 const attrs = toAttrs(existing, env.project);
510 const { labels } = parseDescription(existing.description);
511 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
512 }),
513
514 list: () =>
515 Effect.gen(function* () {
516 const env = yield* GcpEnvironment.current;
517 const pages = yield* compute.aggregatedListAutoscalers
518 .pages({
519 project: env.project,
520 maxResults: 500,
521 returnPartialSuccess: true,
522 })
523 .pipe(Stream.runCollect);
524 return Array.from(pages).flatMap((page) =>
525 Object.values(page.items ?? {}).flatMap((scoped) =>
526 (scoped?.autoscalers ?? [])
527 .filter(
528 (autoscaler) =>
529 (autoscaler.region ?? "").length > 0 &&
530 hasOwnershipMarker(autoscaler.description),
531 )
532 .map((autoscaler) => toAttrs(autoscaler, env.project)),
533 ),
534 );
535 }),
536
537 reconcile: Effect.fn(function* ({ id, news, output }) {
538 const env = yield* GcpEnvironment.current;
539 const autoscalerName = yield* toName(
540 id,
541 news.autoscalerName,
542 output?.autoscalerName,
543 );
544 const region = normalizeRegion(news.region ?? output?.region, env.region);
545 const target = toTargetUrl(env.project, region, news.target);
546 const desiredLabels = {
547 ...toLabels(news.labels),
548 ...(yield* createInternalLabels(id)),
549 };
550 const description = encodeDescription(news.description, desiredLabels);
551 const policy = desiredPolicy(news.autoscalingPolicy);
552
553 let current = yield* getByName(env.project, region, autoscalerName);
554
555 if (current === undefined) {
556 yield* compute
557 .insertRegionAutoscalers({
558 project: env.project,
559 region,
560 body: {
561 name: autoscalerName,
562 target,
563 description,
564 autoscalingPolicy: policy,
565 },
566 })
567 .pipe(
568 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
569 Effect.flatMap((operation) =>
570 operation === undefined
571 ? Effect.void
572 : waitRegionOperation(env.project, region, operation, {
573 ignore: ["RESOURCE_ALREADY_EXISTS"],
574 }).pipe(Effect.asVoid),
575 ),
576 );
577 current = yield* waitPresent(env.project, region, autoscalerName);
578 }
579
580 if (current === undefined) {
581 return yield* new RegionAutoscalerNotResolved({
582 autoscalerName,
583 region,
584 });
585 }
586
587 const descriptionChanged = (current.description ?? "") !== description;
588 const policyChanged = !samePolicy(current.autoscalingPolicy, policy);
589 const targetChanged =
590 lastSegment(current.target) !== lastSegment(target) &&
591 (current.target ?? "") !== target;
592
593 if (descriptionChanged || policyChanged || targetChanged) {
594 yield* compute
595 .patchRegionAutoscalers({
596 project: env.project,
597 region,
598 autoscaler: autoscalerName,
599 body: {
600 name: autoscalerName,
601 target,
602 description,
603 autoscalingPolicy: policy,
604 },
605 })
606 .pipe(
607 Effect.flatMap((operation) =>
608 waitRegionOperation(env.project, region, operation),
609 ),
610 Effect.retry({
611 while: (error) => error._tag === "Conflict",
612 times: 5,
613 schedule: Schedule.exponential("250 millis"),
614 }),
615 );
616 current =
617 (yield* getByName(env.project, region, autoscalerName)) ?? current;
618 }
619
620 return toAttrs(current, env.project);
621 }),
622
623 delete: Effect.fn(function* ({ output }) {
624 yield* compute
625 .deleteRegionAutoscalers({
626 project: output.project,
627 region: output.region,
628 autoscaler: output.autoscalerName,
629 })
630 .pipe(
631 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
632 Effect.flatMap((operation) =>
633 operation === undefined
634 ? Effect.void
635 : waitRegionOperation(output.project, output.region, operation, {
636 ignore: ["RESOURCE_NOT_FOUND"],
637 }).pipe(Effect.asVoid),
638 ),
639 Effect.catchTag("NotFound", () => Effect.void),
640 Effect.retry({
641 while: (error) => error._tag === "Conflict",
642 times: 5,
643 schedule: Schedule.spaced("1 second"),
644 }),
645 );
646 yield* waitGone(output.project, output.region, output.autoscalerName);
647 }),
648 });