1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { type WaitComputeOptions, 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 { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23export type InstanceAttachedDisk = {
24 /**
25 * Disk URL or partial URL (`zones/{zone}/disks/{disk}`).
26 */
27 source: string;
28 /**
29 * Device name exposed to the guest as `/dev/disk/by-id/google-{deviceName}`.
30 * @default the disk name
31 */
32 deviceName?: string;
33 /**
34 * Attach mode.
35 * @default "READ_WRITE"
36 */
37 mode?: "READ_WRITE" | "READ_ONLY";
38};
39
40export type InstanceProps = {
41 /**
42 * Instance name. If omitted, a unique RFC1035 name is generated from the
43 * stack, stage, and logical id. 1-63 characters, lowercase letter first.
44 */
45 instanceName?: string;
46 /**
47 * Zone to create the instance in (e.g. `us-central1-a`). Immutable —
48 * changing it replaces the instance.
49 * @default "us-central1-a"
50 */
51 zone?: string;
52 /**
53 * Machine type short name (`e2-micro`) or partial/full URL. Immutable —
54 * changing it replaces the instance.
55 * @default "e2-micro"
56 */
57 machineType?: string;
58 /**
59 * Optional description.
60 */
61 description?: string;
62 /**
63 * User labels. Alchemy ownership labels are merged in automatically.
64 */
65 labels?: Record<string, string>;
66 /**
67 * Network tags used by VPC firewall rules.
68 */
69 tags?: string[];
70 /**
71 * Instance metadata key/value pairs (replaces the instance metadata set).
72 */
73 metadata?: Record<string, string>;
74 /**
75 * Source image for the boot disk. Immutable — changing it replaces the
76 * instance.
77 * @default "projects/debian-cloud/global/images/family/debian-12"
78 */
79 sourceImage?: string;
80 /**
81 * Boot disk size in GB.
82 * @default 10
83 */
84 diskSizeGb?: number;
85 /**
86 * VPC network URL or partial URL.
87 * @default "global/networks/default"
88 */
89 network?: string;
90 /**
91 * Optional subnetwork URL or partial URL.
92 */
93 subnetwork?: string;
94 /**
95 * Attach an ephemeral public IPv4 address.
96 * @default false
97 */
98 associatePublicIp?: boolean;
99 /**
100 * Reserved static external IPv4 address to assign to the primary network
101 * interface (e.g. `GCP.Compute.Address(...).address`). Implies a public
102 * access config; takes precedence over `associatePublicIp`.
103 */
104 natIP?: string;
105 /**
106 * Additional (non-boot) persistent disks to attach, e.g.
107 * `GCP.Compute.Disk(...).selfLink`. The disks must be in the instance's
108 * zone. Disks attached out-of-band that are not listed here are detached.
109 */
110 attachedDisks?: InstanceAttachedDisk[];
111 /**
112 * Create a preemptible VM. Immutable — changing it replaces the instance.
113 * @default false
114 */
115 preemptible?: boolean;
116 /**
117 * Automatically restart the VM if Compute Engine terminates it.
118 * Ignored (forced off) when `preemptible` is true.
119 * @default true
120 */
121 automaticRestart?: boolean;
122 /**
123 * Allow packets with non-matching source/destination IPs (IP forwarding).
124 * @default false
125 */
126 canIpForward?: boolean;
127 /**
128 * Protect the instance from accidental deletion.
129 * @default false
130 */
131 deletionProtection?: boolean;
132};
133
134export type Instance = Resource<
135 "GCP.Compute.Instance",
136 InstanceProps,
137 {
138 /** Instance name (RFC1035). */
139 instanceName: string;
140 /** Server-assigned numeric instance id. */
141 instanceId: string;
142 /** Project id. */
143 project: string;
144 /** Zone short name (e.g. `us-central1-a`). */
145 zone: string;
146 /** Machine type short name (e.g. `e2-micro`). */
147 machineType: string;
148 /** Current instance status (`RUNNING`, `TERMINATED`, …). */
149 status: string | undefined;
150 /** User labels (Alchemy ownership labels stripped). */
151 labels: Record<string, string>;
152 /** Network tags. */
153 tags: string[];
154 /** Instance metadata key/value pairs. */
155 metadata: Record<string, string>;
156 /** Whether deletion protection is enabled. */
157 deletionProtection: boolean;
158 /** Whether IP forwarding is enabled. */
159 canIpForward: boolean;
160 /** Primary internal IPv4. */
161 networkIP: string | undefined;
162 /** Ephemeral or reserved public IPv4, if any. */
163 natIP: string | undefined;
164 /** Additional (non-boot) disks attached to the instance. */
165 attachedDisks: {
166 source: string;
167 deviceName: string | undefined;
168 mode: string | undefined;
169 }[];
170 /** Compute Engine self-link. */
171 selfLink: string | undefined;
172 /** RFC3339 creation timestamp. */
173 creationTimestamp: string | undefined;
174 /** CPU platform reported by Compute Engine. */
175 cpuPlatform: string | undefined;
176 },
177 never,
178 Providers
179>;
180
181/**
182 * A Google Compute Engine VM instance.
183 *
184 * ### Creating an Instance
185 * **Example:** Generated name
186 * ```typescript
187 * const vm = yield* GCP.Compute.Instance("web", {
188 * zone: "us-central1-a",
189 * machineType: "e2-micro",
190 * });
191 * ```
192 *
193 * **Example:** Explicit name, labels, and metadata
194 * ```typescript
195 * const vm = yield* GCP.Compute.Instance("web", {
196 * instanceName: "web-1",
197 * zone: "us-central1-a",
198 * machineType: "e2-micro",
199 * sourceImage: "projects/debian-cloud/global/images/family/debian-12",
200 * labels: { env: "prod" },
201 * tags: ["http-server"],
202 * metadata: { "enable-oslogin": "TRUE" },
203 * });
204 * ```
205 *
206 * ### Static IPs and Extra Disks
207 * **Example:** Web server with a static IP and a data disk
208 * ```typescript
209 * const ip = yield* GCP.Compute.Address("web-ip", { region: "us-central1" });
210 * const data = yield* GCP.Compute.Disk("web-data", {
211 * zone: "us-central1-a",
212 * sizeGb: 10,
213 * });
214 * const vm = yield* GCP.Compute.Instance("web", {
215 * zone: "us-central1-a",
216 * natIP: ip.address,
217 * attachedDisks: [{ source: data.selfLink, deviceName: "data" }],
218 * tags: ["http-server"],
219 * metadata: {
220 * "startup-script": "#!/bin/bash\ncd /tmp && python3 -m http.server 80",
221 * },
222 * });
223 * ```
224 *
225 * ### Starting and Stopping
226 * **Example:** Start a bound instance
227 * ```typescript
228 * const start = yield* GCP.Compute.StartInstance(vm);
229 * yield* start();
230 * ```
231 *
232 * @resource
233 * @category Compute
234 */
235export const Instance = Resource<Instance>("GCP.Compute.Instance");
236
237export class InstanceNotResolved extends Data.TaggedError(
238 "GCP.Compute.InstanceNotResolved",
239)<{
240 instanceName: string;
241 zone: string;
242}> {}
243
244export class InstanceStillExists extends Data.TaggedError(
245 "GCP.Compute.InstanceStillExists",
246)<{
247 instanceName: string;
248 zone: string;
249 status: string;
250}> {}
251
252export class InstanceNotSettled extends Data.TaggedError(
253 "GCP.Compute.InstanceNotSettled",
254)<{
255 instanceName: string;
256 zone: string;
257 status: string;
258}> {}
259
260const DEFAULT_ZONE = "us-central1-a";
261const DEFAULT_MACHINE_TYPE = "e2-micro";
262const DEFAULT_SOURCE_IMAGE =
263 "projects/debian-cloud/global/images/family/debian-12";
264const DEFAULT_NETWORK = "global/networks/default";
265const DEFAULT_DISK_SIZE_GB = 10;
266
267const lastSegment = (value: string | undefined): string => {
268 if (value === undefined || value.length === 0) return "";
269 const parts = value.split("/");
270 return parts[parts.length - 1] ?? value;
271};
272
273const rfc1035 = (name: string): string => {
274 let next = name
275 .toLowerCase()
276 .replace(/[^a-z0-9-]/g, "-")
277 .replace(/-+/g, "-")
278 .replace(/^-+|-+$/g, "");
279 if (!/^[a-z]/.test(next)) next = `n${next}`;
280 next = next.slice(0, 63).replace(/-+$/g, "");
281 return next.length > 0 ? next : "instance";
282};
283
284const machineTypeUrl = (zone: string, machineType: string): string =>
285 machineType.includes("/")
286 ? machineType
287 : `zones/${zone}/machineTypes/${machineType}`;
288
289const userLabels = (
290 labels: Record<string, string | undefined> | null | undefined,
291): Record<string, string> => stripInternalLabels(tagRecord(labels));
292
293const metadataRecord = (
294 metadata: compute.Metadata | undefined,
295): Record<string, string> =>
296 Object.fromEntries(
297 (metadata?.items ?? [])
298 .filter((item) => item.key !== undefined)
299 .map((item) => [item.key!, item.value ?? ""]),
300 );
301
302const sameRecord = (
303 left: Record<string, string>,
304 right: Record<string, string>,
305): boolean => {
306 const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
307 for (const key of keys) {
308 if (left[key] !== right[key]) return false;
309 }
310 return true;
311};
312
313const sameTags = (left: string[], right: string[]): boolean => {
314 if (left.length !== right.length) return false;
315 const a = [...left].sort();
316 const b = [...right].sort();
317 return a.every((value, index) => value === b[index]);
318};
319
320const toName = (id: string, name: string | undefined, existing?: string) =>
321 Effect.gen(function* () {
322 return (
323 name ??
324 existing ??
325 rfc1035(
326 yield* createPhysicalName({
327 id,
328 maxLength: 63,
329 lowercase: true,
330 }),
331 )
332 );
333 });
334
335const extraDisks = (instance: compute.Instance) =>
336 (instance.disks ?? []).filter((disk) => disk.boot !== true);
337
338/** Disks are zonal, so the disk name identifies a disk within the zone. */
339const diskKey = (source: string | undefined) => lastSegment(source);
340
341const desiredAttachedDisk = (
342 disk: InstanceAttachedDisk,
343): compute.AttachedDisk => ({
344 source: disk.source,
345 deviceName: disk.deviceName,
346 mode: disk.mode ?? "READ_WRITE",
347 type: "PERSISTENT",
348 autoDelete: false,
349});
350
351type DesiredAccess =
352 | { kind: "none" }
353 | { kind: "ephemeral" }
354 | { kind: "static"; natIP: string };
355
356const desiredAccess = (news: InstanceProps): DesiredAccess =>
357 news.natIP !== undefined
358 ? { kind: "static", natIP: news.natIP }
359 : news.associatePublicIp === true
360 ? { kind: "ephemeral" }
361 : { kind: "none" };
362
363const accessConfigsFor = (access: DesiredAccess): compute.AccessConfig[] =>
364 access.kind === "none"
365 ? []
366 : [
367 {
368 type: "ONE_TO_ONE_NAT",
369 name: "External NAT",
370 ...(access.kind === "static" ? { natIP: access.natIP } : {}),
371 },
372 ];
373
374const toAttrs = (instance: compute.Instance, project: string) => {
375 const nic = instance.networkInterfaces?.[0];
376 return {
377 instanceName: instance.name ?? "",
378 instanceId: instance.id ?? "",
379 project,
380 zone: lastSegment(instance.zone),
381 machineType: lastSegment(instance.machineType),
382 status: instance.status,
383 labels: userLabels(instance.labels),
384 tags: [...(instance.tags?.items ?? [])],
385 metadata: metadataRecord(instance.metadata),
386 deletionProtection: instance.deletionProtection === true,
387 canIpForward: instance.canIpForward === true,
388 networkIP: nic?.networkIP,
389 natIP: nic?.accessConfigs?.[0]?.natIP,
390 attachedDisks: extraDisks(instance).map((disk) => ({
391 source: disk.source ?? "",
392 deviceName: disk.deviceName,
393 mode: disk.mode,
394 })),
395 selfLink: instance.selfLink,
396 creationTimestamp: instance.creationTimestamp,
397 cpuPlatform: instance.cpuPlatform,
398 };
399};
400
401const getByName = (project: string, zone: string, instance: string) =>
402 compute
403 .getInstances({ project, zone, instance })
404 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
405
406const waitUntilGone = (project: string, zone: string, instanceName: string) =>
407 getByName(project, zone, instanceName).pipe(
408 Effect.flatMap((instance) =>
409 instance === undefined
410 ? Effect.void
411 : Effect.fail(
412 new InstanceStillExists({
413 instanceName,
414 zone,
415 status: instance.status ?? "UNKNOWN",
416 }),
417 ),
418 ),
419 Effect.retry({
420 while: (error) => error._tag === "GCP.Compute.InstanceStillExists",
421 times: 18,
422 schedule: Schedule.spaced("3 seconds"),
423 }),
424 );
425
426/** Transitional statuses Compute passes through before an instance settles. */
427const TRANSITIONAL_STATUSES = new Set([
428 "PENDING",
429 "PROVISIONING",
430 "STAGING",
431 "REPAIRING",
432 "STOPPING",
433 "PENDING_STOP",
434 "SUSPENDING",
435]);
436
437/** Poll until the instance reports a settled status (e.g. `RUNNING`). */
438const waitUntilSettled = (
439 project: string,
440 zone: string,
441 instanceName: string,
442) =>
443 getByName(project, zone, instanceName).pipe(
444 Effect.flatMap(
445 (
446 instance,
447 ): Effect.Effect<
448 compute.Instance,
449 InstanceNotResolved | InstanceNotSettled
450 > =>
451 instance === undefined
452 ? Effect.fail(new InstanceNotResolved({ instanceName, zone }))
453 : TRANSITIONAL_STATUSES.has(instance.status ?? "")
454 ? Effect.fail(
455 new InstanceNotSettled({
456 instanceName,
457 zone,
458 status: instance.status ?? "UNKNOWN",
459 }),
460 )
461 : Effect.succeed(instance),
462 ),
463 Effect.retry({
464 while: (error) =>
465 error._tag === "GCP.Compute.InstanceNotSettled" ||
466 error._tag === "GCP.Compute.InstanceNotResolved",
467 times: 60,
468 schedule: Schedule.spaced("5 seconds"),
469 }),
470 );
471
472const applyZoneOp = <E, R>(
473 project: string,
474 zone: string,
475 start: Effect.Effect<compute.Operation, E, R>,
476 options?: WaitComputeOptions,
477) =>
478 Effect.gen(function* () {
479 const op = yield* start;
480 return yield* waitZoneOperation(project, zone, op, options);
481 });
482
483const insertBody = (
484 news: InstanceProps,
485 instanceName: string,
486 zone: string,
487 desiredLabels: Record<string, string>,
488): compute.Instance => {
489 const preemptible = news.preemptible === true;
490 const metadata = news.metadata
491 ? {
492 items: Object.entries(news.metadata).map(([key, value]) => ({
493 key,
494 value,
495 })),
496 }
497 : undefined;
498 return {
499 name: instanceName,
500 machineType: machineTypeUrl(zone, news.machineType ?? DEFAULT_MACHINE_TYPE),
501 description: news.description,
502 labels: desiredLabels,
503 tags: news.tags ? { items: news.tags } : undefined,
504 metadata,
505 canIpForward: news.canIpForward === true,
506 deletionProtection: news.deletionProtection === true,
507 scheduling: preemptible
508 ? {
509 preemptible: true,
510 automaticRestart: false,
511 onHostMaintenance: "TERMINATE",
512 }
513 : {
514 preemptible: false,
515 automaticRestart: news.automaticRestart !== false,
516 },
517 disks: [
518 {
519 boot: true,
520 autoDelete: true,
521 type: "PERSISTENT",
522 initializeParams: {
523 sourceImage: news.sourceImage ?? DEFAULT_SOURCE_IMAGE,
524 diskSizeGb: String(news.diskSizeGb ?? DEFAULT_DISK_SIZE_GB),
525 },
526 },
527 ...(news.attachedDisks ?? []).map(desiredAttachedDisk),
528 ],
529 networkInterfaces: [
530 {
531 network: news.network ?? DEFAULT_NETWORK,
532 subnetwork: news.subnetwork,
533 accessConfigs: accessConfigsFor(desiredAccess(news)),
534 },
535 ],
536 };
537};
538
539export const InstanceProvider = () =>
540 Provider.succeed(Instance, {
541 stables: [
542 "instanceName",
543 "instanceId",
544 "project",
545 "zone",
546 "selfLink",
547 "creationTimestamp",
548 ],
549
550 diff: Effect.fn(function* ({ news, olds, output }) {
551 if (!isResolved(news)) return undefined;
552 const previousZone = olds?.zone ?? output?.zone ?? DEFAULT_ZONE;
553 const nextZone = news.zone ?? DEFAULT_ZONE;
554 if (lastSegment(previousZone) !== lastSegment(nextZone)) {
555 return { action: "replace" as const };
556 }
557 const previousName = olds?.instanceName ?? output?.instanceName;
558 const nextName = news.instanceName ?? previousName;
559 if (
560 previousName !== undefined &&
561 nextName !== undefined &&
562 previousName !== nextName
563 ) {
564 return { action: "replace" as const, deleteFirst: true };
565 }
566 const previousType = lastSegment(
567 olds?.machineType ?? DEFAULT_MACHINE_TYPE,
568 );
569 const nextType = lastSegment(news.machineType ?? DEFAULT_MACHINE_TYPE);
570 if (olds !== undefined && previousType !== nextType) {
571 return { action: "replace" as const, deleteFirst: true };
572 }
573 if (
574 olds?.sourceImage !== undefined &&
575 news.sourceImage !== undefined &&
576 olds.sourceImage !== news.sourceImage
577 ) {
578 return { action: "replace" as const, deleteFirst: true };
579 }
580 if (
581 olds !== undefined &&
582 (olds.preemptible === true) !== (news.preemptible === true)
583 ) {
584 return { action: "replace" as const, deleteFirst: true };
585 }
586 return undefined;
587 }),
588
589 read: Effect.fn(function* ({ id, olds, output }) {
590 const env = yield* GcpEnvironment.current;
591 const instanceName = yield* toName(
592 id,
593 olds?.instanceName,
594 output?.instanceName,
595 );
596 const zone = lastSegment(olds?.zone ?? output?.zone ?? DEFAULT_ZONE);
597 const existing = yield* getByName(env.project, zone, instanceName);
598 if (existing === undefined) return undefined;
599 const attrs = toAttrs(existing, env.project);
600 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
601 ? attrs
602 : Unowned(attrs);
603 }),
604
605 list: () =>
606 Effect.gen(function* () {
607 const env = yield* GcpEnvironment.current;
608 const pages = yield* compute.aggregatedListInstances
609 .pages({
610 project: env.project,
611 filter: "labels.alchemy-id:*",
612 returnPartialSuccess: true,
613 maxResults: 500,
614 })
615 .pipe(Stream.runCollect);
616 return Array.from(pages).flatMap((page) =>
617 Object.values(page.items ?? {}).flatMap((scoped) =>
618 (scoped?.instances ?? []).map((instance) =>
619 toAttrs(instance, env.project),
620 ),
621 ),
622 );
623 }),
624
625 reconcile: Effect.fn(function* ({ id, news, output }) {
626 const env = yield* GcpEnvironment.current;
627 const zone = lastSegment(news.zone ?? output?.zone ?? DEFAULT_ZONE);
628 const instanceName = yield* toName(
629 id,
630 news.instanceName,
631 output?.instanceName,
632 );
633 const desiredLabels = {
634 ...toLabels(news.labels),
635 ...(yield* createInternalLabels(id)),
636 };
637
638 let current = yield* getByName(env.project, zone, instanceName);
639
640 if (current === undefined) {
641 yield* applyZoneOp(
642 env.project,
643 zone,
644 compute.insertInstances({
645 project: env.project,
646 zone,
647 body: insertBody(news, instanceName, zone, desiredLabels),
648 }),
649 { ignore: ["RESOURCE_ALREADY_EXISTS"] },
650 ).pipe(Effect.catchTag("Conflict", () => Effect.void));
651 current = yield* getByName(env.project, zone, instanceName).pipe(
652 Effect.flatMap((existing) =>
653 existing !== undefined
654 ? Effect.succeed(existing)
655 : Effect.fail(new InstanceNotResolved({ instanceName, zone })),
656 ),
657 Effect.retry({
658 while: (error) => error._tag === "GCP.Compute.InstanceNotResolved",
659 schedule: Schedule.spaced("2 seconds"),
660 times: 8,
661 }),
662 );
663 }
664
665 if (current === undefined) {
666 return yield* new InstanceNotResolved({ instanceName, zone });
667 }
668
669 const observedLabels = tagRecord(current.labels);
670 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
671 if (upsert.length > 0 || removed.length > 0) {
672 yield* applyZoneOp(
673 env.project,
674 zone,
675 compute.setLabelsInstances({
676 project: env.project,
677 zone,
678 instance: instanceName,
679 body: {
680 labels: desiredLabels,
681 labelFingerprint: current.labelFingerprint,
682 },
683 }),
684 );
685 current =
686 (yield* getByName(env.project, zone, instanceName)) ?? current;
687 }
688
689 if (news.tags !== undefined) {
690 const observedTags = [...(current.tags?.items ?? [])];
691 if (!sameTags(observedTags, news.tags)) {
692 yield* applyZoneOp(
693 env.project,
694 zone,
695 compute.setTagsInstances({
696 project: env.project,
697 zone,
698 instance: instanceName,
699 body: {
700 items: news.tags,
701 fingerprint: current.tags?.fingerprint,
702 },
703 }),
704 );
705 current =
706 (yield* getByName(env.project, zone, instanceName)) ?? current;
707 }
708 }
709
710 if (news.metadata !== undefined) {
711 const observedMetadata = metadataRecord(current.metadata);
712 if (!sameRecord(observedMetadata, news.metadata)) {
713 yield* applyZoneOp(
714 env.project,
715 zone,
716 compute.setMetadataInstances({
717 project: env.project,
718 zone,
719 instance: instanceName,
720 body: {
721 fingerprint: current.metadata?.fingerprint,
722 items: Object.entries(news.metadata).map(([key, value]) => ({
723 key,
724 value,
725 })),
726 },
727 }),
728 );
729 current =
730 (yield* getByName(env.project, zone, instanceName)) ?? current;
731 }
732 }
733
734 const nic = current.networkInterfaces?.[0];
735 const nicName = nic?.name ?? "nic0";
736 const observedAccess = nic?.accessConfigs?.[0];
737 const access = desiredAccess(news);
738 const accessMatches =
739 access.kind === "none"
740 ? observedAccess === undefined
741 : access.kind === "ephemeral"
742 ? observedAccess !== undefined
743 : observedAccess?.natIP === access.natIP;
744 if (!accessMatches) {
745 if (observedAccess !== undefined) {
746 yield* applyZoneOp(
747 env.project,
748 zone,
749 compute.deleteAccessConfigInstances({
750 project: env.project,
751 zone,
752 instance: instanceName,
753 networkInterface: nicName,
754 accessConfig: observedAccess.name ?? "External NAT",
755 }),
756 );
757 }
758 for (const config of accessConfigsFor(access)) {
759 yield* applyZoneOp(
760 env.project,
761 zone,
762 compute.addAccessConfigInstances({
763 project: env.project,
764 zone,
765 instance: instanceName,
766 networkInterface: nicName,
767 body: config,
768 }),
769 );
770 }
771 current =
772 (yield* getByName(env.project, zone, instanceName)) ?? current;
773 }
774
775 const desiredDisks = news.attachedDisks ?? [];
776 const observedDisks = extraDisks(current);
777 const desiredKeys = new Set(
778 desiredDisks.map((disk) => diskKey(disk.source)),
779 );
780 const observedKeys = new Set(
781 observedDisks.map((disk) => diskKey(disk.source)),
782 );
783 for (const disk of observedDisks) {
784 if (desiredKeys.has(diskKey(disk.source))) continue;
785 yield* applyZoneOp(
786 env.project,
787 zone,
788 compute.detachDiskInstances({
789 project: env.project,
790 zone,
791 instance: instanceName,
792 deviceName: disk.deviceName ?? diskKey(disk.source),
793 }),
794 );
795 }
796 for (const disk of desiredDisks) {
797 if (observedKeys.has(diskKey(disk.source))) continue;
798 yield* applyZoneOp(
799 env.project,
800 zone,
801 compute.attachDiskInstances({
802 project: env.project,
803 zone,
804 instance: instanceName,
805 body: desiredAttachedDisk(disk),
806 }),
807 );
808 }
809 if (
810 observedDisks.some((disk) => !desiredKeys.has(diskKey(disk.source))) ||
811 desiredDisks.some((disk) => !observedKeys.has(diskKey(disk.source)))
812 ) {
813 current =
814 (yield* getByName(env.project, zone, instanceName)) ?? current;
815 }
816
817 const desiredProtection = news.deletionProtection === true;
818 if ((current.deletionProtection === true) !== desiredProtection) {
819 yield* applyZoneOp(
820 env.project,
821 zone,
822 compute.setDeletionProtectionInstances({
823 project: env.project,
824 zone,
825 resource: instanceName,
826 deletionProtection: desiredProtection,
827 }),
828 );
829 current =
830 (yield* getByName(env.project, zone, instanceName)) ?? current;
831 }
832
833 current = yield* waitUntilSettled(env.project, zone, instanceName);
834 return toAttrs(current, env.project);
835 }),
836
837 delete: Effect.fn(function* ({ output, force }) {
838 const env = yield* GcpEnvironment.current;
839 const zone = lastSegment(output.zone);
840 const instance = output.instanceName;
841 if (output.deletionProtection === true || force === true) {
842 yield* applyZoneOp(
843 env.project,
844 zone,
845 compute.setDeletionProtectionInstances({
846 project: env.project,
847 zone,
848 resource: instance,
849 deletionProtection: false,
850 }),
851 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
852 }
853 yield* applyZoneOp(
854 env.project,
855 zone,
856 compute.deleteInstances({
857 project: env.project,
858 zone,
859 instance,
860 }),
861 { ignore: ["RESOURCE_NOT_FOUND"] },
862 ).pipe(
863 Effect.retry({
864 while: (error) =>
865 error._tag === "Conflict" ||
866 (error._tag === "GCP.OperationFailed" &&
867 error.reason === "RESOURCE_NOT_READY"),
868 times: 8,
869 schedule: Schedule.spaced("3 seconds"),
870 }),
871 Effect.catchTag("NotFound", () => Effect.void),
872 );
873 yield* waitUntilGone(env.project, zone, instance);
874 }),
875 });