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
20export type InstanceGroupManagerNamedPort = {
21 /** RFC1035 name for this port mapping (e.g. `"http"`). */
22 name: string;
23 /** TCP port number (`1`–`65535`). */
24 port: number;
25};
26
27export type InstanceGroupManagerAutoHealingPolicy = {
28 /**
29 * Health check URL or name. Names expand to
30 * `projects/{project}/global/healthChecks/{name}`.
31 */
32 healthCheck: string;
33 /**
34 * Seconds to ignore failed health checks after a VM is created
35 * (`0`–`3600`).
36 * @default 0
37 */
38 initialDelaySec?: number;
39};
40
41export type InstanceGroupManagerUpdatePolicy =
42 compute.InstanceGroupManagerUpdatePolicy;
43
44export type InstanceGroupManagerProps = {
45 /**
46 * Name of the managed instance group. Must be 1–63 characters and comply
47 * with RFC1035. If omitted, a unique name is generated from the stack,
48 * stage, and logical id. Changing this replaces the group.
49 */
50 managerName?: string;
51 /**
52 * Zone of the group (e.g. `"us-central1-a"`). Immutable — changing it
53 * replaces the group.
54 * @default "us-central1-a"
55 */
56 zone?: string;
57 /**
58 * Instance template URL or name used to create VMs. Names expand to
59 * `projects/{project}/global/instanceTemplates/{name}`.
60 */
61 instanceTemplate: string;
62 /**
63 * Target number of running VMs. `0` creates an empty group.
64 * @default 0
65 */
66 targetSize?: number;
67 /**
68 * Prefix for VM names (RFC1035, max 58 characters). Defaults to the
69 * manager name. Immutable — changing it replaces the group.
70 */
71 baseInstanceName?: string;
72 /**
73 * Optional description. Managed instance groups have no labels API, so
74 * Alchemy stamps ownership (`alchemy-stack` / `alchemy-stage` /
75 * `alchemy-id`) into this field for `list` / nuke.
76 */
77 description?: string;
78 /**
79 * Named ports applied to the complementary instance group (the manager
80 * field is output-only; updates use `instanceGroups.setNamedPorts`).
81 */
82 namedPorts?: InstanceGroupManagerNamedPort[];
83 /**
84 * Autohealing policy (one health check). Omit to leave the observed
85 * policy unchanged.
86 */
87 autoHealingPolicies?: InstanceGroupManagerAutoHealingPolicy[];
88 /**
89 * Rolling-update policy. Omit to leave the observed policy unchanged.
90 */
91 updatePolicy?: InstanceGroupManagerUpdatePolicy;
92 /**
93 * Target-pool URLs or names that receive the group's VMs. Omit to leave
94 * observed pools unchanged.
95 */
96 targetPools?: string[];
97 /**
98 * Repair / failure policy for managed VMs (e.g.
99 * `{ defaultActionOnFailure: "DO_NOTHING" }`, which resize requests
100 * require). Omit to leave the observed policy unchanged.
101 */
102 instanceLifecyclePolicy?: compute.InstanceGroupManagerInstanceLifecyclePolicy;
103};
104
105export type InstanceGroupManager = Resource<
106 "GCP.Compute.InstanceGroupManager",
107 InstanceGroupManagerProps,
108 {
109 /** Managed instance group name. */
110 managerName: string;
111 /** Zone (short name, e.g. `"us-central1-a"`). */
112 zone: string;
113 /** Project id. */
114 project: string;
115 /** Instance template URL. */
116 instanceTemplate: string | undefined;
117 /** Target number of running VMs. */
118 targetSize: number;
119 /** Prefix used for VM names. */
120 baseInstanceName: string | undefined;
121 /** User-facing description (ownership marker stripped). */
122 description: string | undefined;
123 /** Named ports currently configured on the group. */
124 namedPorts: InstanceGroupManagerNamedPort[];
125 /** Complementary unmanaged instance group URL. */
126 instanceGroup: string | undefined;
127 /** Whether the group is currently stable. */
128 isStable: boolean | undefined;
129 /** Server-generated resource URL. */
130 selfLink: string | undefined;
131 /** Server-generated numeric id. */
132 id: string | undefined;
133 /** RFC3339 creation timestamp. */
134 creationTimestamp: string | undefined;
135 },
136 never,
137 Providers
138>;
139
140/**
141 * A zonal Compute Engine managed instance group (MIG).
142 *
143 * The group creates VMs from an instance template and maintains
144 * `targetSize`. Alchemy records ownership in the description so
145 * `list` / `pnpm nuke:gcp` can find groups (MIGs have no labels API).
146 * Changing `managerName`, `zone`, or `baseInstanceName` replaces the
147 * group.
148 *
149 * ### Creating a Managed Instance Group
150 * **Example:** Generated name, empty group
151 * ```typescript
152 * const template = yield* GCP.Compute.InstanceTemplate("web", {});
153 * const group = yield* GCP.Compute.InstanceGroupManager("web", {
154 * instanceTemplate: template.templateName,
155 * });
156 * ```
157 *
158 * **Example:** Named ports and target size
159 * ```typescript
160 * const template = yield* GCP.Compute.InstanceTemplate("web", {});
161 * const group = yield* GCP.Compute.InstanceGroupManager("web", {
162 * managerName: "web-mig",
163 * zone: "us-central1-a",
164 * instanceTemplate: template.templateName,
165 * targetSize: 2,
166 * namedPorts: [{ name: "http", port: 80 }],
167 * });
168 * ```
169 *
170 * @resource
171 * @category Compute
172 */
173export const InstanceGroupManager = Resource<InstanceGroupManager>(
174 "GCP.Compute.InstanceGroupManager",
175);
176
177export class InstanceGroupManagerNotResolved extends Data.TaggedError(
178 "GCP.Compute.InstanceGroupManagerNotResolved",
179)<{
180 managerName: string;
181 zone: string;
182}> {}
183
184export class InstanceGroupManagerStillExists extends Data.TaggedError(
185 "GCP.Compute.InstanceGroupManagerStillExists",
186)<{
187 managerName: string;
188 zone: string;
189}> {}
190
191const DEFAULT_ZONE = "us-central1-a";
192const DEFAULT_TARGET_SIZE = 0;
193const MAX_NAME_LENGTH = 63;
194const MAX_BASE_NAME_LENGTH = 58;
195
196const lastSegment = (value: string | undefined) => {
197 if (value === undefined || value.length === 0) return "";
198 const parts = value.split("/").filter((part) => part.length > 0);
199 return parts[parts.length - 1] ?? value;
200};
201
202const rfc1035Name = (name: string, maxLength = MAX_NAME_LENGTH) => {
203 let next = name
204 .toLowerCase()
205 .replace(/[^a-z0-9-]/g, "-")
206 .replace(/-+/g, "-")
207 .replace(/^-+|-+$/g, "");
208 if (!/^[a-z]/.test(next)) {
209 next = `m${next}`;
210 }
211 next = next.slice(0, maxLength).replace(/-+$/g, "");
212 return next.length > 0 ? next : "mig";
213};
214
215const toName = (id: string, name: string | undefined, existing?: string) =>
216 Effect.gen(function* () {
217 return rfc1035Name(
218 name ??
219 existing ??
220 (yield* createPhysicalName({
221 id,
222 maxLength: MAX_NAME_LENGTH,
223 lowercase: true,
224 })),
225 );
226 });
227
228const toBaseInstanceName = (managerName: string, explicit?: string) =>
229 rfc1035Name(explicit ?? managerName, MAX_BASE_NAME_LENGTH);
230
231const encodeDescription = (
232 user: string | undefined,
233 labels: Record<string, string>,
234) => {
235 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
236 const trimmed = user?.trim();
237 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
238};
239
240const parseDescription = (description: string | undefined) => {
241 if (!description?.startsWith("[alchemy ")) {
242 return { user: description, labels: {} as Record<string, string> };
243 }
244 const end = description.indexOf("]");
245 if (end < 0) {
246 return { user: description, labels: {} as Record<string, string> };
247 }
248 const labels: Record<string, string> = {};
249 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
250 const eq = part.indexOf("=");
251 if (eq > 0) {
252 labels[part.slice(0, eq)] = part.slice(eq + 1);
253 }
254 }
255 const rest = description
256 .slice(end + 1)
257 .replace(/^\n/, "")
258 .trim();
259 return {
260 user: rest.length > 0 ? rest : undefined,
261 labels,
262 };
263};
264
265const hasOwnershipMarker = (description: string | undefined) =>
266 Object.keys(parseDescription(description).labels).some((key) =>
267 key.startsWith("alchemy-"),
268 );
269
270const toTemplateUrl = (project: string, template: string) =>
271 template.includes("/")
272 ? template
273 : `projects/${project}/global/instanceTemplates/${template}`;
274
275const toHealthCheckUrl = (project: string, healthCheck: string) =>
276 healthCheck.includes("/")
277 ? healthCheck
278 : `projects/${project}/global/healthChecks/${healthCheck}`;
279
280const toTargetPoolUrl = (project: string, zone: string, pool: string) => {
281 if (pool.includes("/")) return pool;
282 const region = zone.slice(0, zone.lastIndexOf("-"));
283 return `projects/${project}/regions/${region}/targetPools/${pool}`;
284};
285
286const canonPorts = (
287 ports: readonly InstanceGroupManagerNamedPort[] | undefined,
288): InstanceGroupManagerNamedPort[] =>
289 [...(ports ?? [])]
290 .map((port) => ({ name: port.name, port: port.port }))
291 .sort((a, b) => a.name.localeCompare(b.name) || a.port - b.port);
292
293const samePorts = (
294 left: readonly InstanceGroupManagerNamedPort[] | undefined,
295 right: readonly InstanceGroupManagerNamedPort[] | undefined,
296) => JSON.stringify(canonPorts(left)) === JSON.stringify(canonPorts(right));
297
298const fromApiPorts = (
299 ports: readonly compute.NamedPort[] | undefined,
300): InstanceGroupManagerNamedPort[] =>
301 canonPorts(
302 (ports ?? [])
303 .filter(
304 (port): port is { name: string; port: number } =>
305 typeof port.name === "string" && typeof port.port === "number",
306 )
307 .map((port) => ({ name: port.name, port: port.port })),
308 );
309
310const sameSegments = (
311 left: readonly string[] | undefined,
312 right: readonly string[] | undefined,
313) => {
314 const a = [...(left ?? [])].map(lastSegment).sort();
315 const b = [...(right ?? [])].map(lastSegment).sort();
316 return a.length === b.length && a.every((value, index) => value === b[index]);
317};
318
319const toHealing = (
320 project: string,
321 policies: readonly InstanceGroupManagerAutoHealingPolicy[],
322): compute.InstanceGroupManagerAutoHealingPolicy[] =>
323 policies.map((policy) => ({
324 healthCheck: toHealthCheckUrl(project, policy.healthCheck),
325 initialDelaySec: policy.initialDelaySec,
326 }));
327
328const fromApiHealing = (
329 policies:
330 | readonly compute.InstanceGroupManagerAutoHealingPolicy[]
331 | undefined,
332): InstanceGroupManagerAutoHealingPolicy[] =>
333 (policies ?? [])
334 .filter(
335 (policy): policy is { healthCheck: string } & typeof policy =>
336 typeof policy.healthCheck === "string",
337 )
338 .map((policy) => ({
339 healthCheck: policy.healthCheck,
340 initialDelaySec: policy.initialDelaySec,
341 }));
342
343const sameHealing = (
344 left:
345 | readonly { healthCheck?: string; initialDelaySec?: number }[]
346 | undefined,
347 right:
348 | readonly { healthCheck?: string; initialDelaySec?: number }[]
349 | undefined,
350) => {
351 const canon = (
352 policies: readonly { healthCheck?: string; initialDelaySec?: number }[],
353 ) =>
354 [...policies]
355 .map((policy) => ({
356 healthCheck: lastSegment(policy.healthCheck ?? ""),
357 initialDelaySec: policy.initialDelaySec ?? 0,
358 }))
359 .sort((a, b) => a.healthCheck.localeCompare(b.healthCheck));
360 return (
361 JSON.stringify(canon(left ?? [])) === JSON.stringify(canon(right ?? []))
362 );
363};
364
365const sameUpdatePolicy = (
366 left: compute.InstanceGroupManagerUpdatePolicy | undefined,
367 right: compute.InstanceGroupManagerUpdatePolicy | undefined,
368) => JSON.stringify(left ?? {}) === JSON.stringify(right ?? {});
369
370const toAttrs = (manager: compute.InstanceGroupManager, project: string) => {
371 const { user } = parseDescription(manager.description);
372 return {
373 managerName: manager.name ?? lastSegment(manager.selfLink),
374 zone: lastSegment(manager.zone) || DEFAULT_ZONE,
375 project,
376 instanceTemplate: manager.instanceTemplate,
377 targetSize: manager.targetSize ?? DEFAULT_TARGET_SIZE,
378 baseInstanceName: manager.baseInstanceName,
379 description: user,
380 namedPorts: fromApiPorts(manager.namedPorts),
381 instanceGroup: manager.instanceGroup,
382 isStable: manager.status?.isStable,
383 selfLink: manager.selfLink,
384 id: manager.id,
385 creationTimestamp: manager.creationTimestamp,
386 };
387};
388
389const getByName = (
390 project: string,
391 zone: string,
392 instanceGroupManager: string,
393) =>
394 compute
395 .getInstanceGroupManagers({ project, zone, instanceGroupManager })
396 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
397
398const waitGone = (project: string, zone: string, managerName: string) =>
399 getByName(project, zone, managerName).pipe(
400 Effect.flatMap((manager) =>
401 manager === undefined
402 ? Effect.void
403 : Effect.fail(
404 new InstanceGroupManagerStillExists({ managerName, zone }),
405 ),
406 ),
407 Effect.retry({
408 while: (error) =>
409 error._tag === "GCP.Compute.InstanceGroupManagerStillExists",
410 times: 10,
411 schedule: Schedule.spaced("2 seconds"),
412 }),
413 );
414
415const waitPresent = (project: string, zone: string, managerName: string) =>
416 getByName(project, zone, managerName).pipe(
417 Effect.flatMap((manager) =>
418 manager === undefined
419 ? Effect.fail(
420 new InstanceGroupManagerNotResolved({ managerName, zone }),
421 )
422 : Effect.succeed(manager),
423 ),
424 Effect.retry({
425 while: (error) =>
426 error._tag === "GCP.Compute.InstanceGroupManagerNotResolved",
427 times: 8,
428 schedule: Schedule.exponential("250 millis"),
429 }),
430 Effect.catchTag("GCP.Compute.InstanceGroupManagerNotResolved", () =>
431 Effect.succeed(undefined),
432 ),
433 );
434
435const runOp = <E, R>(
436 project: string,
437 zone: string,
438 start: Effect.Effect<compute.Operation, E, R>,
439) =>
440 start.pipe(
441 Effect.flatMap((operation) =>
442 waitZoneOperation(project, zone, operation, {
443 ignore: ["RESOURCE_ALREADY_EXISTS"],
444 }),
445 ),
446 );
447
448const syncNamedPorts = (
449 project: string,
450 zone: string,
451 instanceGroupUrl: string | undefined,
452 namedPorts: InstanceGroupManagerNamedPort[],
453) =>
454 Effect.gen(function* () {
455 const instanceGroup = lastSegment(instanceGroupUrl);
456 if (instanceGroup.length === 0) return;
457 const group = yield* compute
458 .getInstanceGroups({ project, zone, instanceGroup })
459 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
460 if (group === undefined) return;
461 if (samePorts(fromApiPorts(group.namedPorts), namedPorts)) return;
462 yield* runOp(
463 project,
464 zone,
465 compute.setNamedPortsInstanceGroups({
466 project,
467 zone,
468 instanceGroup,
469 body: {
470 namedPorts,
471 fingerprint: group.fingerprint,
472 },
473 }),
474 ).pipe(
475 Effect.retry({
476 while: (error) => error._tag === "Conflict",
477 times: 5,
478 schedule: Schedule.exponential("250 millis"),
479 }),
480 );
481 });
482
483export const InstanceGroupManagerProvider = () =>
484 Provider.succeed(InstanceGroupManager, {
485 stables: [
486 "managerName",
487 "zone",
488 "project",
489 "baseInstanceName",
490 "id",
491 "selfLink",
492 "instanceGroup",
493 "creationTimestamp",
494 ],
495
496 diff: Effect.fn(function* ({ news, olds, output }) {
497 if (!isResolved(news)) return undefined;
498 const previousName = olds?.managerName ?? output?.managerName;
499 const nextName = news.managerName ?? previousName;
500 const previousZone = lastSegment(olds?.zone ?? output?.zone);
501 const nextZone = lastSegment(news.zone ?? DEFAULT_ZONE);
502 const previousBase = olds?.baseInstanceName ?? output?.baseInstanceName;
503 const nextBase =
504 news.baseInstanceName !== undefined
505 ? toBaseInstanceName(
506 nextName ?? news.baseInstanceName,
507 news.baseInstanceName,
508 )
509 : previousBase;
510 const nameChanged =
511 previousName !== undefined &&
512 nextName !== undefined &&
513 previousName !== nextName;
514 const zoneChanged = previousZone.length > 0 && previousZone !== nextZone;
515 const baseChanged =
516 previousBase !== undefined &&
517 nextBase !== undefined &&
518 lastSegment(previousBase) !== lastSegment(nextBase);
519 if (!nameChanged && !zoneChanged && !baseChanged) {
520 return undefined;
521 }
522 return {
523 action: "replace" as const,
524 deleteFirst:
525 previousName !== undefined &&
526 nextName !== undefined &&
527 previousName === nextName &&
528 previousZone === nextZone,
529 };
530 }),
531
532 read: Effect.fn(function* ({ id, olds, output }) {
533 const env = yield* GcpEnvironment.current;
534 const managerName = yield* toName(
535 id,
536 olds?.managerName,
537 output?.managerName,
538 );
539 const zone = lastSegment(olds?.zone ?? output?.zone ?? DEFAULT_ZONE);
540 const existing = yield* getByName(env.project, zone, managerName);
541 if (existing === undefined) return undefined;
542 const attrs = toAttrs(existing, env.project);
543 const { labels } = parseDescription(existing.description);
544 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
545 }),
546
547 list: () =>
548 Effect.gen(function* () {
549 const env = yield* GcpEnvironment.current;
550 const pages = yield* compute.aggregatedListInstanceGroupManagers
551 .pages({
552 project: env.project,
553 maxResults: 500,
554 returnPartialSuccess: true,
555 })
556 .pipe(Stream.runCollect);
557 return Array.from(pages).flatMap((page) =>
558 Object.entries(page.items ?? {}).flatMap(([scope, scoped]) => {
559 if (!scope.startsWith("zones/")) return [];
560 return (scoped?.instanceGroupManagers ?? [])
561 .filter((manager) => hasOwnershipMarker(manager.description))
562 .map((manager) => toAttrs(manager, env.project));
563 }),
564 );
565 }),
566
567 reconcile: Effect.fn(function* ({ id, news, output }) {
568 const env = yield* GcpEnvironment.current;
569 const managerName = yield* toName(
570 id,
571 news.managerName,
572 output?.managerName,
573 );
574 const zone = lastSegment(news.zone ?? output?.zone ?? DEFAULT_ZONE);
575 const instanceTemplate = toTemplateUrl(
576 env.project,
577 news.instanceTemplate,
578 );
579 const targetSize = news.targetSize ?? DEFAULT_TARGET_SIZE;
580 const baseInstanceName = toBaseInstanceName(
581 managerName,
582 news.baseInstanceName ?? output?.baseInstanceName,
583 );
584 const namedPorts = canonPorts(news.namedPorts);
585 const desiredLabels = yield* createInternalLabels(id);
586 const description = encodeDescription(news.description, desiredLabels);
587
588 let current = yield* getByName(env.project, zone, managerName);
589
590 if (current === undefined) {
591 yield* compute
592 .insertInstanceGroupManagers({
593 project: env.project,
594 zone,
595 body: {
596 name: managerName,
597 description,
598 instanceTemplate,
599 baseInstanceName,
600 targetSize,
601 namedPorts: namedPorts.length > 0 ? namedPorts : undefined,
602 autoHealingPolicies:
603 news.autoHealingPolicies !== undefined
604 ? toHealing(env.project, news.autoHealingPolicies)
605 : undefined,
606 updatePolicy: news.updatePolicy,
607 targetPools:
608 news.targetPools !== undefined
609 ? news.targetPools.map((pool) =>
610 toTargetPoolUrl(env.project, zone, pool),
611 )
612 : undefined,
613 instanceLifecyclePolicy: news.instanceLifecyclePolicy,
614 },
615 })
616 .pipe(
617 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
618 Effect.flatMap((operation) =>
619 operation === undefined
620 ? Effect.void
621 : waitZoneOperation(env.project, zone, operation, {
622 ignore: ["RESOURCE_ALREADY_EXISTS"],
623 }).pipe(Effect.asVoid),
624 ),
625 );
626 current = yield* waitPresent(env.project, zone, managerName);
627 }
628
629 if (current === undefined) {
630 return yield* new InstanceGroupManagerNotResolved({
631 managerName,
632 zone,
633 });
634 }
635
636 const patch: compute.InstanceGroupManager = {
637 fingerprint: current.fingerprint,
638 };
639 let dirty = false;
640
641 if ((current.description ?? "") !== description) {
642 patch.description = description;
643 dirty = true;
644 }
645 if ((current.targetSize ?? DEFAULT_TARGET_SIZE) !== targetSize) {
646 patch.targetSize = targetSize;
647 dirty = true;
648 }
649 if (
650 lastSegment(current.instanceTemplate) !== lastSegment(instanceTemplate)
651 ) {
652 patch.instanceTemplate = instanceTemplate;
653 dirty = true;
654 }
655 if (news.autoHealingPolicies !== undefined) {
656 const desiredHealing = toHealing(env.project, news.autoHealingPolicies);
657 if (
658 !sameHealing(
659 fromApiHealing(current.autoHealingPolicies),
660 desiredHealing,
661 )
662 ) {
663 patch.autoHealingPolicies = desiredHealing;
664 dirty = true;
665 }
666 }
667 if (
668 news.updatePolicy !== undefined &&
669 !sameUpdatePolicy(current.updatePolicy, news.updatePolicy)
670 ) {
671 patch.updatePolicy = news.updatePolicy;
672 dirty = true;
673 }
674 if (news.targetPools !== undefined) {
675 const desiredPools = news.targetPools.map((pool) =>
676 toTargetPoolUrl(env.project, zone, pool),
677 );
678 if (!sameSegments(current.targetPools, desiredPools)) {
679 patch.targetPools = desiredPools;
680 dirty = true;
681 }
682 }
683 const observedLifecycle: Record<string, unknown> = {
684 ...current.instanceLifecyclePolicy,
685 };
686 if (
687 news.instanceLifecyclePolicy !== undefined &&
688 Object.entries(news.instanceLifecyclePolicy).some(
689 ([key, value]) =>
690 JSON.stringify(observedLifecycle[key] ?? null) !==
691 JSON.stringify(value ?? null),
692 )
693 ) {
694 patch.instanceLifecyclePolicy = news.instanceLifecyclePolicy;
695 dirty = true;
696 }
697
698 if (dirty) {
699 yield* runOp(
700 env.project,
701 zone,
702 compute.patchInstanceGroupManagers({
703 project: env.project,
704 zone,
705 instanceGroupManager: managerName,
706 body: patch,
707 }),
708 ).pipe(
709 Effect.retry({
710 while: (error) => error._tag === "Conflict",
711 times: 5,
712 schedule: Schedule.exponential("250 millis"),
713 }),
714 );
715 current = (yield* getByName(env.project, zone, managerName)) ?? current;
716 }
717
718 if (!samePorts(fromApiPorts(current.namedPorts), namedPorts)) {
719 yield* syncNamedPorts(
720 env.project,
721 zone,
722 current.instanceGroup,
723 namedPorts,
724 );
725 current = (yield* getByName(env.project, zone, managerName)) ?? current;
726 }
727
728 return toAttrs(current, env.project);
729 }),
730
731 delete: Effect.fn(function* ({ output }) {
732 yield* compute
733 .deleteInstanceGroupManagers({
734 project: output.project,
735 zone: output.zone,
736 instanceGroupManager: output.managerName,
737 })
738 .pipe(
739 Effect.retry({
740 while: (error) => error._tag === "Conflict",
741 times: 8,
742 schedule: Schedule.spaced("2 seconds"),
743 }),
744 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
745 Effect.flatMap((operation) =>
746 operation === undefined
747 ? Effect.void
748 : waitZoneOperation(output.project, output.zone, operation, {
749 ignore: ["RESOURCE_NOT_FOUND"],
750 }).pipe(Effect.asVoid),
751 ),
752 Effect.catchTag("NotFound", () => Effect.void),
753 );
754 yield* waitGone(output.project, output.zone, output.managerName);
755 }),
756 });