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 { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_TARGET_SIZE = 0;
21const MAX_NAME_LENGTH = 63;
22const MAX_BASE_NAME_LENGTH = 58;
23
24export type RegionInstanceGroupManagerNamedPort = {
25 /** RFC1035 name for this port mapping (e.g. `"http"`). */
26 name: string;
27 /** TCP port number (`1`–`65535`). */
28 port: number;
29};
30
31export type RegionInstanceGroupManagerVersion = {
32 /**
33 * Instance template URL or name used for this version. The group
34 * creates VMs from this template until `targetSize` is reached.
35 */
36 instanceTemplate?: string;
37 /** Version name unique within this manager. */
38 name?: string;
39 /** Fixed count or percent of the group's `targetSize` for this version. */
40 targetSize?: {
41 fixed?: number;
42 percent?: number;
43 };
44};
45
46export type RegionInstanceGroupManagerProps = {
47 /**
48 * Manager name (RFC1035, 1-63 characters). If omitted, a unique name is
49 * generated from the stack, stage, and logical id. Changing the name
50 * replaces the manager.
51 */
52 managerName?: string;
53 /**
54 * Region the manager lives in (e.g. `us-central1`). Immutable —
55 * changing it replaces the manager. `US-CENTRAL1` is accepted and
56 * normalized to `us-central1`.
57 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
58 */
59 region?: string;
60 /**
61 * Instance template URL or name used to create VMs. Accepts a name
62 * (`web`), a partial URL (`projects/{project}/global/instanceTemplates/web`),
63 * or a full self-link. Mutable in place via patch.
64 */
65 instanceTemplate: string;
66 /**
67 * Prefix attached to every VM name in the group (RFC1035, 1-58
68 * characters). Defaults to the manager name truncated to 58 characters.
69 * Immutable — changing it replaces the manager.
70 */
71 baseInstanceName?: string;
72 /**
73 * Optional description. Regional MIGs have no labels API, so Alchemy
74 * stamps ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
75 * into this field for `list` / nuke.
76 */
77 description?: string;
78 /**
79 * Target number of running VMs. Keep this `0` in tests unless you
80 * intend to provision instances.
81 * @default 0
82 */
83 targetSize?: number;
84 /**
85 * Named ports published on the complementary regional instance group.
86 * When omitted, observed ports are left as-is. When set (including
87 * `[]`), observed ports are replaced via `setNamedPorts`.
88 */
89 namedPorts?: RegionInstanceGroupManagerNamedPort[];
90 /**
91 * Canary / multi-template versions. When set, this field is patched
92 * instead of the top-level `instanceTemplate`. Exactly one version must
93 * leave `targetSize` unset.
94 */
95 versions?: RegionInstanceGroupManagerVersion[];
96 /**
97 * Autohealing health checks. When omitted, observed policy is left
98 * as-is.
99 */
100 autoHealingPolicies?: compute.InstanceGroupManagerAutoHealingPolicy[];
101 /**
102 * Rolling-update policy (type, surge, redistribution). When omitted,
103 * observed policy is left as-is.
104 */
105 updatePolicy?: compute.InstanceGroupManagerUpdatePolicy;
106 /**
107 * Zone distribution for the regional group. `zones` is immutable —
108 * changing it replaces the manager. `targetShape` updates in place.
109 */
110 distributionPolicy?: compute.DistributionPolicy;
111 /**
112 * Target-pool URLs that receive new VMs. When omitted, observed pools
113 * are left as-is.
114 */
115 targetPools?: string[];
116 /**
117 * Repair / failure policy for managed VMs. When omitted, observed
118 * policy is left as-is.
119 */
120 instanceLifecyclePolicy?: compute.InstanceGroupManagerInstanceLifecyclePolicy;
121};
122
123export type RegionInstanceGroupManager = Resource<
124 "GCP.Compute.RegionInstanceGroupManager",
125 RegionInstanceGroupManagerProps,
126 {
127 /** Manager name. */
128 managerName: string;
129 /** Project id. */
130 project: string;
131 /** Region short name (`us-central1`). */
132 region: string;
133 /** User-facing description (ownership marker stripped). */
134 description: string | undefined;
135 /** Instance template URL currently configured on the group. */
136 instanceTemplate: string | undefined;
137 /** Prefix used for VM names. */
138 baseInstanceName: string | undefined;
139 /** Target number of running VMs. */
140 targetSize: number;
141 /** Named ports currently configured on the complementary group. */
142 namedPorts: RegionInstanceGroupManagerNamedPort[];
143 /** Template versions currently configured. */
144 versions: RegionInstanceGroupManagerVersion[];
145 /** Complementary instance-group URL. */
146 instanceGroup: string | undefined;
147 /** Server-assigned numeric id. */
148 managerId: string | undefined;
149 /** Compute Engine self-link. */
150 selfLink: string | undefined;
151 /** RFC3339 creation timestamp. */
152 creationTimestamp: string | undefined;
153 /** Whether the group reports as stable. */
154 isStable: boolean | undefined;
155 /** Zone last-segments from the distribution policy, if any. */
156 distributionPolicyZones: string[];
157 },
158 never,
159 Providers
160>;
161
162/**
163 * A regional Compute Engine managed instance group.
164 *
165 * The manager creates and heals VMs from an instance template, spread
166 * across zones in a region. Compute Engine has no labels on this
167 * resource, so Alchemy stamps ownership into the description
168 * (`[alchemy alchemy-stack=… alchemy-stage=… alchemy-id=…]`) so `list` /
169 * `pnpm nuke:gcp` can find them.
170 *
171 * Name, region, `baseInstanceName`, and `distributionPolicy.zones` are
172 * immutable — changing them replaces the manager. `targetSize`,
173 * `instanceTemplate` / `versions`, description, autohealing, and update
174 * policy patch in place. Named ports are synced onto the complementary
175 * regional instance group.
176 *
177 * ### Creating a Regional MIG
178 * **Example:** Generated name, no running VMs
179 * ```typescript
180 * const template = yield* GCP.Compute.InstanceTemplate("web", {});
181 * const mig = yield* GCP.Compute.RegionInstanceGroupManager("web", {
182 * instanceTemplate: template.selfLink,
183 * targetSize: 0,
184 * });
185 * ```
186 *
187 * **Example:** Named group with named ports
188 * ```typescript
189 * const mig = yield* GCP.Compute.RegionInstanceGroupManager("web", {
190 * managerName: "web-mig",
191 * region: "us-central1",
192 * instanceTemplate:
193 * "projects/{project}/global/instanceTemplates/web",
194 * baseInstanceName: "web",
195 * targetSize: 3,
196 * namedPorts: [{ name: "http", port: 80 }],
197 * });
198 * ```
199 *
200 * @resource
201 * @category Compute
202 */
203export const RegionInstanceGroupManager = Resource<RegionInstanceGroupManager>(
204 "GCP.Compute.RegionInstanceGroupManager",
205);
206
207export class RegionInstanceGroupManagerNotResolved extends Data.TaggedError(
208 "GCP.Compute.RegionInstanceGroupManagerNotResolved",
209)<{
210 managerName: string;
211 region: string;
212}> {}
213
214export class RegionInstanceGroupManagerStillExists extends Data.TaggedError(
215 "GCP.Compute.RegionInstanceGroupManagerStillExists",
216)<{
217 managerName: string;
218 region: string;
219}> {}
220
221const lastSegment = (value: string | undefined): string => {
222 if (value === undefined || value.length === 0) return "";
223 const trimmed = value.split("?")[0]!.replace(/\/+$/, "");
224 const parts = trimmed.split("/").filter((part) => part.length > 0);
225 return parts[parts.length - 1] || trimmed;
226};
227
228const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
229 lastSegment(region ?? defaultRegion).toLowerCase();
230
231const rfc1035Name = (name: string, fallback: string, maxLength: number) => {
232 let next = name
233 .toLowerCase()
234 .replace(/[^a-z0-9-]/g, "-")
235 .replace(/-+/g, "-")
236 .replace(/^-+|-+$/g, "");
237 if (!/^[a-z]/.test(next)) {
238 next = `m${next}`;
239 }
240 next = next.slice(0, maxLength).replace(/-+$/g, "");
241 return next.length > 0 ? next : fallback;
242};
243
244const toName = (id: string, name: string | undefined, existing?: string) =>
245 Effect.gen(function* () {
246 return rfc1035Name(
247 name ??
248 existing ??
249 (yield* createPhysicalName({
250 id,
251 maxLength: MAX_NAME_LENGTH,
252 lowercase: true,
253 })),
254 "mig",
255 MAX_NAME_LENGTH,
256 );
257 });
258
259const toBaseName = (name: string | undefined, managerName: string) =>
260 rfc1035Name(name ?? managerName, "mig", MAX_BASE_NAME_LENGTH);
261
262const encodeDescription = (
263 user: string | undefined,
264 labels: Record<string, string>,
265) => {
266 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
267 const trimmed = user?.trim();
268 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
269};
270
271const parseDescription = (description: string | undefined) => {
272 if (!description?.startsWith("[alchemy ")) {
273 return { user: description, labels: {} as Record<string, string> };
274 }
275 const end = description.indexOf("]");
276 if (end < 0) {
277 return { user: description, labels: {} as Record<string, string> };
278 }
279 const labels: Record<string, string> = {};
280 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
281 const eq = part.indexOf("=");
282 if (eq > 0) {
283 labels[part.slice(0, eq)] = part.slice(eq + 1);
284 }
285 }
286 const rest = description
287 .slice(end + 1)
288 .replace(/^\n/, "")
289 .trim();
290 return {
291 user: rest.length > 0 ? rest : undefined,
292 labels,
293 };
294};
295
296const hasOwnershipMarker = (description: string | undefined) =>
297 Object.keys(parseDescription(description).labels).some((key) =>
298 key.startsWith("alchemy-"),
299 );
300
301const toTemplateUrl = (project: string, template: string) =>
302 template.includes("/")
303 ? template
304 : `projects/${project}/global/instanceTemplates/${template}`;
305
306const canonPorts = (
307 ports: readonly RegionInstanceGroupManagerNamedPort[] | undefined,
308): RegionInstanceGroupManagerNamedPort[] =>
309 [...(ports ?? [])]
310 .map((port) => ({ name: port.name, port: port.port }))
311 .sort((a, b) => a.name.localeCompare(b.name) || a.port - b.port);
312
313const samePorts = (
314 left: readonly RegionInstanceGroupManagerNamedPort[] | undefined,
315 right: readonly RegionInstanceGroupManagerNamedPort[] | undefined,
316) => JSON.stringify(canonPorts(left)) === JSON.stringify(canonPorts(right));
317
318const fromApiPorts = (
319 ports: readonly compute.NamedPort[] | undefined,
320): RegionInstanceGroupManagerNamedPort[] =>
321 canonPorts(
322 (ports ?? [])
323 .filter(
324 (port): port is { name: string; port: number } =>
325 typeof port.name === "string" && typeof port.port === "number",
326 )
327 .map((port) => ({ name: port.name, port: port.port })),
328 );
329
330const fromApiVersions = (
331 versions: readonly compute.InstanceGroupManagerVersion[] | undefined,
332): RegionInstanceGroupManagerVersion[] =>
333 (versions ?? []).map((version) => ({
334 instanceTemplate: version.instanceTemplate,
335 name: version.name,
336 targetSize: version.targetSize
337 ? {
338 fixed: version.targetSize.fixed,
339 percent: version.targetSize.percent,
340 }
341 : undefined,
342 }));
343
344const sortedRefs = (values: readonly string[] | undefined) =>
345 [...(values ?? [])].map(lastSegment).filter(Boolean).sort();
346
347const sameRefs = (
348 left: readonly string[] | undefined,
349 right: readonly string[] | undefined,
350) => JSON.stringify(sortedRefs(left)) === JSON.stringify(sortedRefs(right));
351
352const zonesOf = (policy: compute.DistributionPolicy | undefined) =>
353 sortedRefs((policy?.zones ?? []).map((zone) => zone.zone ?? ""));
354
355const subsetEqual = (observed: unknown, desired: unknown): boolean => {
356 if (desired === undefined) return true;
357 if (typeof desired !== typeof observed) return false;
358 if (desired === null || observed === null) return desired === observed;
359 if (Array.isArray(desired)) {
360 if (!Array.isArray(observed) || desired.length !== observed.length) {
361 return false;
362 }
363 return desired.every((item, index) => subsetEqual(observed[index], item));
364 }
365 if (typeof desired === "object") {
366 if (typeof observed !== "object") return false;
367 const current = observed as Record<string, unknown>;
368 return Object.entries(desired as Record<string, unknown>).every(
369 ([key, value]) => value === undefined || subsetEqual(current[key], value),
370 );
371 }
372 return observed === desired;
373};
374
375const sameJson = (left: unknown, right: unknown) =>
376 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
377
378const versionsChanged = (
379 observed: readonly compute.InstanceGroupManagerVersion[] | undefined,
380 desired: readonly RegionInstanceGroupManagerVersion[] | undefined,
381) => {
382 if (desired === undefined) return false;
383 const left = (observed ?? []).map((version) => ({
384 name: version.name ?? "",
385 instanceTemplate: lastSegment(version.instanceTemplate),
386 targetSize: version.targetSize ?? {},
387 }));
388 const right = desired.map((version) => ({
389 name: version.name ?? "",
390 instanceTemplate: lastSegment(version.instanceTemplate),
391 targetSize: version.targetSize ?? {},
392 }));
393 return !sameJson(left, right);
394};
395
396const toAttrs = (manager: compute.InstanceGroupManager, project: string) => {
397 const { user } = parseDescription(manager.description);
398 return {
399 managerName: manager.name ?? lastSegment(manager.selfLink),
400 project,
401 region: lastSegment(manager.region).toLowerCase(),
402 description: user,
403 instanceTemplate: manager.instanceTemplate,
404 baseInstanceName: manager.baseInstanceName,
405 targetSize: manager.targetSize ?? 0,
406 namedPorts: fromApiPorts(manager.namedPorts),
407 versions: fromApiVersions(manager.versions),
408 instanceGroup: manager.instanceGroup,
409 managerId: manager.id,
410 selfLink: manager.selfLink,
411 creationTimestamp: manager.creationTimestamp,
412 isStable: manager.status?.isStable,
413 distributionPolicyZones: zonesOf(manager.distributionPolicy),
414 };
415};
416
417const getByName = (
418 project: string,
419 region: string,
420 instanceGroupManager: string,
421) =>
422 compute
423 .getRegionInstanceGroupManagers({
424 project,
425 region,
426 instanceGroupManager,
427 })
428 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
429
430const waitUntilPresent = (
431 project: string,
432 region: string,
433 managerName: string,
434) =>
435 getByName(project, region, managerName).pipe(
436 Effect.flatMap((manager) =>
437 manager !== undefined
438 ? Effect.succeed(manager)
439 : Effect.fail(
440 new RegionInstanceGroupManagerNotResolved({
441 managerName,
442 region,
443 }),
444 ),
445 ),
446 Effect.retry({
447 while: (error) =>
448 error._tag === "GCP.Compute.RegionInstanceGroupManagerNotResolved",
449 times: 8,
450 schedule: Schedule.spaced("1 second"),
451 }),
452 Effect.catchTag("GCP.Compute.RegionInstanceGroupManagerNotResolved", () =>
453 Effect.succeed(undefined),
454 ),
455 );
456
457const waitUntilGone = (project: string, region: string, managerName: string) =>
458 getByName(project, region, managerName).pipe(
459 Effect.flatMap((manager) =>
460 manager === undefined
461 ? Effect.void
462 : Effect.fail(
463 new RegionInstanceGroupManagerStillExists({
464 managerName,
465 region,
466 }),
467 ),
468 ),
469 Effect.retry({
470 while: (error) =>
471 error._tag === "GCP.Compute.RegionInstanceGroupManagerStillExists",
472 times: 15,
473 schedule: Schedule.spaced("2 seconds"),
474 }),
475 );
476
477const getInstanceGroup = (
478 project: string,
479 region: string,
480 instanceGroup: string,
481) =>
482 compute
483 .getRegionInstanceGroups({ project, region, instanceGroup })
484 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
485
486const syncNamedPorts = (
487 project: string,
488 region: string,
489 managerName: string,
490 instanceGroupUrl: string | undefined,
491 namedPorts: RegionInstanceGroupManagerNamedPort[],
492) =>
493 Effect.gen(function* () {
494 const instanceGroup = lastSegment(instanceGroupUrl) || managerName;
495 const group = yield* getInstanceGroup(project, region, instanceGroup).pipe(
496 Effect.flatMap((current) =>
497 current !== undefined
498 ? Effect.succeed(current)
499 : Effect.fail(
500 new RegionInstanceGroupManagerNotResolved({
501 managerName,
502 region,
503 }),
504 ),
505 ),
506 Effect.retry({
507 while: (error) =>
508 error._tag === "GCP.Compute.RegionInstanceGroupManagerNotResolved",
509 times: 8,
510 schedule: Schedule.spaced("1 second"),
511 }),
512 );
513 const operation = yield* compute
514 .setNamedPortsRegionInstanceGroups({
515 project,
516 region,
517 instanceGroup,
518 body: {
519 namedPorts,
520 fingerprint: group.fingerprint,
521 },
522 })
523 .pipe(
524 Effect.retry({
525 while: (error) => error._tag === "Conflict",
526 times: 5,
527 schedule: Schedule.exponential("250 millis"),
528 }),
529 );
530 yield* waitRegionOperation(project, region, operation);
531 });
532
533const toVersionBodies = (
534 project: string,
535 versions: readonly RegionInstanceGroupManagerVersion[],
536): compute.InstanceGroupManagerVersion[] =>
537 versions.map((version) => ({
538 name: version.name,
539 instanceTemplate:
540 version.instanceTemplate !== undefined
541 ? toTemplateUrl(project, version.instanceTemplate)
542 : undefined,
543 targetSize: version.targetSize,
544 }));
545
546export const RegionInstanceGroupManagerProvider = () =>
547 Provider.succeed(RegionInstanceGroupManager, {
548 stables: [
549 "managerName",
550 "project",
551 "region",
552 "baseInstanceName",
553 "managerId",
554 "instanceGroup",
555 "selfLink",
556 "creationTimestamp",
557 ],
558
559 diff: Effect.fn(function* ({ news, olds, output }) {
560 if (!isResolved(news)) return undefined;
561 const env = yield* GcpEnvironment.current;
562 const previousName = olds?.managerName ?? output?.managerName;
563 const nextName = news.managerName ?? previousName;
564 const previousRegion = normalizeRegion(
565 olds?.region ?? output?.region,
566 env.region,
567 );
568 const nextRegion = normalizeRegion(
569 news.region ?? output?.region,
570 env.region,
571 );
572 const previousBase =
573 olds?.baseInstanceName ?? output?.baseInstanceName ?? previousName;
574 const nextBase =
575 news.baseInstanceName !== undefined
576 ? toBaseName(news.baseInstanceName, nextName ?? previousName ?? "mig")
577 : previousBase;
578 const nameChanged =
579 previousName !== undefined &&
580 nextName !== undefined &&
581 previousName !== nextName;
582 const regionChanged = previousRegion !== nextRegion;
583 const baseChanged =
584 previousBase !== undefined &&
585 nextBase !== undefined &&
586 previousBase !== nextBase;
587 const previousZones =
588 olds?.distributionPolicy !== undefined
589 ? zonesOf(olds.distributionPolicy)
590 : (output?.distributionPolicyZones ?? []);
591 const nextZones = zonesOf(news.distributionPolicy);
592 const zonesChanged =
593 news.distributionPolicy?.zones !== undefined &&
594 previousZones.length > 0 &&
595 nextZones.length > 0 &&
596 !sameJson(previousZones, nextZones);
597 if (!nameChanged && !regionChanged && !baseChanged && !zonesChanged) {
598 return undefined;
599 }
600 const sameIdentity =
601 previousName === nextName && previousRegion === nextRegion;
602 return {
603 action: "replace" as const,
604 deleteFirst: sameIdentity,
605 };
606 }),
607
608 read: Effect.fn(function* ({ id, olds, output }) {
609 const env = yield* GcpEnvironment.current;
610 const managerName = yield* toName(
611 id,
612 olds?.managerName,
613 output?.managerName,
614 );
615 const region = normalizeRegion(
616 olds?.region ?? output?.region,
617 env.region,
618 );
619 const existing = yield* getByName(env.project, region, managerName);
620 if (existing === undefined) return undefined;
621 const attrs = toAttrs(existing, env.project);
622 const { labels } = parseDescription(existing.description);
623 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
624 }),
625
626 list: () =>
627 Effect.gen(function* () {
628 const env = yield* GcpEnvironment.current;
629 const pages = yield* compute.aggregatedListInstanceGroupManagers
630 .pages({
631 project: env.project,
632 maxResults: 500,
633 returnPartialSuccess: true,
634 })
635 .pipe(Stream.runCollect);
636 return Array.from(pages).flatMap((page) =>
637 Object.entries(page.items ?? {}).flatMap(([scope, scoped]) => {
638 if (!scope.startsWith("regions/")) return [];
639 return (scoped?.instanceGroupManagers ?? [])
640 .filter(
641 (manager) =>
642 hasOwnershipMarker(manager.description) &&
643 lastSegment(manager.region).length > 0,
644 )
645 .map((manager) => toAttrs(manager, env.project));
646 }),
647 );
648 }),
649
650 reconcile: Effect.fn(function* ({ id, news, output }) {
651 const env = yield* GcpEnvironment.current;
652 const managerName = yield* toName(
653 id,
654 news.managerName,
655 output?.managerName,
656 );
657 const region = normalizeRegion(news.region ?? output?.region, env.region);
658 const ownership = yield* createInternalLabels(id);
659 const description = encodeDescription(news.description, ownership);
660 const instanceTemplate = toTemplateUrl(
661 env.project,
662 news.instanceTemplate,
663 );
664 const baseInstanceName = toBaseName(news.baseInstanceName, managerName);
665 const targetSize = news.targetSize ?? DEFAULT_TARGET_SIZE;
666 const namedPorts =
667 news.namedPorts !== undefined ? canonPorts(news.namedPorts) : undefined;
668
669 let current = yield* getByName(env.project, region, managerName);
670
671 if (current === undefined) {
672 yield* compute
673 .insertRegionInstanceGroupManagers({
674 project: env.project,
675 region,
676 body: {
677 name: managerName,
678 description,
679 instanceTemplate,
680 baseInstanceName,
681 targetSize,
682 namedPorts:
683 namedPorts !== undefined && namedPorts.length > 0
684 ? namedPorts
685 : undefined,
686 versions:
687 news.versions !== undefined
688 ? toVersionBodies(env.project, news.versions)
689 : undefined,
690 autoHealingPolicies: news.autoHealingPolicies,
691 updatePolicy: news.updatePolicy,
692 distributionPolicy: news.distributionPolicy,
693 targetPools: news.targetPools,
694 instanceLifecyclePolicy: news.instanceLifecyclePolicy,
695 },
696 })
697 .pipe(
698 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
699 Effect.flatMap((operation) =>
700 operation === undefined
701 ? Effect.void
702 : waitRegionOperation(env.project, region, operation, {
703 ignore: ["RESOURCE_ALREADY_EXISTS"],
704 }).pipe(Effect.asVoid),
705 ),
706 );
707 current = yield* waitUntilPresent(env.project, region, managerName);
708 }
709
710 if (current === undefined) {
711 return yield* new RegionInstanceGroupManagerNotResolved({
712 managerName,
713 region,
714 });
715 }
716
717 const patch: compute.InstanceGroupManager = {
718 fingerprint: current.fingerprint,
719 };
720 let dirty = false;
721
722 if ((current.description ?? "") !== description) {
723 patch.description = description;
724 dirty = true;
725 }
726 if ((current.targetSize ?? 0) !== targetSize) {
727 patch.targetSize = targetSize;
728 dirty = true;
729 }
730 if (
731 lastSegment(current.instanceTemplate) !== lastSegment(instanceTemplate)
732 ) {
733 patch.instanceTemplate = instanceTemplate;
734 dirty = true;
735 }
736 if (versionsChanged(current.versions, news.versions)) {
737 patch.versions = toVersionBodies(env.project, news.versions ?? []);
738 dirty = true;
739 }
740 if (
741 news.autoHealingPolicies !== undefined &&
742 !subsetEqual(
743 current.autoHealingPolicies ?? [],
744 news.autoHealingPolicies,
745 )
746 ) {
747 patch.autoHealingPolicies = news.autoHealingPolicies;
748 dirty = true;
749 }
750 if (
751 news.updatePolicy !== undefined &&
752 !subsetEqual(current.updatePolicy ?? {}, news.updatePolicy)
753 ) {
754 patch.updatePolicy = news.updatePolicy;
755 dirty = true;
756 }
757 if (
758 news.targetPools !== undefined &&
759 !sameRefs(current.targetPools, news.targetPools)
760 ) {
761 patch.targetPools = news.targetPools;
762 dirty = true;
763 }
764 if (
765 news.instanceLifecyclePolicy !== undefined &&
766 !subsetEqual(
767 current.instanceLifecyclePolicy ?? {},
768 news.instanceLifecyclePolicy,
769 )
770 ) {
771 patch.instanceLifecyclePolicy = news.instanceLifecyclePolicy;
772 dirty = true;
773 }
774 if (
775 news.distributionPolicy?.targetShape !== undefined &&
776 news.distributionPolicy.targetShape !==
777 current.distributionPolicy?.targetShape
778 ) {
779 patch.distributionPolicy = {
780 targetShape: news.distributionPolicy.targetShape,
781 };
782 dirty = true;
783 }
784
785 if (dirty) {
786 yield* compute
787 .patchRegionInstanceGroupManagers({
788 project: env.project,
789 region,
790 instanceGroupManager: managerName,
791 body: patch,
792 })
793 .pipe(
794 Effect.retry({
795 while: (error) => error._tag === "Conflict",
796 times: 5,
797 schedule: Schedule.exponential("250 millis"),
798 }),
799 Effect.flatMap((operation) =>
800 waitRegionOperation(env.project, region, operation),
801 ),
802 );
803 current =
804 (yield* getByName(env.project, region, managerName)) ?? current;
805 }
806
807 if (
808 namedPorts !== undefined &&
809 !samePorts(fromApiPorts(current.namedPorts), namedPorts)
810 ) {
811 yield* syncNamedPorts(
812 env.project,
813 region,
814 managerName,
815 current.instanceGroup,
816 namedPorts,
817 );
818 current =
819 (yield* getByName(env.project, region, managerName)) ?? current;
820 }
821
822 return toAttrs(current, env.project);
823 }),
824
825 delete: Effect.fn(function* ({ output }) {
826 const env = yield* GcpEnvironment.current;
827 const project = output.project || env.project;
828 const region = normalizeRegion(output.region, env.region);
829 const operation = yield* compute
830 .deleteRegionInstanceGroupManagers({
831 project,
832 region,
833 instanceGroupManager: output.managerName,
834 })
835 .pipe(
836 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
837 Effect.retry({
838 while: (error) => error._tag === "Conflict",
839 times: 8,
840 schedule: Schedule.spaced("2 seconds"),
841 }),
842 );
843 if (operation !== undefined) {
844 yield* waitRegionOperation(project, region, operation, {
845 ignore: ["RESOURCE_NOT_FOUND"],
846 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
847 }
848 yield* waitUntilGone(project, region, output.managerName);
849 }),
850 });