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