1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
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 { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
15import type { Providers } from "../Providers.ts";
16
17export interface FirewallRule {
18 /**
19 * IP protocol this rule matches. One of `tcp`, `udp`, `icmp`, `esp`,
20 * `ah`, `ipip`, `sctp`, or an IP protocol number.
21 */
22 protocol: string;
23 /**
24 * Destination ports or ranges (e.g. `"22"`, `"80"`, `"8000-8080"`).
25 * Only valid for `tcp` and `udp`. Omit to match every port.
26 */
27 ports?: string[];
28}
29
30export interface FirewallLogConfig {
31 /**
32 * Whether to export logs for this rule to Cloud Logging.
33 * @default false
34 */
35 enable?: boolean;
36 /**
37 * Whether log entries include metadata. Only set when `enable` is true.
38 */
39 metadata?: "EXCLUDE_ALL_METADATA" | "INCLUDE_ALL_METADATA";
40}
41
42export type FirewallProps = {
43 /**
44 * Firewall name. If omitted, a unique RFC1035 name is generated from the
45 * stack, stage, and logical id. Must be 1-63 characters and match
46 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Changing the name replaces the rule.
47 */
48 firewallName?: string;
49 /**
50 * VPC network the rule belongs to. Accepts a name (`default`), a partial
51 * URL (`global/networks/default`), or a full URL. Immutable — changing it
52 * replaces the rule.
53 * @default "global/networks/default"
54 */
55 network?: string;
56 /**
57 * Human-readable description. Alchemy ownership markers are stored in
58 * this field (Compute firewalls have no labels) and stripped from
59 * attributes.
60 */
61 description?: string;
62 /**
63 * Priority in `[0, 65535]`. Lower values take precedence. DENY wins
64 * over ALLOW at the same priority.
65 * @default 1000
66 */
67 priority?: number;
68 /**
69 * Traffic direction. Immutable — changing it replaces the rule.
70 * @default "INGRESS"
71 */
72 direction?: "INGRESS" | "EGRESS";
73 /**
74 * When true, the rule exists but is not enforced.
75 * @default false
76 */
77 disabled?: boolean;
78 /**
79 * ALLOW match conditions. A rule must specify `allowed` or `denied`,
80 * not both. Switching action replaces the rule.
81 */
82 allowed?: FirewallRule[];
83 /**
84 * DENY match conditions. A rule must specify `allowed` or `denied`,
85 * not both. Switching action replaces the rule.
86 */
87 denied?: FirewallRule[];
88 /**
89 * Source CIDR ranges. For INGRESS, omit together with `sourceTags` and
90 * `sourceServiceAccounts` to match `0.0.0.0/0`.
91 */
92 sourceRanges?: string[];
93 /**
94 * Destination CIDR ranges.
95 */
96 destinationRanges?: string[];
97 /**
98 * Source network tags (INGRESS). Cannot be combined with service-account
99 * targeting.
100 */
101 sourceTags?: string[];
102 /**
103 * Target network tags. If omitted, the rule applies to every instance on
104 * the network.
105 */
106 targetTags?: string[];
107 /**
108 * Source service accounts (INGRESS). Cannot be combined with network tags.
109 */
110 sourceServiceAccounts?: string[];
111 /**
112 * Target service accounts. Cannot be combined with network tags.
113 */
114 targetServiceAccounts?: string[];
115 /**
116 * Firewall Rules Logging options.
117 */
118 logConfig?: FirewallLogConfig;
119};
120
121export type Firewall = Resource<
122 "GCP.Compute.Firewall",
123 FirewallProps,
124 {
125 /** Firewall name. */
126 firewallName: string;
127 /** Project id. */
128 project: string;
129 /** Network URL. */
130 network: string;
131 /** User description (ownership marker stripped). */
132 description: string | undefined;
133 /** Priority. */
134 priority: number;
135 /** `INGRESS` or `EGRESS`. */
136 direction: string;
137 /** Whether the rule is not enforced. */
138 disabled: boolean;
139 /** ALLOW match conditions. */
140 allowed: FirewallRule[];
141 /** DENY match conditions. */
142 denied: FirewallRule[];
143 /** Source CIDR ranges. */
144 sourceRanges: string[];
145 /** Destination CIDR ranges. */
146 destinationRanges: string[];
147 /** Source network tags. */
148 sourceTags: string[];
149 /** Target network tags. */
150 targetTags: string[];
151 /** Source service accounts. */
152 sourceServiceAccounts: string[];
153 /** Target service accounts. */
154 targetServiceAccounts: string[];
155 /** Logging options, if enabled. */
156 logConfig: FirewallLogConfig | undefined;
157 /** Server-assigned numeric id. */
158 id: string | undefined;
159 /** Server-defined URL. */
160 selfLink: string | undefined;
161 /** RFC3339 creation timestamp. */
162 creationTimestamp: string | undefined;
163 },
164 never,
165 Providers
166>;
167
168/**
169 * A VPC firewall rule that allows or denies ingress/egress traffic for
170 * instances on a network.
171 *
172 * Compute Engine firewalls have no resource labels. Alchemy stamps
173 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) into the
174 * description so `read` and `list` (and `pnpm nuke:gcp`) can find them.
175 *
176 * Name, network, direction, and allow-vs-deny are immutable — changing any
177 * of them replaces the rule.
178 *
179 * ### Creating a Firewall
180 * **Example:** Generated name, HTTP from a private range
181 * ```typescript
182 * const http = yield* GCP.Compute.Firewall("AllowHttp", {
183 * allowed: [{ protocol: "tcp", ports: ["80"] }],
184 * sourceRanges: ["10.0.0.0/8"],
185 * targetTags: ["web"],
186 * });
187 * ```
188 *
189 * **Example:** Explicit name, HTTPS deny, logging
190 * ```typescript
191 * const deny = yield* GCP.Compute.Firewall("DenyHttps", {
192 * firewallName: "deny-https-egress",
193 * direction: "EGRESS",
194 * denied: [{ protocol: "tcp", ports: ["443"] }],
195 * destinationRanges: ["0.0.0.0/0"],
196 * priority: 800,
197 * logConfig: { enable: true, metadata: "INCLUDE_ALL_METADATA" },
198 * });
199 * ```
200 *
201 * @resource
202 * @category Compute
203 */
204export const Firewall = Resource<Firewall>("GCP.Compute.Firewall");
205
206export class FirewallNotResolved extends Data.TaggedError(
207 "GCP.Compute.FirewallNotResolved",
208)<{
209 firewallName: string;
210}> {}
211
212const DEFAULT_NETWORK = "global/networks/default";
213const DEFAULT_DIRECTION = "INGRESS";
214const DEFAULT_PRIORITY = 1000;
215const OWNERSHIP_KEYS = [
216 "alchemy-stack",
217 "alchemy-stage",
218 "alchemy-id",
219] as const;
220
221const backoff = Schedule.min([
222 Schedule.exponential(Duration.millis(300), 1.5),
223 Schedule.spaced(Duration.seconds(2)),
224]);
225
226const networkId = (network: string | undefined) => {
227 if (network === undefined || network.length === 0) return undefined;
228 const parts = network.split("/").filter((part) => part.length > 0);
229 return parts[parts.length - 1];
230};
231
232const toNetworkUrl = (network: string | undefined, project: string) => {
233 if (network === undefined || network.length === 0) {
234 return `projects/${project}/global/networks/default`;
235 }
236 if (network.includes("/")) return network;
237 return `projects/${project}/global/networks/${network}`;
238};
239
240const toName = (id: string, name: string | undefined, existing?: string) =>
241 Effect.gen(function* () {
242 if (name !== undefined) return name;
243 if (existing !== undefined) return existing;
244 const generated = yield* createPhysicalName({
245 id,
246 maxLength: 63,
247 lowercase: true,
248 });
249 const rfc = generated.replace(/^[^a-z]+/, "f").replace(/-+$/g, "");
250 return rfc.slice(0, 63);
251 });
252
253const encodeDescription = (
254 user: string | undefined,
255 labels: Record<string, string>,
256) => {
257 const marker = OWNERSHIP_KEYS.map(
258 (key) => `${key}=${labels[key] ?? ""}`,
259 ).join(" ");
260 const trimmed = user?.trim() ?? "";
261 return trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
262};
263
264const parseDescription = (description: string | undefined) => {
265 if (description === undefined || description.length === 0) {
266 return { labels: {} as Record<string, string>, description: undefined };
267 }
268 const newline = description.indexOf("\n");
269 const first = newline === -1 ? description : description.slice(0, newline);
270 const rest = newline === -1 ? undefined : description.slice(newline + 1);
271 if (!first.includes("alchemy-id=") || !first.includes("alchemy-stack=")) {
272 return { labels: {} as Record<string, string>, description };
273 }
274 const labels: Record<string, string> = {};
275 for (const part of first.split(/\s+/)) {
276 const eq = part.indexOf("=");
277 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
278 }
279 return {
280 labels,
281 description: rest && rest.length > 0 ? rest : undefined,
282 };
283};
284
285const hasAlchemyMarker = (description: string | undefined) =>
286 Object.keys(parseDescription(description).labels).some((key) =>
287 key.startsWith("alchemy-"),
288 );
289
290const toApiRules = (
291 rules: FirewallRule[] | undefined,
292): compute.FirewallAllowedItemList | undefined => {
293 if (rules === undefined || rules.length === 0) return undefined;
294 return rules.map((rule) => ({
295 IPProtocol: rule.protocol,
296 ports:
297 rule.ports !== undefined && rule.ports.length > 0
298 ? rule.ports
299 : undefined,
300 }));
301};
302
303const fromApiRules = (
304 rules: compute.FirewallAllowedItemList | undefined,
305): FirewallRule[] =>
306 (rules ?? []).flatMap((rule) =>
307 rule.IPProtocol
308 ? [
309 {
310 protocol: rule.IPProtocol,
311 ports: rule.ports,
312 },
313 ]
314 : [],
315 );
316
317const toApiLogConfig = (
318 log: FirewallLogConfig | undefined,
319): compute.FirewallLogConfig | undefined => {
320 if (log === undefined || log.enable !== true) return undefined;
321 return {
322 enable: true,
323 metadata: log.metadata,
324 };
325};
326
327const fromApiLogConfig = (
328 log: compute.FirewallLogConfig | undefined,
329): FirewallLogConfig | undefined =>
330 log?.enable === true
331 ? {
332 enable: true,
333 metadata: log.metadata as FirewallLogConfig["metadata"],
334 }
335 : undefined;
336
337const listsEqual = (left?: string[], right?: string[]) => {
338 const a = [...(left ?? [])].sort();
339 const b = [...(right ?? [])].sort();
340 return a.length === b.length && a.every((value, i) => value === b[i]);
341};
342
343const rulesEqual = (left?: FirewallRule[], right?: FirewallRule[]) => {
344 const canon = (rules?: FirewallRule[]) =>
345 JSON.stringify(
346 (rules ?? [])
347 .map((rule) => ({
348 protocol: rule.protocol.toLowerCase(),
349 ports: [...(rule.ports ?? [])].sort(),
350 }))
351 .sort(
352 (a, b) =>
353 a.protocol.localeCompare(b.protocol) ||
354 JSON.stringify(a.ports).localeCompare(JSON.stringify(b.ports)),
355 ),
356 );
357 return canon(left) === canon(right);
358};
359
360const logConfigEqual = (
361 left: FirewallLogConfig | undefined,
362 right: FirewallLogConfig | undefined,
363) =>
364 (left?.enable === true) === (right?.enable === true) &&
365 (left?.enable === true ? left.metadata : undefined) ===
366 (right?.enable === true ? right.metadata : undefined);
367
368type FirewallAction = "allow" | "deny";
369
370const actionOf = (
371 props:
372 | {
373 allowed?: unknown[];
374 denied?: unknown[];
375 }
376 | undefined,
377): FirewallAction | undefined => {
378 if (props === undefined) return undefined;
379 if (props.denied !== undefined && props.denied.length > 0) return "deny";
380 if (props.allowed !== undefined && props.allowed.length > 0) return "allow";
381 return undefined;
382};
383
384const toAttrs = (firewall: compute.Firewall, project: string) => ({
385 firewallName: firewall.name ?? firewall.id ?? "",
386 project,
387 network: firewall.network ?? "",
388 description: parseDescription(firewall.description).description,
389 priority: firewall.priority ?? DEFAULT_PRIORITY,
390 direction: (firewall.direction ?? DEFAULT_DIRECTION).toUpperCase(),
391 disabled: firewall.disabled === true,
392 allowed: fromApiRules(firewall.allowed),
393 denied: fromApiRules(firewall.denied),
394 sourceRanges: firewall.sourceRanges ?? [],
395 destinationRanges: firewall.destinationRanges ?? [],
396 sourceTags: firewall.sourceTags ?? [],
397 targetTags: firewall.targetTags ?? [],
398 sourceServiceAccounts: firewall.sourceServiceAccounts ?? [],
399 targetServiceAccounts: firewall.targetServiceAccounts ?? [],
400 logConfig: fromApiLogConfig(firewall.logConfig),
401 id: firewall.id,
402 selfLink: firewall.selfLink,
403 creationTimestamp: firewall.creationTimestamp,
404});
405
406const getByName = (project: string, firewall: string) =>
407 compute
408 .getFirewalls({ project, firewall })
409 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
410
411const insertBody = (
412 firewallName: string,
413 project: string,
414 news: FirewallProps,
415 description: string,
416): compute.Firewall => ({
417 name: firewallName,
418 network: toNetworkUrl(news.network, project),
419 description,
420 priority: news.priority ?? DEFAULT_PRIORITY,
421 direction: (news.direction ?? DEFAULT_DIRECTION).toUpperCase(),
422 disabled: news.disabled === true,
423 allowed: toApiRules(news.allowed),
424 denied: toApiRules(news.denied),
425 sourceRanges: news.sourceRanges,
426 destinationRanges: news.destinationRanges,
427 sourceTags: news.sourceTags,
428 targetTags: news.targetTags,
429 sourceServiceAccounts: news.sourceServiceAccounts,
430 targetServiceAccounts: news.targetServiceAccounts,
431 logConfig: toApiLogConfig(news.logConfig),
432});
433
434export const FirewallProvider = () =>
435 Provider.succeed(Firewall, {
436 stables: [
437 "firewallName",
438 "project",
439 "network",
440 "direction",
441 "id",
442 "selfLink",
443 "creationTimestamp",
444 ],
445
446 diff: Effect.fn(function* ({ news, olds, output }) {
447 if (!isResolved(news)) return undefined;
448 const prevName = olds?.firewallName ?? output?.firewallName;
449 const nextName = news.firewallName ?? prevName;
450 const nameChanged =
451 prevName !== undefined &&
452 nextName !== undefined &&
453 prevName !== nextName;
454
455 const prevNetwork = networkId(olds?.network ?? output?.network);
456 const nextNetwork = networkId(
457 news.network ?? prevNetwork ?? DEFAULT_NETWORK,
458 );
459 const networkChanged =
460 prevNetwork !== undefined &&
461 nextNetwork !== undefined &&
462 prevNetwork !== nextNetwork;
463
464 const prevDir = (olds?.direction ?? output?.direction)?.toUpperCase();
465 const nextDir = (
466 news.direction ??
467 prevDir ??
468 DEFAULT_DIRECTION
469 ).toUpperCase();
470 const directionChanged = prevDir !== undefined && prevDir !== nextDir;
471
472 const prevAction = actionOf(olds) ?? actionOf(output);
473 const nextAction = actionOf(news);
474 const actionChanged =
475 prevAction !== undefined &&
476 nextAction !== undefined &&
477 prevAction !== nextAction;
478
479 if (nameChanged || networkChanged || directionChanged || actionChanged) {
480 return {
481 action: "replace" as const,
482 deleteFirst: !nameChanged,
483 };
484 }
485 return undefined;
486 }),
487
488 read: Effect.fn(function* ({ id, olds, output }) {
489 const env = yield* GcpEnvironment.current;
490 const firewallName = yield* toName(
491 id,
492 olds?.firewallName,
493 output?.firewallName,
494 );
495 const existing = yield* getByName(env.project, firewallName);
496 if (existing === undefined) return undefined;
497 const attrs = toAttrs(existing, env.project);
498 return (yield* hasAlchemyLabels(
499 id,
500 parseDescription(existing.description).labels,
501 ))
502 ? attrs
503 : Unowned(attrs);
504 }),
505
506 list: () =>
507 Effect.gen(function* () {
508 const env = yield* GcpEnvironment.current;
509 return yield* compute.listFirewalls
510 .items({ project: env.project })
511 .pipe(
512 Stream.filter((firewall) => hasAlchemyMarker(firewall.description)),
513 Stream.map((firewall) => toAttrs(firewall, env.project)),
514 Stream.runCollect,
515 Effect.map((chunk) => Array.from(chunk)),
516 );
517 }),
518
519 reconcile: Effect.fn(function* ({ id, news, output }) {
520 const env = yield* GcpEnvironment.current;
521 const firewallName = yield* toName(
522 id,
523 news.firewallName,
524 output?.firewallName,
525 );
526 const desiredLabels = yield* createInternalLabels(id);
527 const desiredDescription = encodeDescription(
528 news.description,
529 desiredLabels,
530 );
531 const desiredPriority = news.priority ?? DEFAULT_PRIORITY;
532 const desiredDisabled = news.disabled === true;
533 const desiredAllowed = news.allowed ?? [];
534 const desiredDenied = news.denied ?? [];
535 const desiredSourceRanges = news.sourceRanges ?? [];
536 const desiredDestinationRanges = news.destinationRanges ?? [];
537 const desiredSourceTags = news.sourceTags ?? [];
538 const desiredTargetTags = news.targetTags ?? [];
539 const desiredSourceServiceAccounts = news.sourceServiceAccounts ?? [];
540 const desiredTargetServiceAccounts = news.targetServiceAccounts ?? [];
541 const desiredLogConfig = toApiLogConfig(news.logConfig);
542
543 let current = yield* getByName(env.project, firewallName);
544
545 if (current === undefined) {
546 yield* compute
547 .insertFirewalls({
548 project: env.project,
549 body: insertBody(
550 firewallName,
551 env.project,
552 news,
553 desiredDescription,
554 ),
555 })
556 .pipe(
557 Effect.flatMap((operation) =>
558 waitGlobalOperation(env.project, operation),
559 ),
560 Effect.catchTag("Conflict", () => Effect.void),
561 );
562 current = yield* getByName(env.project, firewallName).pipe(
563 Effect.flatMap((firewall) =>
564 firewall !== undefined
565 ? Effect.succeed(firewall)
566 : Effect.fail(new FirewallNotResolved({ firewallName })),
567 ),
568 Effect.retry({
569 while: (error) => error._tag === "GCP.Compute.FirewallNotResolved",
570 times: 8,
571 schedule: backoff,
572 }),
573 Effect.catchTag("GCP.Compute.FirewallNotResolved", () =>
574 Effect.succeed(undefined),
575 ),
576 );
577 }
578
579 if (current === undefined) {
580 return yield* new FirewallNotResolved({ firewallName });
581 }
582
583 const observed = toAttrs(current, env.project);
584 const patch: compute.Firewall = {};
585
586 if ((current.description ?? "") !== desiredDescription) {
587 patch.description = desiredDescription;
588 }
589 if (observed.priority !== desiredPriority) {
590 patch.priority = desiredPriority;
591 }
592 if (observed.disabled !== desiredDisabled) {
593 patch.disabled = desiredDisabled;
594 }
595 if (!rulesEqual(observed.allowed, desiredAllowed)) {
596 patch.allowed = toApiRules(desiredAllowed) ?? [];
597 }
598 if (!rulesEqual(observed.denied, desiredDenied)) {
599 patch.denied = toApiRules(desiredDenied) ?? [];
600 }
601 if (!listsEqual(observed.sourceRanges, desiredSourceRanges)) {
602 patch.sourceRanges = desiredSourceRanges;
603 }
604 if (!listsEqual(observed.destinationRanges, desiredDestinationRanges)) {
605 patch.destinationRanges = desiredDestinationRanges;
606 }
607 if (!listsEqual(observed.sourceTags, desiredSourceTags)) {
608 patch.sourceTags = desiredSourceTags;
609 }
610 if (!listsEqual(observed.targetTags, desiredTargetTags)) {
611 patch.targetTags = desiredTargetTags;
612 }
613 if (
614 !listsEqual(
615 observed.sourceServiceAccounts,
616 desiredSourceServiceAccounts,
617 )
618 ) {
619 patch.sourceServiceAccounts = desiredSourceServiceAccounts;
620 }
621 if (
622 !listsEqual(
623 observed.targetServiceAccounts,
624 desiredTargetServiceAccounts,
625 )
626 ) {
627 patch.targetServiceAccounts = desiredTargetServiceAccounts;
628 }
629 if (
630 !logConfigEqual(observed.logConfig, fromApiLogConfig(desiredLogConfig))
631 ) {
632 patch.logConfig = desiredLogConfig ?? { enable: false };
633 }
634
635 if (Object.keys(patch).length > 0) {
636 yield* compute
637 .patchFirewalls({
638 project: env.project,
639 firewall: firewallName,
640 body: patch,
641 })
642 .pipe(
643 Effect.flatMap((operation) =>
644 waitGlobalOperation(env.project, operation),
645 ),
646 Effect.retry({
647 while: (error) => error._tag === "Conflict",
648 times: 5,
649 schedule: backoff,
650 }),
651 );
652 current = (yield* getByName(env.project, firewallName)) ?? current;
653 }
654
655 return toAttrs(current, env.project);
656 }),
657
658 delete: Effect.fn(function* ({ output }) {
659 const env = yield* GcpEnvironment.current;
660 yield* compute
661 .deleteFirewalls({
662 project: env.project,
663 firewall: output.firewallName,
664 })
665 .pipe(
666 Effect.flatMap((operation) =>
667 waitGlobalOperation(env.project, operation, {
668 ignore: ["RESOURCE_NOT_FOUND"],
669 }),
670 ),
671 Effect.catchTag("NotFound", () => Effect.void),
672 );
673 }),
674 });