1import * as tpu from "@distilled.cloud/gcp/tpu_v2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_ACCELERATOR,
19 DEFAULT_NETWORK,
20 DEFAULT_RUNTIME,
21 fieldMask,
22 lastSegment,
23 listLabeledPages,
24 mapKey,
25 normalizeLocation,
26 parseName,
27 replaceOnIdentity,
28 ResourceNotResolved,
29 stringMapOf,
30 stringsKey,
31 stringsOf,
32 toPhysicalId,
33 userLabels,
34 waitForOperation,
35 waitUntilExists,
36 waitUntilGone,
37 waitUntilReady,
38} from "./internal.ts";
39
40export type NetworkConfig = {
41 /**
42 * Associate external IPs with TPU workers. When false, the network or
43 * subnetwork must have Private Google Access.
44 * @default true
45 */
46 enableExternalIps?: boolean;
47 /**
48 * Subnetwork name or path. Defaults to `default`.
49 */
50 subnetwork?: string;
51 /**
52 * VPC network name or path. Defaults to `default`.
53 * @default "default"
54 */
55 network?: string;
56 /**
57 * Allow the TPU to send/receive packets with non-matching IPs (required
58 * to forward routes).
59 */
60 canIpForward?: boolean;
61 /**
62 * Networking queue count for the TPU VM NIC.
63 */
64 queueCount?: number;
65};
66
67export type AcceleratorConfig = {
68 /**
69 * TPU generation (`V2`, `V3`, `V4`, `V5LITE_POD`, `V5P`, `V6E`).
70 */
71 type?: tpu.AcceleratorConfigTypeEnum | (string & {});
72 /**
73 * Chip topology (e.g. `2x2` for v2-8).
74 */
75 topology?: string;
76};
77
78export type ServiceAccount = {
79 /**
80 * Service account email. Omit to use the default Compute service
81 * account.
82 */
83 email?: string;
84 /**
85 * OAuth scopes. Omit to allow all Cloud APIs.
86 */
87 scope?: string[];
88};
89
90export type SchedulingConfig = {
91 /** Provision under a reservation. */
92 reserved?: boolean;
93 /** Provision as a Spot VM. Immutable. */
94 spot?: boolean;
95 /** Provision as preemptible. Immutable. */
96 preemptible?: boolean;
97};
98
99export type AttachedDisk = {
100 /**
101 * Existing disk path
102 * (`projects/{project}/zones/{zone}/disks/{disk}`).
103 */
104 sourceDisk?: string;
105 /**
106 * Attach mode. Defaults to `READ_WRITE`.
107 */
108 mode?: tpu.AttachedDiskModeEnum | (string & {});
109};
110
111export type BootDiskConfig = {
112 /** Customer-managed KMS key for the boot disk. */
113 customerEncryptionKey?: {
114 /** Cloud KMS crypto key resource name. */
115 kmsKeyName?: string;
116 };
117};
118
119export type ShieldedInstanceConfig = {
120 /** Enable Secure Boot. */
121 enableSecureBoot?: boolean;
122};
123
124export type NodeProps = {
125 /**
126 * Node id (the `{node}` segment of
127 * `projects/{project}/locations/{location}/nodes/{node}`).
128 * If omitted, a unique RFC1035 name is generated from the stack, stage,
129 * and logical id. Must be 1-63 characters, start with a letter, and end
130 * with a letter or digit. Immutable — changing it replaces the node.
131 */
132 nodeId?: string;
133 /**
134 * Zone (`us-central1-c`, `us-central1-b`, …). TPU nodes are zonal.
135 * Immutable — changing it replaces the node. `US-CENTRAL1-C` is
136 * accepted and normalized to `us-central1-c`.
137 * @default "us-central1-c"
138 */
139 location?: string;
140 /**
141 * Runtime version running on the node (e.g. `tpu-ubuntu2204-base`).
142 * Immutable — changing it replaces the node.
143 * @default "tpu-ubuntu2204-base"
144 */
145 runtimeVersion?: string;
146 /**
147 * Accelerator type (`v2-8`, `v3-8`, `v5litepod-8`, …). Mutually
148 * exclusive with `acceleratorConfig`. Immutable. If neither is set,
149 * defaults to `v2-8`.
150 */
151 acceleratorType?: string;
152 /**
153 * Accelerator generation and chip topology. Mutually exclusive with
154 * `acceleratorType`. Immutable.
155 */
156 acceleratorConfig?: AcceleratorConfig;
157 /**
158 * Human-readable description (512 characters or less).
159 */
160 description?: string;
161 /**
162 * User labels. Alchemy ownership labels are merged in automatically.
163 */
164 labels?: Record<string, string>;
165 /**
166 * Custom VM metadata (`startup-script`, `shutdown-script`, …).
167 */
168 metadata?: Record<string, string>;
169 /**
170 * Network tags applied to the TPU VM (firewall source/target tags).
171 */
172 networkTags?: string[];
173 /**
174 * CIDR `/29` the node uses when picking an IP. Immutable.
175 */
176 cidrBlock?: string;
177 /**
178 * Service account the TPU VMs run as. Immutable.
179 */
180 serviceAccount?: ServiceAccount;
181 /**
182 * Single-NIC network configuration. Mutually exclusive with
183 * `networkConfigs`. `enableExternalIps` updates in place; the rest is
184 * immutable.
185 */
186 networkConfig?: NetworkConfig;
187 /**
188 * Multi-NIC network configurations. Mutually exclusive with
189 * `networkConfig`. Immutable.
190 */
191 networkConfigs?: NetworkConfig[];
192 /**
193 * Boot disk encryption. Immutable.
194 */
195 bootDiskConfig?: BootDiskConfig;
196 /**
197 * Additional data disks. Immutable.
198 */
199 dataDisks?: AttachedDisk[];
200 /**
201 * Scheduling (reservation, Spot, preemptible). Immutable.
202 */
203 schedulingConfig?: SchedulingConfig;
204 /**
205 * Shielded VM options. Immutable.
206 */
207 shieldedInstanceConfig?: ShieldedInstanceConfig;
208};
209
210export type Node = Resource<
211 "GCP.TPU.Node",
212 NodeProps,
213 {
214 /** Full resource name `projects/{project}/locations/{location}/nodes/{node}`. */
215 name: string;
216 /** Node id (last path segment). */
217 nodeId: string;
218 /** Project id. */
219 project: string;
220 /** Zone id (`us-central1-c`, …). */
221 location: string;
222 /** Server-assigned numeric id. */
223 tpuId: string | undefined;
224 /** Human-readable description. */
225 description: string | undefined;
226 /** User labels (Alchemy ownership labels stripped). */
227 labels: Record<string, string>;
228 /** Custom VM metadata. */
229 metadata: Record<string, string>;
230 /** Network tags. */
231 networkTags: string[];
232 /** Accelerator type (`v2-8`, …). */
233 acceleratorType: string | undefined;
234 /** Accelerator config currently applied. */
235 acceleratorConfig: AcceleratorConfig | undefined;
236 /** Runtime version. */
237 runtimeVersion: string | undefined;
238 /** Server-reported state (`READY`, `CREATING`, …). */
239 state: string | undefined;
240 /** Health (`HEALTHY`, `TIMEOUT`, …). */
241 health: string | undefined;
242 /** Why the node is unhealthy, if populated. */
243 healthDescription: string | undefined;
244 /** CIDR `/29` in use. */
245 cidrBlock: string | undefined;
246 /** Service account. */
247 serviceAccount: ServiceAccount | undefined;
248 /** Single-NIC network configuration. */
249 networkConfig: NetworkConfig | undefined;
250 /** Multi-NIC network configurations. */
251 networkConfigs: NetworkConfig[];
252 /** Worker network endpoints. */
253 networkEndpoints: tpu.NetworkEndpoint[];
254 /** Boot disk configuration. */
255 bootDiskConfig: BootDiskConfig | undefined;
256 /** Attached data disks. */
257 dataDisks: AttachedDisk[];
258 /** Scheduling options. */
259 schedulingConfig: SchedulingConfig | undefined;
260 /** Shielded VM options. */
261 shieldedInstanceConfig: ShieldedInstanceConfig | undefined;
262 /** Whether this node is part of a Multislice group. */
263 multisliceNode: boolean | undefined;
264 /** QueuedResource that requested this node, if any. */
265 queuedResource: string | undefined;
266 /** API version that created the node. */
267 apiVersion: string | undefined;
268 /** Symptoms reported on the node. */
269 symptoms: tpu.Symptom[];
270 /** Upcoming maintenance, if any. */
271 upcomingMaintenance: tpu.UpcomingMaintenance | undefined;
272 /** RFC3339 creation timestamp. */
273 createTime: string | undefined;
274 },
275 never,
276 Providers
277>;
278
279/**
280 * A Cloud TPU node (TPU VM).
281 *
282 * Changing `nodeId`, `location`, `runtimeVersion`, `acceleratorType`,
283 * `acceleratorConfig`, `cidrBlock`, `serviceAccount`, scheduling,
284 * boot/data disks, shielded config, or network identity (other than
285 * `enableExternalIps`) replaces the node. Description, labels, metadata,
286 * network tags, and `networkConfig.enableExternalIps` update in place.
287 *
288 * Provisioning typically takes several minutes and requires Cloud TPU
289 * quota in the zone.
290 *
291 * ### Creating a Node
292 * **Example:** Generated name, v2-8
293 * ```typescript
294 * const tpu = yield* GCP.TPU.Node("Trainer", {});
295 * ```
296 *
297 * **Example:** Explicit id, labels, and description
298 * ```typescript
299 * const tpu = yield* GCP.TPU.Node("Trainer", {
300 * nodeId: "app-tpu",
301 * location: "us-central1-c",
302 * acceleratorType: "v2-8",
303 * runtimeVersion: "tpu-ubuntu2204-base",
304 * description: "training node",
305 * labels: { env: "prod" },
306 * });
307 * ```
308 *
309 * ### Spot scheduling
310 * **Example:** Preemptible Spot TPU
311 * ```typescript
312 * const tpu = yield* GCP.TPU.Node("Trainer", {
313 * acceleratorType: "v2-8",
314 * schedulingConfig: { spot: true, preemptible: true },
315 * });
316 * ```
317 *
318 * @resource
319 * @category TPU
320 */
321export const Node = Resource<Node>("GCP.TPU.Node");
322
323const resourceName = (project: string, location: string, nodeId: string) =>
324 `projects/${project}/locations/${location}/nodes/${nodeId}`;
325
326const networkOf = (
327 config: tpu.NetworkConfig | NetworkConfig | undefined,
328): NetworkConfig | undefined => {
329 if (config === undefined) return undefined;
330 return {
331 enableExternalIps: config.enableExternalIps,
332 subnetwork: config.subnetwork,
333 network: config.network,
334 canIpForward: config.canIpForward,
335 queueCount: config.queueCount,
336 };
337};
338
339const networksOf = (
340 configs: ReadonlyArray<tpu.NetworkConfig | NetworkConfig> | undefined,
341): NetworkConfig[] => (configs ?? []).map((config) => networkOf(config)!);
342
343const networkIdentityKey = (config: NetworkConfig | undefined) =>
344 JSON.stringify({
345 network: lastSegment(config?.network ?? DEFAULT_NETWORK),
346 subnetwork: lastSegment(config?.subnetwork ?? ""),
347 canIpForward: config?.canIpForward === true,
348 queueCount: config?.queueCount ?? "",
349 });
350
351const networksIdentityKey = (configs: readonly NetworkConfig[] | undefined) =>
352 JSON.stringify((configs ?? []).map(networkIdentityKey));
353
354const acceleratorOf = (
355 config: tpu.AcceleratorConfig | AcceleratorConfig | undefined,
356): AcceleratorConfig | undefined => {
357 if (config === undefined) return undefined;
358 if (config.type === undefined && config.topology === undefined) {
359 return undefined;
360 }
361 return { type: config.type, topology: config.topology };
362};
363
364const acceleratorKey = (
365 type: string | undefined,
366 config: AcceleratorConfig | undefined,
367) =>
368 JSON.stringify({
369 type: (type ?? "").toLowerCase(),
370 configType: (config?.type ?? "").toUpperCase(),
371 topology: config?.topology ?? "",
372 });
373
374const serviceAccountOf = (
375 account: tpu.ServiceAccount | ServiceAccount | undefined,
376): ServiceAccount | undefined => {
377 if (account === undefined) return undefined;
378 return {
379 email: account.email,
380 scope: account.scope ? [...account.scope] : undefined,
381 };
382};
383
384const serviceAccountKey = (
385 account: tpu.ServiceAccount | ServiceAccount | undefined,
386) =>
387 JSON.stringify({
388 email: serviceAccountOf(account)?.email ?? "",
389 scope: [...(serviceAccountOf(account)?.scope ?? [])].sort(),
390 });
391
392const schedulingOf = (
393 config: tpu.SchedulingConfig | SchedulingConfig | undefined,
394): SchedulingConfig | undefined => {
395 if (config === undefined) return undefined;
396 return {
397 reserved: config.reserved,
398 spot: config.spot,
399 preemptible: config.preemptible,
400 };
401};
402
403const schedulingKey = (
404 config: tpu.SchedulingConfig | SchedulingConfig | undefined,
405) =>
406 JSON.stringify({
407 reserved: schedulingOf(config)?.reserved === true,
408 spot: schedulingOf(config)?.spot === true,
409 preemptible: schedulingOf(config)?.preemptible === true,
410 });
411
412const diskOf = (disk: tpu.AttachedDisk | AttachedDisk): AttachedDisk => ({
413 sourceDisk: disk.sourceDisk,
414 mode: disk.mode,
415});
416
417const disksKey = (
418 disks: ReadonlyArray<tpu.AttachedDisk | AttachedDisk> | undefined,
419) =>
420 JSON.stringify(
421 (disks ?? []).map((disk) => ({
422 sourceDisk: disk.sourceDisk ?? "",
423 mode: (disk.mode ?? "READ_WRITE").toUpperCase(),
424 })),
425 );
426
427const bootOf = (
428 config: tpu.BootDiskConfig | BootDiskConfig | undefined,
429): BootDiskConfig | undefined => {
430 if (config === undefined) return undefined;
431 const key = config.customerEncryptionKey?.kmsKeyName;
432 if (key === undefined) return undefined;
433 return { customerEncryptionKey: { kmsKeyName: key } };
434};
435
436const bootKey = (config: tpu.BootDiskConfig | BootDiskConfig | undefined) =>
437 bootOf(config)?.customerEncryptionKey?.kmsKeyName ?? "";
438
439const shieldedOf = (
440 config: tpu.ShieldedInstanceConfig | ShieldedInstanceConfig | undefined,
441): ShieldedInstanceConfig | undefined => {
442 if (config === undefined) return undefined;
443 return { enableSecureBoot: config.enableSecureBoot };
444};
445
446const shieldedKey = (
447 config: tpu.ShieldedInstanceConfig | ShieldedInstanceConfig | undefined,
448) => JSON.stringify(shieldedOf(config)?.enableSecureBoot === true);
449
450const runtimeOf = (version: string | undefined) => version ?? DEFAULT_RUNTIME;
451
452const desiredAcceleratorType = (news: NodeProps) =>
453 news.acceleratorConfig === undefined
454 ? (news.acceleratorType ?? DEFAULT_ACCELERATOR)
455 : news.acceleratorType;
456
457export const toNodeBody = (
458 news: NodeProps,
459 desiredLabels: Record<string, string>,
460 options?: { description?: string },
461): tpu.Node => {
462 const runtimeVersion = runtimeOf(news.runtimeVersion);
463 const acceleratorType = desiredAcceleratorType(news);
464 const networkConfigs = news.networkConfigs;
465 const networkConfig =
466 networkConfigs === undefined
467 ? (news.networkConfig ?? {
468 enableExternalIps: true,
469 network: DEFAULT_NETWORK,
470 })
471 : undefined;
472 return {
473 description: options?.description ?? news.description,
474 labels: desiredLabels,
475 metadata: news.metadata,
476 tags: news.networkTags,
477 runtimeVersion,
478 acceleratorType,
479 acceleratorConfig: news.acceleratorConfig,
480 cidrBlock: news.cidrBlock,
481 serviceAccount: news.serviceAccount,
482 networkConfig,
483 networkConfigs,
484 bootDiskConfig: news.bootDiskConfig,
485 dataDisks: news.dataDisks,
486 schedulingConfig: news.schedulingConfig,
487 shieldedInstanceConfig: news.shieldedInstanceConfig,
488 };
489};
490
491const toAttrs = (node: tpu.Node, project: string) => {
492 const name = node.name ?? "";
493 const parsed = parseName(name, "nodes");
494 return {
495 name,
496 nodeId: parsed.id,
497 project: parsed.project || project,
498 location: parsed.location,
499 tpuId: node.id,
500 description: node.description,
501 labels: userLabels(node.labels),
502 metadata: stringMapOf(node.metadata),
503 networkTags: stringsOf(node.tags),
504 acceleratorType: node.acceleratorType,
505 acceleratorConfig: acceleratorOf(node.acceleratorConfig),
506 runtimeVersion: node.runtimeVersion,
507 state: node.state,
508 health: node.health,
509 healthDescription: node.healthDescription,
510 cidrBlock: node.cidrBlock,
511 serviceAccount: serviceAccountOf(node.serviceAccount),
512 networkConfig: networkOf(node.networkConfig),
513 networkConfigs: networksOf(node.networkConfigs),
514 networkEndpoints: node.networkEndpoints ?? [],
515 bootDiskConfig: bootOf(node.bootDiskConfig),
516 dataDisks: (node.dataDisks ?? []).map(diskOf),
517 schedulingConfig: schedulingOf(node.schedulingConfig),
518 shieldedInstanceConfig: shieldedOf(node.shieldedInstanceConfig),
519 multisliceNode: node.multisliceNode,
520 queuedResource: node.queuedResource,
521 apiVersion: node.apiVersion,
522 symptoms: node.symptoms ?? [],
523 upcomingMaintenance: node.upcomingMaintenance,
524 createTime: node.createTime,
525 };
526};
527
528const isPlaceholder = (node: tpu.Node) => {
529 const name = node.name ?? "";
530 return (
531 name.length === 0 || name.endsWith("/nodes/-") || name.endsWith("/nodes/")
532 );
533};
534
535const getByName = (name: string) =>
536 tpu
537 .getProjectsLocationsNodes({ name })
538 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
539
540export const NodeProvider = () =>
541 Provider.succeed(Node, {
542 stables: ["name", "nodeId", "project", "location", "tpuId", "createTime"],
543
544 diff: Effect.fn(function* ({ news, olds, output }) {
545 if (!isResolved(news)) return undefined;
546
547 const previousId = olds?.nodeId ?? output?.nodeId;
548 const nextId = news.nodeId ?? previousId;
549 const previousLocation = normalizeLocation(
550 olds?.location ?? output?.location,
551 );
552 const nextLocation = normalizeLocation(news.location ?? output?.location);
553 const previousRuntime = runtimeOf(
554 olds?.runtimeVersion ?? output?.runtimeVersion,
555 );
556 const nextRuntime = runtimeOf(
557 news.runtimeVersion ?? olds?.runtimeVersion ?? output?.runtimeVersion,
558 );
559 const previousAccelerator = acceleratorKey(
560 olds?.acceleratorType ?? output?.acceleratorType,
561 acceleratorOf(olds?.acceleratorConfig ?? output?.acceleratorConfig),
562 );
563 const nextAccelerator = acceleratorKey(
564 news.acceleratorType ??
565 olds?.acceleratorType ??
566 output?.acceleratorType,
567 acceleratorOf(
568 news.acceleratorConfig ??
569 olds?.acceleratorConfig ??
570 output?.acceleratorConfig,
571 ),
572 );
573 const previousCidr = olds?.cidrBlock ?? output?.cidrBlock ?? "";
574 const nextCidr = news.cidrBlock ?? previousCidr;
575 const previousAccount = serviceAccountKey(
576 olds?.serviceAccount ?? output?.serviceAccount,
577 );
578 const nextAccount = serviceAccountKey(
579 news.serviceAccount ?? olds?.serviceAccount ?? output?.serviceAccount,
580 );
581 const previousScheduling = schedulingKey(
582 olds?.schedulingConfig ?? output?.schedulingConfig,
583 );
584 const nextScheduling = schedulingKey(
585 news.schedulingConfig ??
586 olds?.schedulingConfig ??
587 output?.schedulingConfig,
588 );
589 const previousBoot = bootKey(
590 olds?.bootDiskConfig ?? output?.bootDiskConfig,
591 );
592 const nextBoot = bootKey(
593 news.bootDiskConfig ?? olds?.bootDiskConfig ?? output?.bootDiskConfig,
594 );
595 const previousDisks = disksKey(olds?.dataDisks ?? output?.dataDisks);
596 const nextDisks = disksKey(
597 news.dataDisks ?? olds?.dataDisks ?? output?.dataDisks,
598 );
599 const previousShielded = shieldedKey(
600 olds?.shieldedInstanceConfig ?? output?.shieldedInstanceConfig,
601 );
602 const nextShielded = shieldedKey(
603 news.shieldedInstanceConfig ??
604 olds?.shieldedInstanceConfig ??
605 output?.shieldedInstanceConfig,
606 );
607 const previousMulti = networksIdentityKey(
608 olds?.networkConfigs ?? output?.networkConfigs,
609 );
610 const nextMulti = networksIdentityKey(
611 news.networkConfigs ?? olds?.networkConfigs ?? output?.networkConfigs,
612 );
613 const previousNet = networkIdentityKey(
614 olds?.networkConfig ?? output?.networkConfig,
615 );
616 const nextNet = networkIdentityKey(
617 news.networkConfig ?? olds?.networkConfig ?? output?.networkConfig,
618 );
619 const usedMulti =
620 (news.networkConfigs ?? olds?.networkConfigs ?? output?.networkConfigs)
621 ?.length ?? 0;
622
623 return replaceOnIdentity({
624 previousId,
625 nextId,
626 previousLocation,
627 nextLocation,
628 extra:
629 previousRuntime !== nextRuntime ||
630 previousAccelerator !== nextAccelerator ||
631 previousCidr !== nextCidr ||
632 previousAccount !== nextAccount ||
633 previousScheduling !== nextScheduling ||
634 previousBoot !== nextBoot ||
635 previousDisks !== nextDisks ||
636 previousShielded !== nextShielded ||
637 previousMulti !== nextMulti ||
638 (usedMulti === 0 && previousNet !== nextNet),
639 });
640 }),
641
642 read: Effect.fn(function* ({ id, olds, output }) {
643 const env = yield* GcpEnvironment.current;
644 const nodeId = yield* toPhysicalId(id, olds?.nodeId, output?.nodeId);
645 const location = normalizeLocation(olds?.location ?? output?.location);
646 const name = output?.name ?? resourceName(env.project, location, nodeId);
647 const existing = yield* getByName(name);
648 if (existing === undefined) return undefined;
649 const attrs = toAttrs(existing, env.project);
650 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
651 ? attrs
652 : Unowned(attrs);
653 }),
654
655 list: () =>
656 Effect.gen(function* () {
657 const env = yield* GcpEnvironment.current;
658 const nodes = yield* listLabeledPages(
659 tpu.listProjectsLocationsNodes.pages({
660 parent: `projects/${env.project}/locations/-`,
661 pageSize: 1000,
662 }),
663 (page) => page.nodes,
664 (node) => node.labels,
665 );
666 return nodes
667 .filter((node) => !isPlaceholder(node))
668 .map((node) => toAttrs(node, env.project));
669 }),
670
671 reconcile: Effect.fn(function* ({ id, news, output }) {
672 const env = yield* GcpEnvironment.current;
673 const nodeId = yield* toPhysicalId(id, news.nodeId, output?.nodeId);
674 const location = normalizeLocation(news.location ?? output?.location);
675 const name = resourceName(env.project, location, nodeId);
676 const desiredLabels = {
677 ...toLabels(news.labels),
678 ...(yield* createInternalLabels(id)),
679 };
680
681 let current = yield* getByName(output?.name ?? name);
682
683 if (current === undefined) {
684 const created = yield* tpu
685 .createProjectsLocationsNodes({
686 parent: `projects/${env.project}/locations/${location}`,
687 nodeId,
688 body: toNodeBody(news, desiredLabels),
689 })
690 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
691 if (created !== undefined) {
692 yield* waitForOperation(created);
693 }
694 current = yield* waitUntilExists(getByName(name), name);
695 }
696
697 if (current === undefined) {
698 return yield* new ResourceNotResolved({ name });
699 }
700
701 const state = (current.state ?? "").toUpperCase();
702 if (state !== "READY" && state !== "STOPPED" && state.length > 0) {
703 current = yield* waitUntilReady(
704 getByName(name),
705 name,
706 (node) => node.state,
707 (node) => node.healthDescription,
708 );
709 }
710
711 if (current === undefined) {
712 return yield* new ResourceNotResolved({ name });
713 }
714
715 const observedLabels = tagRecord(current.labels);
716 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
717 const labelsChanged = upsert.length > 0 || removed.length > 0;
718 const descriptionChanged =
719 (current.description ?? "") !== (news.description ?? "");
720 const metadataChanged =
721 news.metadata !== undefined &&
722 mapKey(current.metadata) !== mapKey(news.metadata);
723 const tagsChanged =
724 news.networkTags !== undefined &&
725 stringsKey(current.tags) !== stringsKey(news.networkTags);
726 const desiredExternal = news.networkConfig?.enableExternalIps !== false;
727 const observedExternal =
728 current.networkConfig?.enableExternalIps !== false;
729 const usingMulti =
730 (news.networkConfigs ?? current.networkConfigs ?? []).length > 0;
731 const externalChanged =
732 !usingMulti &&
733 news.networkConfig !== undefined &&
734 desiredExternal !== observedExternal;
735
736 if (
737 labelsChanged ||
738 descriptionChanged ||
739 metadataChanged ||
740 tagsChanged ||
741 externalChanged
742 ) {
743 const updateMask = fieldMask([
744 labelsChanged && "labels",
745 descriptionChanged && "description",
746 metadataChanged && "metadata",
747 tagsChanged && "tags",
748 externalChanged && "network_config.enable_external_ips",
749 ]);
750 const patched = yield* tpu.patchProjectsLocationsNodes({
751 name,
752 updateMask,
753 body: {
754 name,
755 labels: desiredLabels,
756 description: news.description,
757 metadata: news.metadata ?? stringMapOf(current.metadata),
758 tags: news.networkTags ?? stringsOf(current.tags),
759 networkConfig: externalChanged
760 ? {
761 ...networkOf(current.networkConfig),
762 enableExternalIps: desiredExternal,
763 }
764 : undefined,
765 },
766 });
767 yield* waitForOperation(patched);
768 current = yield* waitUntilExists(getByName(name), name);
769 }
770
771 if (current === undefined) {
772 return yield* new ResourceNotResolved({ name });
773 }
774
775 return toAttrs(current, env.project);
776 }),
777
778 delete: Effect.fn(function* ({ output }) {
779 const operation = yield* tpu
780 .deleteProjectsLocationsNodes({ name: output.name })
781 .pipe(
782 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
783 Effect.retry({
784 while: (error) => error._tag === "Conflict",
785 times: 8,
786 schedule: Schedule.spaced("5 seconds"),
787 }),
788 );
789 if (operation !== undefined) {
790 yield* waitForOperation(operation, { notFoundOk: true });
791 }
792 yield* waitUntilGone(getByName(output.name), output.name);
793 }),
794 });