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_ENABLE: compute.PacketMirroringEnableEnum = "TRUE";
21const DEFAULT_PRIORITY = 1000;
22const DEFAULT_DIRECTION: compute.PacketMirroringFilterDirectionEnum = "BOTH";
23const MAX_NAME_LENGTH = 63;
24
25export type PacketMirroringEnable = compute.PacketMirroringEnableEnum;
26
27export type PacketMirroringFilterDirection =
28 compute.PacketMirroringFilterDirectionEnum;
29
30export type PacketMirroringFilter = {
31 /**
32 * Traffic direction to copy (`INGRESS`, `EGRESS`, or `BOTH`).
33 * @default "BOTH"
34 */
35 direction?: PacketMirroringFilterDirection | (string & {});
36 /**
37 * IP protocols to copy (`tcp`, `udp`, `icmp`, `esp`, …). Empty (the
38 * default) copies every protocol that matches `cidrRanges`.
39 */
40 ipProtocols?: string[];
41 /**
42 * IPv4 or IPv6 CIDR ranges matched against the source (ingress) or
43 * destination (egress) address. Empty (the default) copies every IPv4
44 * packet that matches `ipProtocols`. Use `0.0.0.0/0,::/0` to copy both
45 * IPv4 and IPv6.
46 */
47 cidrRanges?: string[];
48};
49
50export type PacketMirroringMirroredResources = {
51 /**
52 * Subnetwork names or URLs whose VMs are mirrored (max 5). Subnets must
53 * live in the same region and VPC as this policy.
54 */
55 subnetworks?: string[];
56 /**
57 * Instance names or URLs to mirror (max 50). Instances must live in a
58 * zone of this policy's region and have a NIC on `network`.
59 */
60 instances?: string[];
61 /**
62 * Network tags. Traffic from/to every VM that has at least one of these
63 * tags is mirrored.
64 */
65 tags?: string[];
66};
67
68export type PacketMirroringProps = {
69 /**
70 * PacketMirroring name (RFC1035, 1-63 characters). If omitted, a unique
71 * name is generated from the stack, stage, and logical id. Immutable —
72 * changing it replaces the policy.
73 */
74 packetMirroringName?: string;
75 /**
76 * Region the policy lives in. Immutable — changing it replaces the
77 * policy. `US-CENTRAL1` is accepted and normalized to `us-central1`.
78 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
79 */
80 region?: string;
81 /**
82 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
83 * prefix so `list` / nuke can find resources (Compute PacketMirroring has
84 * no labels field). Immutable — changing it replaces the policy.
85 */
86 description?: string;
87 /**
88 * Mirrored VPC network name (`default`), partial URL
89 * (`global/networks/default`), or self-link. Only packets on this
90 * network are copied. Immutable — changing it replaces the policy.
91 */
92 network: string;
93 /**
94 * Collector internal passthrough Network Load Balancer: a regional
95 * forwarding-rule name, path, or URL whose `loadBalancingScheme` is
96 * `INTERNAL` and `isMirroringCollector` is `true`. Updated in place.
97 */
98 collectorIlb: string;
99 /**
100 * Mirrored sources — at least one of `tags`, `instances`, or
101 * `subnetworks`. Updated in place.
102 */
103 mirroredResources: PacketMirroringMirroredResources;
104 /**
105 * Whether the policy is enforced. `false` / `"FALSE"` leaves the
106 * resource in place but stops copying packets.
107 * @default true
108 */
109 enable?: boolean | PacketMirroringEnable | (string & {});
110 /**
111 * Tie-breaker when two policies match the same VM. Lower wins.
112 * Valid range is `0`–`65535`.
113 * @default 1000
114 */
115 priority?: number;
116 /**
117 * Optional traffic filter. When omitted, every IPv4 packet is copied.
118 * Updated in place.
119 */
120 filter?: PacketMirroringFilter;
121};
122
123export type PacketMirroring = Resource<
124 "GCP.Compute.PacketMirroring",
125 PacketMirroringProps,
126 {
127 /** PacketMirroring name. */
128 packetMirroringName: string;
129 /** Project id. */
130 project: string;
131 /** Region short name (`us-central1`). */
132 region: string;
133 /** User description with the Alchemy ownership prefix stripped. */
134 description: string | undefined;
135 /** Mirrored VPC network URL. */
136 network: string;
137 /** Server-canonical network URL, if reported. */
138 networkCanonicalUrl: string | undefined;
139 /** Collector forwarding-rule URL. */
140 collectorIlb: string;
141 /** Server-canonical collector URL, if reported. */
142 collectorIlbCanonicalUrl: string | undefined;
143 /** Mirrored sources currently configured. */
144 mirroredResources: {
145 subnetworks: ReadonlyArray<string>;
146 instances: ReadonlyArray<string>;
147 tags: ReadonlyArray<string>;
148 };
149 /** Whether the policy is enforced. */
150 enable: boolean;
151 /** Tie-breaker priority. */
152 priority: number;
153 /** Traffic filter, if set. */
154 filter:
155 | {
156 direction: string | undefined;
157 ipProtocols: ReadonlyArray<string>;
158 cidrRanges: ReadonlyArray<string>;
159 }
160 | undefined;
161 /** Server-defined URL. */
162 selfLink: string | undefined;
163 /** Server-assigned numeric id. */
164 packetMirroringId: string | undefined;
165 /** RFC3339 creation timestamp. */
166 creationTimestamp: string | undefined;
167 /** Resource kind. */
168 kind: string | undefined;
169 },
170 never,
171 Providers
172>;
173
174/**
175 * A regional Compute Engine packet mirroring policy.
176 *
177 * Packet Mirroring copies traffic from selected VMs, subnets, or network
178 * tags in a VPC and sends it to a collector internal passthrough Network
179 * Load Balancer (`ForwardingRule` with `isMirroringCollector: true`).
180 * Compute PacketMirroring has no labels field — Alchemy ownership is
181 * stored in the description so nuke can find leaked resources.
182 *
183 * Name, region, network, and description are immutable; collector ILB,
184 * mirrored sources, filter, enable, and priority update in place.
185 *
186 * ### Creating a Packet Mirroring Policy
187 * **Example:** Mirror VMs by network tag
188 * ```typescript
189 * const policy = yield* GCP.Compute.PacketMirroring("Capture", {
190 * network: vpc.selfLink,
191 * collectorIlb: collector.selfLink,
192 * mirroredResources: { tags: ["mirror-me"] },
193 * });
194 * ```
195 *
196 * **Example:** Named policy with a traffic filter
197 * ```typescript
198 * const policy = yield* GCP.Compute.PacketMirroring("Capture", {
199 * packetMirroringName: "app-capture",
200 * region: "us-central1",
201 * description: "tcp to the collector",
202 * network: "default",
203 * collectorIlb: collector.selfLink,
204 * mirroredResources: {
205 * subnetworks: [subnet.selfLink],
206 * tags: ["web"],
207 * },
208 * filter: {
209 * direction: "BOTH",
210 * ipProtocols: ["tcp"],
211 * cidrRanges: ["0.0.0.0/0"],
212 * },
213 * priority: 800,
214 * });
215 * ```
216 *
217 * ### Updating a Policy
218 * **Example:** Disable mirroring without deleting the policy
219 * ```typescript
220 * const policy = yield* GCP.Compute.PacketMirroring("Capture", {
221 * packetMirroringName: "app-capture",
222 * network: vpc.selfLink,
223 * collectorIlb: collector.selfLink,
224 * mirroredResources: { tags: ["mirror-me"] },
225 * enable: false,
226 * });
227 * ```
228 *
229 * @resource
230 * @category Compute
231 */
232export const PacketMirroring = Resource<PacketMirroring>(
233 "GCP.Compute.PacketMirroring",
234);
235
236export class PacketMirroringNotResolved extends Data.TaggedError(
237 "GCP.Compute.PacketMirroringNotResolved",
238)<{
239 packetMirroringName: string;
240 region: string;
241}> {}
242
243const lastSegment = (value: string | undefined): string => {
244 if (value === undefined || value.length === 0) return "";
245 const trimmed = value.replace(/\/+$/, "");
246 const parts = trimmed.split("/").filter((part) => part.length > 0);
247 return parts[parts.length - 1] ?? "";
248};
249
250const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
251 lastSegment(region ?? defaultRegion).toLowerCase();
252
253const rfc1035 = (name: string): string => {
254 let next = name
255 .toLowerCase()
256 .replace(/[^a-z0-9-]/g, "-")
257 .replace(/-+/g, "-")
258 .replace(/^-+|-+$/g, "");
259 if (!/^[a-z]/.test(next)) next = `p${next}`;
260 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
261 return next.length > 0 ? next : "packet";
262};
263
264const toName = (id: string, name: string | undefined, existing?: string) =>
265 Effect.gen(function* () {
266 if (name !== undefined) return name;
267 if (existing !== undefined) return existing;
268 return rfc1035(
269 yield* createPhysicalName({
270 id,
271 maxLength: MAX_NAME_LENGTH,
272 lowercase: true,
273 }),
274 );
275 });
276
277const encodeDescription = (
278 labels: Record<string, string>,
279 description: string | undefined,
280): string => {
281 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
282 return description ? `${marker}\n${description}` : marker;
283};
284
285const parseDescription = (
286 description: string | undefined,
287): {
288 labels: Record<string, string>;
289 description: string | undefined;
290} => {
291 if (!description?.startsWith("[alchemy ")) {
292 return { labels: {}, description };
293 }
294 const end = description.indexOf("]");
295 if (end < 0) return { labels: {}, description };
296 const labels: Record<string, string> = {};
297 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
298 const eq = part.indexOf("=");
299 if (eq > 0) {
300 labels[part.slice(0, eq)] = part.slice(eq + 1);
301 }
302 }
303 const rest = description.slice(end + 1).replace(/^\n/, "");
304 return { labels, description: rest.length > 0 ? rest : undefined };
305};
306
307const toNetworkUrl = (project: string, network: string) => {
308 if (network.includes("/")) {
309 return network.startsWith("projects/") || network.startsWith("http")
310 ? network
311 : `projects/${project}/${network.replace(/^\//, "")}`;
312 }
313 return `projects/${project}/global/networks/${network}`;
314};
315
316const toForwardingRuleUrl = (
317 project: string,
318 region: string,
319 collectorIlb: string,
320) => {
321 if (collectorIlb.includes("/")) {
322 return collectorIlb.startsWith("projects/") ||
323 collectorIlb.startsWith("http")
324 ? collectorIlb
325 : `projects/${project}/${collectorIlb.replace(/^\//, "")}`;
326 }
327 return `projects/${project}/regions/${region}/forwardingRules/${collectorIlb}`;
328};
329
330const toSubnetworkUrl = (project: string, region: string, subnet: string) => {
331 if (subnet.includes("/")) {
332 return subnet.startsWith("projects/") || subnet.startsWith("http")
333 ? subnet
334 : `projects/${project}/${subnet.replace(/^\//, "")}`;
335 }
336 return `projects/${project}/regions/${region}/subnetworks/${subnet}`;
337};
338
339const toInstanceUrl = (project: string, instance: string) => {
340 if (instance.includes("/")) {
341 return instance.startsWith("projects/") || instance.startsWith("http")
342 ? instance
343 : `projects/${project}/${instance.replace(/^\//, "")}`;
344 }
345 return instance;
346};
347
348const enableOf = (
349 value: boolean | string | undefined,
350): compute.PacketMirroringEnableEnum =>
351 value === false || value === "FALSE" ? "FALSE" : DEFAULT_ENABLE;
352
353const enableFlag = (value: string | undefined) => enableOf(value) === "TRUE";
354
355const sorted = (values: ReadonlyArray<string> | undefined) =>
356 [...(values ?? [])].map(String).sort();
357
358const urlsKey = (values: ReadonlyArray<string> | undefined) =>
359 sorted((values ?? []).map(lastSegment)).join(",");
360
361const listsKey = (values: ReadonlyArray<string> | undefined) =>
362 sorted(values).join(",");
363
364const toApiMirrored = (
365 project: string,
366 region: string,
367 resources: PacketMirroringMirroredResources,
368): compute.PacketMirroringMirroredResourceInfo => ({
369 tags: resources.tags,
370 subnetworks: resources.subnetworks?.map((url) => ({
371 url: toSubnetworkUrl(project, region, url),
372 })),
373 instances: resources.instances?.map((url) => ({
374 url: toInstanceUrl(project, url),
375 })),
376});
377
378const fromApiMirrored = (
379 resources: compute.PacketMirroringMirroredResourceInfo | undefined,
380) => ({
381 subnetworks: (resources?.subnetworks ?? []).flatMap((item) =>
382 item.url ? [item.url] : [],
383 ),
384 instances: (resources?.instances ?? []).flatMap((item) =>
385 item.url ? [item.url] : [],
386 ),
387 tags: resources?.tags ?? [],
388});
389
390const toApiFilter = (
391 filter: PacketMirroringFilter | undefined,
392): compute.PacketMirroringFilter | undefined => {
393 if (filter === undefined) return undefined;
394 return {
395 direction: filter.direction ?? DEFAULT_DIRECTION,
396 IPProtocols: filter.ipProtocols,
397 cidrRanges: filter.cidrRanges,
398 };
399};
400
401const fromApiFilter = (filter: compute.PacketMirroringFilter | undefined) => {
402 if (filter === undefined) return undefined;
403 const ipProtocols = filter.IPProtocols ?? [];
404 const cidrRanges = filter.cidrRanges ?? [];
405 const direction = filter.direction;
406 if (
407 direction === undefined &&
408 ipProtocols.length === 0 &&
409 cidrRanges.length === 0
410 ) {
411 return undefined;
412 }
413 return {
414 direction,
415 ipProtocols,
416 cidrRanges,
417 };
418};
419
420const filterKey = (filter: PacketMirroringFilter | undefined) => {
421 const direction = (filter?.direction ?? DEFAULT_DIRECTION).toUpperCase();
422 return `${direction}|${listsKey(filter?.ipProtocols)}|${listsKey(filter?.cidrRanges)}`;
423};
424
425const mirroredKey = (resources: PacketMirroringMirroredResources) =>
426 [
427 `tags=${listsKey(resources.tags)}`,
428 `subnets=${urlsKey(resources.subnetworks)}`,
429 `instances=${urlsKey(resources.instances)}`,
430 ].join("|");
431
432const toAttrs = (policy: compute.PacketMirroring, project: string) => {
433 const parsed = parseDescription(policy.description);
434 const mirrored = fromApiMirrored(policy.mirroredResources);
435 return {
436 packetMirroringName: policy.name ?? policy.id ?? "",
437 project,
438 region: lastSegment(policy.region).toLowerCase(),
439 description: parsed.description,
440 network: policy.network?.url ?? "",
441 networkCanonicalUrl: policy.network?.canonicalUrl,
442 collectorIlb: policy.collectorIlb?.url ?? "",
443 collectorIlbCanonicalUrl: policy.collectorIlb?.canonicalUrl,
444 mirroredResources: mirrored,
445 enable: enableFlag(policy.enable),
446 priority: policy.priority ?? DEFAULT_PRIORITY,
447 filter: fromApiFilter(policy.filter),
448 selfLink: policy.selfLink,
449 packetMirroringId: policy.id,
450 creationTimestamp: policy.creationTimestamp,
451 kind: policy.kind,
452 };
453};
454
455const getByName = (project: string, region: string, packetMirroring: string) =>
456 compute
457 .getPacketMirrorings({ project, region, packetMirroring })
458 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
459
460const requirePolicy = (
461 project: string,
462 region: string,
463 packetMirroringName: string,
464) =>
465 getByName(project, region, packetMirroringName).pipe(
466 Effect.flatMap((existing) =>
467 existing !== undefined
468 ? Effect.succeed(existing)
469 : Effect.fail(
470 new PacketMirroringNotResolved({ packetMirroringName, region }),
471 ),
472 ),
473 Effect.retry({
474 while: (error) => error._tag === "GCP.Compute.PacketMirroringNotResolved",
475 schedule: Schedule.spaced("1 second"),
476 times: 8,
477 }),
478 );
479
480export const PacketMirroringProvider = () =>
481 Provider.succeed(PacketMirroring, {
482 stables: [
483 "packetMirroringName",
484 "project",
485 "region",
486 "network",
487 "packetMirroringId",
488 "selfLink",
489 "creationTimestamp",
490 ],
491
492 diff: Effect.fn(function* ({ news, olds, output }) {
493 if (!isResolved(news)) return undefined;
494 const env = yield* GcpEnvironment.current;
495 const previousName =
496 olds?.packetMirroringName ?? output?.packetMirroringName;
497 const nextName = news.packetMirroringName ?? previousName;
498 const previousRegion = normalizeRegion(
499 olds?.region ?? output?.region,
500 env.region,
501 );
502 const nextRegion = normalizeRegion(
503 news.region ?? previousRegion,
504 env.region,
505 );
506 const previousNetwork = lastSegment(olds?.network ?? output?.network);
507 const nextNetwork = lastSegment(news.network);
508 const previousDescription =
509 olds?.description ?? output?.description ?? "";
510 const nextDescription = news.description ?? "";
511
512 const replace =
513 (previousName !== undefined &&
514 nextName !== undefined &&
515 previousName !== nextName) ||
516 previousRegion !== nextRegion ||
517 (previousNetwork.length > 0 &&
518 nextNetwork.length > 0 &&
519 previousNetwork !== nextNetwork) ||
520 previousDescription !== nextDescription;
521
522 if (!replace) return undefined;
523 return {
524 action: "replace" as const,
525 deleteFirst:
526 previousName !== undefined &&
527 nextName !== undefined &&
528 previousName === nextName &&
529 previousRegion === nextRegion,
530 };
531 }),
532
533 read: Effect.fn(function* ({ id, olds, output }) {
534 const env = yield* GcpEnvironment.current;
535 const packetMirroringName = yield* toName(
536 id,
537 olds?.packetMirroringName,
538 output?.packetMirroringName,
539 );
540 const region = normalizeRegion(
541 olds?.region ?? output?.region,
542 env.region,
543 );
544 const existing = yield* getByName(
545 env.project,
546 region,
547 packetMirroringName,
548 );
549 if (existing === undefined) return undefined;
550 const attrs = toAttrs(existing, env.project);
551 const { labels } = parseDescription(existing.description);
552 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
553 }),
554
555 list: () =>
556 Effect.gen(function* () {
557 const env = yield* GcpEnvironment.current;
558 const pages = yield* compute.aggregatedListPacketMirrorings
559 .pages({
560 project: env.project,
561 returnPartialSuccess: true,
562 maxResults: 500,
563 })
564 .pipe(Stream.runCollect);
565 return Array.from(pages).flatMap((page) =>
566 Object.values(page.items ?? {}).flatMap((scoped) =>
567 (scoped?.packetMirrorings ?? [])
568 .filter((item) => {
569 const { labels } = parseDescription(item.description);
570 return Object.keys(labels).some((key) =>
571 key.startsWith("alchemy-"),
572 );
573 })
574 .map((item) => toAttrs(item, env.project)),
575 ),
576 );
577 }),
578
579 reconcile: Effect.fn(function* ({ id, news, output }) {
580 const env = yield* GcpEnvironment.current;
581 const packetMirroringName = yield* toName(
582 id,
583 news.packetMirroringName,
584 output?.packetMirroringName,
585 );
586 const region = normalizeRegion(news.region ?? output?.region, env.region);
587 const ownership = yield* createInternalLabels(id);
588 const desiredDescription = encodeDescription(ownership, news.description);
589 const desiredNetwork = toNetworkUrl(env.project, news.network);
590 const desiredCollector = toForwardingRuleUrl(
591 env.project,
592 region,
593 news.collectorIlb,
594 );
595 const desiredEnable = enableOf(news.enable);
596 const desiredPriority = news.priority ?? DEFAULT_PRIORITY;
597 const desiredMirrored = toApiMirrored(
598 env.project,
599 region,
600 news.mirroredResources,
601 );
602 const desiredFilter = toApiFilter(news.filter);
603
604 let current = yield* getByName(env.project, region, packetMirroringName);
605
606 if (current === undefined) {
607 const inserted = yield* compute
608 .insertPacketMirrorings({
609 project: env.project,
610 region,
611 body: {
612 name: packetMirroringName,
613 description: desiredDescription,
614 enable: desiredEnable,
615 priority: desiredPriority,
616 network: { url: desiredNetwork },
617 collectorIlb: { url: desiredCollector },
618 mirroredResources: desiredMirrored,
619 filter: desiredFilter,
620 },
621 })
622 .pipe(
623 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
624 // A just-created collector forwarding rule is briefly not ready.
625 Effect.retry({
626 while: (error) => error._tag === "CollectorIlbNotReady",
627 times: 8,
628 schedule: Schedule.spaced("3 seconds"),
629 }),
630 );
631 if (inserted !== undefined) {
632 yield* waitRegionOperation(env.project, region, inserted);
633 }
634 current = yield* requirePolicy(
635 env.project,
636 region,
637 packetMirroringName,
638 );
639 }
640
641 if (current === undefined) {
642 return yield* new PacketMirroringNotResolved({
643 packetMirroringName,
644 region,
645 });
646 }
647
648 const observed = toAttrs(current, env.project);
649 const patch: compute.PacketMirroring = {};
650
651 if ((current.description ?? "") !== desiredDescription) {
652 patch.description = desiredDescription;
653 }
654 if (enableOf(current.enable) !== desiredEnable) {
655 patch.enable = desiredEnable;
656 }
657 if (observed.priority !== desiredPriority) {
658 patch.priority = desiredPriority;
659 }
660 if (
661 lastSegment(observed.collectorIlb) !== lastSegment(desiredCollector)
662 ) {
663 patch.collectorIlb = { url: desiredCollector };
664 }
665 if (
666 mirroredKey(observed.mirroredResources) !==
667 mirroredKey(news.mirroredResources)
668 ) {
669 patch.mirroredResources = desiredMirrored;
670 }
671 if (filterKey(observed.filter) !== filterKey(news.filter)) {
672 patch.filter = desiredFilter ?? {
673 direction: DEFAULT_DIRECTION,
674 IPProtocols: [],
675 cidrRanges: [],
676 };
677 }
678
679 if (Object.keys(patch).length > 0) {
680 yield* compute
681 .patchPacketMirrorings({
682 project: env.project,
683 region,
684 packetMirroring: packetMirroringName,
685 body: patch,
686 })
687 .pipe(
688 Effect.flatMap((operation) =>
689 waitRegionOperation(env.project, region, operation),
690 ),
691 Effect.retry({
692 while: (error) => error._tag === "Conflict",
693 times: 5,
694 schedule: Schedule.spaced("2 seconds"),
695 }),
696 );
697 current = yield* requirePolicy(
698 env.project,
699 region,
700 packetMirroringName,
701 );
702 }
703
704 return toAttrs(current, env.project);
705 }),
706
707 delete: Effect.fn(function* ({ output }) {
708 const env = yield* GcpEnvironment.current;
709 const region = normalizeRegion(output.region, env.region);
710 const operation = yield* compute
711 .deletePacketMirrorings({
712 project: env.project,
713 region,
714 packetMirroring: output.packetMirroringName,
715 })
716 .pipe(
717 Effect.retry({
718 while: (error) => error._tag === "Conflict",
719 times: 8,
720 schedule: Schedule.spaced("2 seconds"),
721 }),
722 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
723 );
724 if (operation !== undefined) {
725 yield* waitRegionOperation(env.project, region, operation, {
726 ignore: ["RESOURCE_NOT_FOUND"],
727 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
728 }
729 }),
730 });