1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import { ignoredCodes } from "./internal.ts";
4import { waitOrganizationOperation } from "./operations.ts";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9import { Unowned } from "../../AdoptPolicy.ts";
10import { isResolved } from "../../Diff.ts";
11import { createPhysicalName } from "../../PhysicalName.ts";
12import * as Provider from "../../Provider.ts";
13import { Resource } from "../../Resource.ts";
14import { GcpEnvironment } from "../Environment.ts";
15import {
16 alchemyLabelKeys,
17 createInternalLabels,
18 hasAlchemyLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22const DEFAULT_POLICY_TYPE = "VPC_POLICY";
23const DEFAULT_RULE_PRIORITY = 2147483647;
24const MAX_NAME_LENGTH = 63;
25
26export type FirewallPolicyType =
27 | compute.FirewallPolicyPolicyTypeEnum
28 | (string & {});
29export type FirewallPolicyRule = compute.FirewallPolicyRule;
30export type FirewallPolicyRuleMatcher = compute.FirewallPolicyRuleMatcher;
31export type FirewallPolicyAssociation = compute.FirewallPolicyAssociation;
32
33export type FirewallPolicyProps = {
34 /**
35 * User-provided RFC1035 name (`shortName`). Unique within the parent
36 * organization or folder. If omitted, a unique name is generated from
37 * the stack, stage, and logical id. Immutable — changing it replaces
38 * the policy. After create, GCP also assigns a numeric `name`.
39 */
40 shortName?: string;
41 /**
42 * Parent folder or organization. Format `folders/{folder}` or
43 * `organizations/{organization}`. If omitted, Alchemy uses the project
44 * parent from Cloud Resource Manager. Immutable — changing it replaces
45 * the policy.
46 */
47 parent?: string;
48 /**
49 * Optional description. Hierarchical firewall policies have no labels
50 * field, so Alchemy ownership (`alchemy-stack` / `alchemy-stage` /
51 * `alchemy-id`) is stored in a `[alchemy …]` prefix for `list` / nuke.
52 */
53 description?: string;
54 /**
55 * Policy type. Set only at create time — changing it replaces the
56 * policy.
57 * @default "VPC_POLICY"
58 */
59 policyType?: FirewallPolicyType;
60 /**
61 * Hierarchical firewall rules. GCP always keeps a default rule at
62 * priority `2147483647` matching all traffic (`goto_next` if you do
63 * not supply one). When this field is set, Alchemy syncs rules with
64 * `addRule` / `patchRule` / `removeRule` (not `patch`). When omitted
65 * on later updates, observed rules are left in place.
66 */
67 rules?: FirewallPolicyRule[];
68};
69
70export type FirewallPolicy = Resource<
71 "GCP.Compute.FirewallPolicy",
72 FirewallPolicyProps,
73 {
74 /** User-provided RFC1035 name. */
75 shortName: string;
76 /** Server-assigned numeric policy id (`name` in the API). */
77 firewallPolicyId: string;
78 /** Parent `folders/{folder}` or `organizations/{organization}`. */
79 parent: string;
80 /** Project id of the deploying stack (policies are org/folder scoped). */
81 project: string;
82 /** User description with the Alchemy ownership prefix stripped. */
83 description: string | undefined;
84 /** Policy type (`VPC_POLICY`, `RDMA_ROCE_POLICY`, `ULL_POLICY`). */
85 policyType: string;
86 /** Rules currently attached (including the default). */
87 rules: FirewallPolicyRule[];
88 /** Associations currently attached to this policy. */
89 associations: FirewallPolicyAssociation[];
90 /** Optimistic-locking fingerprint for metadata patches. */
91 fingerprint: string | undefined;
92 /** Server-defined URL. */
93 selfLink: string | undefined;
94 /** Server-defined URL including the numeric id. */
95 selfLinkWithId: string | undefined;
96 /** RFC3339 creation timestamp. */
97 creationTimestamp: string | undefined;
98 /** Total count of rule tuples. */
99 ruleTupleCount: number | undefined;
100 /** Server-assigned numeric id (same as `firewallPolicyId` when present). */
101 id: string | undefined;
102 /** Resource kind. */
103 kind: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A hierarchical firewall policy attached to an organization or folder.
111 *
112 * Policies live under `locations/global/firewallPolicies` and are
113 * identified by a server-assigned numeric id. The user-facing name is
114 * `shortName`. Parent and `shortName` are immutable — changing either
115 * replaces the policy. Description updates in place via
116 * `firewallPolicies.patch`. Rules are synced with `addRule` /
117 * `patchRule` / `removeRule`.
118 *
119 * Compute Engine firewall policies have no resource labels. Alchemy
120 * stamps ownership into the description so `read` / `list` (and
121 * `pnpm nuke:gcp`) can find them.
122 *
123 * ### Creating a Firewall Policy
124 * **Example:** Generated name with an allow rule
125 * ```typescript
126 * const policy = yield* GCP.Compute.FirewallPolicy("OrgFw", {
127 * description: "allow internal http",
128 * rules: [
129 * {
130 * action: "allow",
131 * priority: 1000,
132 * direction: "INGRESS",
133 * match: {
134 * srcIpRanges: ["10.0.0.0/8"],
135 * layer4Configs: [{ ipProtocol: "tcp", ports: ["80"] }],
136 * },
137 * },
138 * ],
139 * });
140 * ```
141 *
142 * **Example:** Named policy under a folder
143 * ```typescript
144 * const policy = yield* GCP.Compute.FirewallPolicy("OrgFw", {
145 * shortName: "app-org-fw",
146 * parent: "folders/123456789",
147 * description: "folder guardrail",
148 * });
149 * ```
150 *
151 * @resource
152 * @category Compute
153 */
154export const FirewallPolicy = Resource<FirewallPolicy>(
155 "GCP.Compute.FirewallPolicy",
156);
157
158export class FirewallPolicyNotResolved extends Data.TaggedError(
159 "GCP.Compute.FirewallPolicyNotResolved",
160)<{
161 firewallPolicyId: string;
162 shortName: string;
163}> {}
164
165export class FirewallPolicyParentRequired extends Data.TaggedError(
166 "GCP.Compute.FirewallPolicyParentRequired",
167)<{
168 project: string;
169}> {}
170
171export class FirewallPolicyStillExists extends Data.TaggedError(
172 "GCP.Compute.FirewallPolicyStillExists",
173)<{
174 firewallPolicyId: string;
175}> {}
176
177const lastSegment = (value: string | undefined): string => {
178 if (value === undefined || value.length === 0) return "";
179 const trimmed = value.replace(/\/+$/, "");
180 const parts = trimmed.split("/");
181 return parts[parts.length - 1] || trimmed;
182};
183
184const rfc1035 = (name: string): string => {
185 let next = name
186 .toLowerCase()
187 .replace(/[^a-z0-9-]/g, "-")
188 .replace(/-+/g, "-")
189 .replace(/^-+/, "")
190 .replace(/-+$/, "");
191 if (!/^[a-z]/.test(next)) {
192 next = `f${next}`;
193 }
194 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
195 return next.length > 0 ? next : "policy";
196};
197
198const toName = (id: string, name: string | undefined, existing?: string) =>
199 Effect.gen(function* () {
200 if (name !== undefined) return name;
201 if (existing !== undefined) return existing;
202 return rfc1035(
203 yield* createPhysicalName({
204 id,
205 maxLength: MAX_NAME_LENGTH,
206 lowercase: true,
207 }),
208 );
209 });
210
211const normalizeParent = (value: string): string => {
212 const trimmed = value.trim().replace(/\/+$/, "");
213 if (trimmed.startsWith("organizations/") || trimmed.startsWith("folders/")) {
214 return trimmed;
215 }
216 if (trimmed.startsWith("organization/")) {
217 return `organizations/${trimmed.slice("organization/".length)}`;
218 }
219 if (trimmed.startsWith("folder/")) {
220 return `folders/${trimmed.slice("folder/".length)}`;
221 }
222 if (/^\d+$/.test(trimmed)) return `organizations/${trimmed}`;
223 return trimmed;
224};
225
226const typeOf = (value: string | undefined) =>
227 (value ?? DEFAULT_POLICY_TYPE).toUpperCase();
228
229const encodeDescription = (
230 labels: Record<string, string>,
231 description: string | undefined,
232): string => {
233 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
234 return description ? `${marker}\n${description}` : marker;
235};
236
237const parseDescription = (
238 description: string | undefined,
239): {
240 labels: Record<string, string>;
241 description: string | undefined;
242} => {
243 if (!description?.startsWith("[alchemy ")) {
244 return { labels: {}, description };
245 }
246 const end = description.indexOf("]");
247 if (end < 0) return { labels: {}, description };
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.slice(end + 1).replace(/^\n/, "");
256 return { labels, description: rest.length > 0 ? rest : undefined };
257};
258
259const hasOwnershipMarker = (description: string | undefined) =>
260 Object.keys(parseDescription(description).labels).some((key) =>
261 key.startsWith("alchemy-"),
262 );
263
264const projectParent = (project: string) =>
265 resourcemanager.getProjects({ name: `projects/${project}` }).pipe(
266 Effect.map((resource) =>
267 resource.parent !== undefined && resource.parent.length > 0
268 ? normalizeParent(resource.parent)
269 : undefined,
270 ),
271 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
272 );
273
274const resolveParent = (
275 news: FirewallPolicyProps,
276 output: { parent?: string } | undefined,
277) =>
278 Effect.gen(function* () {
279 const explicit = news.parent ?? output?.parent;
280 if (explicit !== undefined && explicit.length > 0) {
281 return normalizeParent(explicit);
282 }
283 const env = yield* GcpEnvironment.current;
284 const fromProject = yield* projectParent(env.project);
285 if (fromProject !== undefined) return fromProject;
286 return yield* new FirewallPolicyParentRequired({ project: env.project });
287 });
288
289const listParents = (project: string) =>
290 Effect.gen(function* () {
291 const parents = new Set<string>();
292 const fromProject = yield* projectParent(project);
293 if (fromProject !== undefined) parents.add(fromProject);
294 return [...parents];
295 });
296
297const sorted = (values: readonly string[] | undefined) =>
298 [...(values ?? [])].slice().sort();
299
300const sameJson = (left: unknown, right: unknown) =>
301 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
302
303const canonMatch = (match: FirewallPolicyRuleMatcher | undefined) => {
304 if (match === undefined) return undefined;
305 return {
306 srcIpRanges: sorted(match.srcIpRanges),
307 destIpRanges: sorted(match.destIpRanges),
308 srcFqdns: sorted(match.srcFqdns),
309 destFqdns: sorted(match.destFqdns),
310 srcRegionCodes: sorted(match.srcRegionCodes),
311 destRegionCodes: sorted(match.destRegionCodes),
312 srcAddressGroups: sorted(match.srcAddressGroups),
313 destAddressGroups: sorted(match.destAddressGroups),
314 srcThreatIntelligences: sorted(match.srcThreatIntelligences),
315 destThreatIntelligences: sorted(match.destThreatIntelligences),
316 srcNetworks: sorted(match.srcNetworks),
317 srcNetworkType: match.srcNetworkType,
318 destNetworkType: match.destNetworkType,
319 srcNetworkContext: match.srcNetworkContext,
320 destNetworkContext: match.destNetworkContext,
321 layer4Configs: [...(match.layer4Configs ?? [])]
322 .map((config) => ({
323 ipProtocol: (config.ipProtocol ?? "").toLowerCase(),
324 ports: sorted(config.ports),
325 }))
326 .sort(
327 (left, right) =>
328 left.ipProtocol.localeCompare(right.ipProtocol) ||
329 JSON.stringify(left.ports).localeCompare(JSON.stringify(right.ports)),
330 ),
331 srcSecureTags: [...(match.srcSecureTags ?? [])]
332 .map((tag) => tag.name ?? "")
333 .sort(),
334 };
335};
336
337const canonRule = (rule: FirewallPolicyRule) => ({
338 priority: rule.priority,
339 action: rule.action,
340 description: rule.description ?? "",
341 direction: (rule.direction ?? "INGRESS").toUpperCase(),
342 enableLogging: rule.enableLogging === true,
343 disabled: rule.disabled === true,
344 ruleName: rule.ruleName ?? "",
345 tlsInspect: rule.tlsInspect === true,
346 targetType: rule.targetType ?? "INSTANCES",
347 securityProfileGroup: rule.securityProfileGroup ?? "",
348 match: canonMatch(rule.match),
349 targetResources: sorted(rule.targetResources),
350 targetServiceAccounts: sorted(rule.targetServiceAccounts),
351 targetForwardingRules: sorted(rule.targetForwardingRules),
352 targetSecureTags: [...(rule.targetSecureTags ?? [])]
353 .map((tag) => tag.name ?? "")
354 .sort(),
355});
356
357const ruleEquals = (left: FirewallPolicyRule, right: FirewallPolicyRule) =>
358 sameJson(canonRule(left), canonRule(right));
359
360const toRuleBody = (rule: FirewallPolicyRule): FirewallPolicyRule => ({
361 priority: rule.priority,
362 action: rule.action,
363 description: rule.description,
364 direction: rule.direction,
365 enableLogging: rule.enableLogging === true ? true : undefined,
366 disabled: rule.disabled === true ? true : undefined,
367 ruleName: rule.ruleName,
368 tlsInspect: rule.tlsInspect === true ? true : undefined,
369 targetType: rule.targetType,
370 securityProfileGroup: rule.securityProfileGroup,
371 match: rule.match,
372 targetResources: rule.targetResources,
373 targetServiceAccounts: rule.targetServiceAccounts,
374 targetForwardingRules: rule.targetForwardingRules,
375 targetSecureTags: rule.targetSecureTags,
376});
377
378const desiredRules = (
379 news: FirewallPolicyProps,
380 observed: readonly FirewallPolicyRule[],
381): FirewallPolicyRule[] | undefined => {
382 if (news.rules === undefined) return undefined;
383 const byPriority = new Map<number, FirewallPolicyRule>();
384 for (const rule of news.rules) {
385 if (rule.priority === undefined) continue;
386 byPriority.set(rule.priority, rule);
387 }
388 if (!byPriority.has(DEFAULT_RULE_PRIORITY)) {
389 const observedDefault = observed.find(
390 (rule) => rule.priority === DEFAULT_RULE_PRIORITY,
391 );
392 if (observedDefault !== undefined) {
393 byPriority.set(DEFAULT_RULE_PRIORITY, observedDefault);
394 }
395 }
396 return [...byPriority.values()].sort(
397 (left, right) => (left.priority ?? 0) - (right.priority ?? 0),
398 );
399};
400
401const policyIdOf = (policy: compute.FirewallPolicy) =>
402 policy.name ?? policy.id ?? lastSegment(policy.selfLinkWithId);
403
404const toAttrs = (
405 policy: compute.FirewallPolicy,
406 project: string,
407): FirewallPolicy["Attributes"] => {
408 const parsed = parseDescription(policy.description);
409 return {
410 shortName: policy.shortName ?? policy.displayName ?? "",
411 firewallPolicyId: policyIdOf(policy),
412 parent: policy.parent ?? "",
413 project,
414 description: parsed.description,
415 policyType: typeOf(policy.policyType),
416 rules: policy.rules ?? [],
417 associations: policy.associations ?? [],
418 fingerprint: policy.fingerprint,
419 selfLink: policy.selfLink,
420 selfLinkWithId: policy.selfLinkWithId,
421 creationTimestamp: policy.creationTimestamp,
422 ruleTupleCount: policy.ruleTupleCount,
423 id: policy.id,
424 kind: policy.kind,
425 };
426};
427
428const getById = (firewallPolicy: string) =>
429 compute
430 .getFirewallPolicies({ firewallPolicy })
431 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
432
433const findByShortName = (parentId: string, shortName: string) =>
434 compute.listFirewallPolicies
435 .items({
436 parentId,
437 maxResults: 500,
438 returnPartialSuccess: true,
439 })
440 .pipe(
441 Stream.filter((policy) => policy.shortName === shortName),
442 Stream.runCollect,
443 Effect.map((chunk) => Array.from(chunk)[0]),
444 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
445 );
446
447const observe = (
448 firewallPolicyId: string | undefined,
449 parentId: string,
450 shortName: string,
451) =>
452 Effect.gen(function* () {
453 if (firewallPolicyId !== undefined && firewallPolicyId.length > 0) {
454 const existing = yield* getById(firewallPolicyId);
455 if (existing !== undefined) return existing;
456 }
457 return yield* findByShortName(parentId, shortName);
458 });
459
460const awaitResource = (
461 firewallPolicyId: string,
462 parentId: string,
463 shortName: string,
464) =>
465 observe(firewallPolicyId, parentId, shortName).pipe(
466 Effect.flatMap((policy) =>
467 policy !== undefined
468 ? Effect.succeed(policy)
469 : Effect.fail(
470 new FirewallPolicyNotResolved({ firewallPolicyId, shortName }),
471 ),
472 ),
473 Effect.retry({
474 while: (error) => error._tag === "GCP.Compute.FirewallPolicyNotResolved",
475 times: 8,
476 schedule: Schedule.spaced("1 second"),
477 }),
478 );
479
480const waitUntilGone = (firewallPolicyId: string) =>
481 getById(firewallPolicyId).pipe(
482 Effect.flatMap((policy) =>
483 policy === undefined
484 ? Effect.void
485 : Effect.fail(new FirewallPolicyStillExists({ firewallPolicyId })),
486 ),
487 Effect.retry({
488 while: (error) => error._tag === "GCP.Compute.FirewallPolicyStillExists",
489 times: 10,
490 schedule: Schedule.spaced("1 second"),
491 }),
492 Effect.catchTag("GCP.Compute.FirewallPolicyStillExists", () => Effect.void),
493 );
494
495const runOp = <E extends { readonly _tag: string }, R>(
496 firewallPolicyId: string,
497 parentId: string,
498 start: Effect.Effect<compute.Operation, E, R>,
499 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
500) =>
501 start.pipe(
502 Effect.flatMap((operation) =>
503 waitOrganizationOperation(operation, parentId, {
504 ignore: ignoredCodes(options),
505 }),
506 ),
507 Effect.retry({
508 while: (error) => error._tag === "Conflict",
509 times: 5,
510 schedule: Schedule.spaced("1 second"),
511 }),
512 );
513
514const insertBody = (
515 shortName: string,
516 news: FirewallPolicyProps,
517 description: string,
518): compute.FirewallPolicy => ({
519 shortName,
520 description,
521 policyType: typeOf(news.policyType),
522 rules: news.rules !== undefined ? news.rules.map(toRuleBody) : undefined,
523});
524
525const idFromOperation = (operation: compute.Operation) => {
526 if (operation.targetId !== undefined && operation.targetId.length > 0) {
527 return operation.targetId;
528 }
529 const fromLink = lastSegment(operation.targetLink);
530 return fromLink.length > 0 ? fromLink : "";
531};
532
533const syncRules = (
534 firewallPolicyId: string,
535 parentId: string,
536 observed: readonly FirewallPolicyRule[],
537 desired: readonly FirewallPolicyRule[],
538) =>
539 Effect.gen(function* () {
540 const observedByPriority = new Map<number, FirewallPolicyRule>();
541 for (const rule of observed) {
542 if (rule.priority !== undefined) {
543 observedByPriority.set(rule.priority, rule);
544 }
545 }
546 const desiredByPriority = new Map<number, FirewallPolicyRule>();
547 for (const rule of desired) {
548 if (rule.priority !== undefined) {
549 desiredByPriority.set(rule.priority, rule);
550 }
551 }
552
553 for (const [priority, rule] of desiredByPriority) {
554 const current = observedByPriority.get(priority);
555 if (current === undefined) {
556 yield* runOp(
557 firewallPolicyId,
558 parentId,
559 compute.addRuleFirewallPolicies({
560 firewallPolicy: firewallPolicyId,
561 body: toRuleBody(rule),
562 }),
563 { ignoreAlreadyExists: true },
564 );
565 continue;
566 }
567 if (!ruleEquals(current, rule)) {
568 yield* runOp(
569 firewallPolicyId,
570 parentId,
571 compute.patchRuleFirewallPolicies({
572 firewallPolicy: firewallPolicyId,
573 priority,
574 body: toRuleBody(rule),
575 }),
576 );
577 }
578 }
579
580 for (const priority of observedByPriority.keys()) {
581 if (desiredByPriority.has(priority)) continue;
582 if (priority === DEFAULT_RULE_PRIORITY) continue;
583 yield* runOp(
584 firewallPolicyId,
585 parentId,
586 compute.removeRuleFirewallPolicies({
587 firewallPolicy: firewallPolicyId,
588 priority,
589 }),
590 { ignoreNotFound: true },
591 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
592 }
593 });
594
595export const FirewallPolicyProvider = () =>
596 Provider.succeed(FirewallPolicy, {
597 stables: [
598 "shortName",
599 "firewallPolicyId",
600 "parent",
601 "project",
602 "policyType",
603 "id",
604 "selfLink",
605 "selfLinkWithId",
606 "creationTimestamp",
607 ],
608
609 diff: Effect.fn(function* ({ news, olds, output }) {
610 if (!isResolved(news)) return undefined;
611
612 const previousName = olds?.shortName ?? output?.shortName;
613 const nextName = news.shortName ?? previousName;
614 const nameChanged =
615 previousName !== undefined &&
616 nextName !== undefined &&
617 previousName !== nextName;
618
619 const previousParent = olds?.parent ?? output?.parent;
620 const nextParent =
621 news.parent !== undefined
622 ? normalizeParent(news.parent)
623 : previousParent;
624 const parentChanged =
625 previousParent !== undefined &&
626 nextParent !== undefined &&
627 normalizeParent(previousParent) !== normalizeParent(nextParent);
628
629 const previousType = typeOf(olds?.policyType ?? output?.policyType);
630 const nextType = typeOf(news.policyType ?? output?.policyType);
631 const typeChanged = previousType !== nextType;
632
633 if (nameChanged) {
634 return { action: "replace" as const, deleteFirst: false };
635 }
636 if (parentChanged || typeChanged) {
637 return { action: "replace" as const, deleteFirst: true };
638 }
639 return undefined;
640 }),
641
642 read: Effect.fn(function* ({ id, olds, output }) {
643 const env = yield* GcpEnvironment.current;
644 const shortName = yield* toName(id, olds?.shortName, output?.shortName);
645 const parent = yield* resolveParent(olds ?? {}, output).pipe(
646 Effect.catchTag("GCP.Compute.FirewallPolicyParentRequired", () =>
647 Effect.succeed(output?.parent ?? olds?.parent ?? ""),
648 ),
649 );
650 const existing = yield* observe(
651 output?.firewallPolicyId,
652 parent,
653 shortName,
654 );
655 if (existing === undefined) return undefined;
656 const attrs = toAttrs(existing, env.project);
657 const { labels } = parseDescription(existing.description);
658 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
659 }),
660
661 list: () =>
662 Effect.gen(function* () {
663 const env = yield* GcpEnvironment.current;
664 const parents = yield* listParents(env.project);
665 if (parents.length === 0) return [];
666 const listed: FirewallPolicy["Attributes"][] = [];
667 for (const parentId of parents) {
668 const chunk = yield* compute.listFirewallPolicies
669 .items({
670 parentId,
671 maxResults: 500,
672 returnPartialSuccess: true,
673 })
674 .pipe(
675 Stream.filter((policy) => hasOwnershipMarker(policy.description)),
676 Stream.map((policy) => toAttrs(policy, env.project)),
677 Stream.runCollect,
678 Effect.map((items) => Array.from(items)),
679 Effect.catchTag("NotFound", () =>
680 Effect.succeed([] as FirewallPolicy["Attributes"][]),
681 ),
682 );
683 listed.push(...chunk);
684 }
685 return listed;
686 }),
687
688 reconcile: Effect.fn(function* ({ id, news, output }) {
689 const env = yield* GcpEnvironment.current;
690 const shortName = yield* toName(id, news.shortName, output?.shortName);
691 const parentId = yield* resolveParent(news, output);
692 const ownership = yield* createInternalLabels(id);
693 const desiredDescription = encodeDescription(ownership, news.description);
694
695 let current = yield* observe(
696 output?.firewallPolicyId,
697 parentId,
698 shortName,
699 );
700
701 if (current === undefined) {
702 const inserted = yield* compute
703 .insertFirewallPolicies({
704 parentId,
705 body: insertBody(shortName, news, desiredDescription),
706 })
707 .pipe(
708 Effect.flatMap((operation) =>
709 waitOrganizationOperation(operation, parentId, {
710 ignore: ["RESOURCE_ALREADY_EXISTS"],
711 }),
712 ),
713 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
714 );
715 const createdId =
716 inserted !== undefined ? idFromOperation(inserted) : "";
717 current = yield* awaitResource(
718 createdId.length > 0 ? createdId : (output?.firewallPolicyId ?? ""),
719 parentId,
720 shortName,
721 );
722 }
723
724 if (current === undefined) {
725 return yield* new FirewallPolicyNotResolved({
726 firewallPolicyId: output?.firewallPolicyId ?? "",
727 shortName,
728 });
729 }
730
731 const firewallPolicyId = policyIdOf(current);
732
733 if ((current.description ?? "") !== desiredDescription) {
734 yield* runOp(
735 firewallPolicyId,
736 parentId,
737 compute.patchFirewallPolicies({
738 firewallPolicy: firewallPolicyId,
739 body: {
740 description: desiredDescription,
741 fingerprint: current.fingerprint,
742 },
743 }),
744 );
745 current = (yield* getById(firewallPolicyId)) ?? current;
746 }
747
748 const nextRules = desiredRules(news, current.rules ?? []);
749 if (nextRules !== undefined) {
750 yield* syncRules(
751 firewallPolicyId,
752 parentId,
753 current.rules ?? [],
754 nextRules,
755 );
756 current = (yield* getById(firewallPolicyId)) ?? current;
757 }
758
759 if (current === undefined) {
760 return yield* new FirewallPolicyNotResolved({
761 firewallPolicyId,
762 shortName,
763 });
764 }
765
766 return toAttrs(current, env.project);
767 }),
768
769 delete: Effect.fn(function* ({ output }) {
770 const firewallPolicyId = output.firewallPolicyId;
771 if (firewallPolicyId.length === 0) return;
772 const parentId =
773 output.parent.length > 0 ? normalizeParent(output.parent) : undefined;
774 yield* compute
775 .deleteFirewallPolicies({
776 firewallPolicy: firewallPolicyId,
777 })
778 .pipe(
779 Effect.flatMap((operation) =>
780 waitOrganizationOperation(operation, parentId ?? "", {
781 ignore: ["RESOURCE_NOT_FOUND"],
782 }),
783 ),
784 Effect.catchTag("NotFound", () => Effect.void),
785 Effect.retry({
786 while: (error) => error._tag === "Conflict",
787 times: 8,
788 schedule: Schedule.spaced("2 seconds"),
789 }),
790 );
791 yield* waitUntilGone(firewallPolicyId);
792 }),
793 });