1import type * as NFW from "@distilled.cloud/aws/network-firewall";
2import * as nfw from "@distilled.cloud/aws/network-firewall";
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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14import { nfwTagsToRecord, recordToNfwTags } from "./internal.ts";
15
16export interface FirewallProps {
17 /**
18 * Name of the firewall. Must be 1-128 alphanumeric characters or hyphens.
19 * If omitted, a deterministic physical name is generated. Changing the
20 * name replaces the firewall.
21 */
22 firewallName?: string;
23 /**
24 * ARN of the {@link FirewallPolicy} that defines the firewall's traffic
25 * inspection behavior. Updated in place via `AssociateFirewallPolicy`.
26 */
27 firewallPolicyArn: string;
28 /**
29 * ID of the VPC the firewall protects. Changing the VPC replaces the
30 * firewall.
31 */
32 vpcId: string;
33 /**
34 * The public subnets (one per Availability Zone) that Network Firewall
35 * provisions firewall endpoints into. Uses raw Network Firewall API
36 * structures (`{ SubnetId, IPAddressType? }`). Updated in place via
37 * `AssociateSubnets` / `DisassociateSubnets`.
38 */
39 subnetMappings: NFW.SubnetMapping[];
40 /**
41 * Whether the firewall is protected against deletion. The provider
42 * automatically clears this flag before deleting the firewall.
43 * @default false
44 */
45 deleteProtection?: boolean;
46 /**
47 * Whether the firewall is protected against changes to its subnet
48 * associations.
49 * @default false
50 */
51 subnetChangeProtection?: boolean;
52 /**
53 * Whether the firewall is protected against a change of firewall policy.
54 * @default false
55 */
56 firewallPolicyChangeProtection?: boolean;
57 /**
58 * Human-readable description of the firewall.
59 */
60 description?: string;
61 /**
62 * Tags to apply to the firewall. Merged with internal Alchemy tags.
63 */
64 tags?: Record<string, string>;
65}
66
67export interface Firewall extends Resource<
68 "AWS.NetworkFirewall.Firewall",
69 FirewallProps,
70 {
71 /** Name of the firewall. */
72 firewallName: string;
73 /** ARN of the firewall. */
74 firewallArn: string;
75 /** Server-assigned unique id of the firewall. */
76 firewallId: string;
77 /** ID of the VPC the firewall is provisioned into. */
78 vpcId: string;
79 /**
80 * The VPC endpoint IDs of the provisioned firewall endpoints, one per
81 * subnet mapping, sorted for determinism. Use these as route targets.
82 */
83 endpointIds: string[];
84 },
85 never,
86 Providers
87> {}
88
89/**
90 * An AWS Network Firewall firewall — provisions managed firewall endpoints
91 * into your VPC subnets and inspects traffic according to an associated
92 * {@link FirewallPolicy}.
93 *
94 * Endpoint provisioning takes several minutes (typically 5-10), and deleting
95 * a firewall waits for the endpoints to deprovision.
96 * ### Creating Firewalls
97 * **Example:** Firewall in a VPC
98 * ```typescript
99 * import * as EC2 from "alchemy/AWS/EC2";
100 * import * as NetworkFirewall from "alchemy/AWS/NetworkFirewall";
101 *
102 * const vpc = yield* EC2.Vpc("Vpc", { cidrBlock: "10.0.0.0/16" });
103 * const subnet = yield* EC2.Subnet("FirewallSubnet", {
104 * vpcId: vpc.vpcId,
105 * cidrBlock: "10.0.1.0/24",
106 * });
107 *
108 * const policy = yield* NetworkFirewall.FirewallPolicy("Policy", {
109 * firewallPolicy: {
110 * StatelessDefaultActions: ["aws:pass"],
111 * StatelessFragmentDefaultActions: ["aws:pass"],
112 * },
113 * });
114 *
115 * const firewall = yield* NetworkFirewall.Firewall("Firewall", {
116 * firewallPolicyArn: policy.firewallPolicyArn,
117 * vpcId: vpc.vpcId,
118 * subnetMappings: [{ SubnetId: subnet.subnetId }],
119 * });
120 * ```
121 *
122 * @resource
123 */
124export const Firewall = Resource<Firewall>("AWS.NetworkFirewall.Firewall");
125
126/** Raised (internally, for bounded retry) while a firewall is provisioning. */
127export class FirewallNotReady extends Data.TaggedError("FirewallNotReady")<{
128 message: string;
129}> {}
130
131/** Raised (internally, for bounded retry) while a firewall is deleting. */
132export class FirewallNotDeleted extends Data.TaggedError("FirewallNotDeleted")<{
133 message: string;
134}> {}
135
136// Explicitly-typed pipeable retry helpers (see internal.ts for why these are
137// not inlined).
138const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
139 self: Effect.Effect<A, E, R>,
140): Effect.Effect<A, E, R> =>
141 Effect.retry(self, {
142 while: (e) => e._tag === "FirewallNotReady",
143 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(60)]),
144 });
145
146const retryWhileNotDeleted = <A, E extends { readonly _tag: string }, R>(
147 self: Effect.Effect<A, E, R>,
148): Effect.Effect<A, E, R> =>
149 Effect.retry(self, {
150 while: (e) => e._tag === "FirewallNotDeleted",
151 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(80)]),
152 });
153
154const retryWhileFirewallBusy = <A, E extends { readonly _tag: string }, R>(
155 self: Effect.Effect<A, E, R>,
156): Effect.Effect<A, E, R> =>
157 Effect.retry(self, {
158 while: (e) => e._tag === "InvalidOperationException",
159 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(20)]),
160 });
161
162export const FirewallProvider = () =>
163 Provider.effect(
164 Firewall,
165 Effect.gen(function* () {
166 const createName = Effect.fn(function* (
167 id: string,
168 props: { firewallName?: string },
169 ) {
170 return (
171 props.firewallName ??
172 (yield* createPhysicalName({ id, maxLength: 128 }))
173 );
174 });
175
176 const endpointIds = (status: NFW.FirewallStatus | undefined): string[] =>
177 Object.values(status?.SyncStates ?? {})
178 .map((state) => state?.Attachment?.EndpointId)
179 .filter((id): id is string => id !== undefined)
180 .sort();
181
182 const toAttrs = (
183 firewall: NFW.Firewall,
184 status: NFW.FirewallStatus | undefined,
185 ): Firewall["Attributes"] => ({
186 firewallName: firewall.FirewallName ?? "",
187 firewallArn: firewall.FirewallArn ?? "",
188 firewallId: firewall.FirewallId,
189 vpcId: firewall.VpcId,
190 endpointIds: endpointIds(status),
191 });
192
193 const describe = Effect.fn(function* (name: string) {
194 return yield* nfw
195 .describeFirewall({ FirewallName: name })
196 .pipe(
197 Effect.catchTag("ResourceNotFoundException", () =>
198 Effect.succeed(undefined),
199 ),
200 );
201 });
202
203 // Bounded readiness wait. Endpoint provisioning typically completes in
204 // 5-10 minutes; budget ~15 min (60 * 15s).
205 const waitForReady = Effect.fn(function* (name: string) {
206 return yield* Effect.gen(function* () {
207 const response = yield* describe(name);
208 if (response?.Firewall === undefined) {
209 return yield* Effect.fail(
210 new FirewallNotReady({ message: `firewall '${name}' not found` }),
211 );
212 }
213 if (response.FirewallStatus?.Status !== "READY") {
214 return yield* Effect.fail(
215 new FirewallNotReady({
216 message: `firewall '${name}' not ready (status: ${response.FirewallStatus?.Status})`,
217 }),
218 );
219 }
220 return response;
221 }).pipe(retryWhileNotReady);
222 });
223
224 return Firewall.Provider.of({
225 stables: ["firewallName", "firewallArn", "firewallId", "vpcId"],
226
227 list: () =>
228 Effect.gen(function* () {
229 const pages = yield* nfw.listFirewalls
230 .pages({})
231 .pipe(Stream.runCollect);
232 const metas = Array.from(pages).flatMap(
233 (page) => page.Firewalls ?? [],
234 );
235 const items = yield* Effect.forEach(
236 metas,
237 (meta) =>
238 nfw.describeFirewall({ FirewallArn: meta.FirewallArn }).pipe(
239 Effect.map((r) =>
240 r.Firewall !== undefined
241 ? toAttrs(r.Firewall, r.FirewallStatus)
242 : undefined,
243 ),
244 Effect.catchTag("ResourceNotFoundException", () =>
245 Effect.succeed(undefined),
246 ),
247 ),
248 { concurrency: 5 },
249 );
250 return items.filter(
251 (item): item is Firewall["Attributes"] => item !== undefined,
252 );
253 }),
254
255 read: Effect.fn(function* ({ id, olds, output }) {
256 const name =
257 output?.firewallName ?? (yield* createName(id, olds ?? {}));
258 const found = yield* describe(name);
259 if (found?.Firewall === undefined) return undefined;
260 const attrs = toAttrs(found.Firewall, found.FirewallStatus);
261 const tags = nfwTagsToRecord(found.Firewall.Tags);
262 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
263 }),
264
265 diff: Effect.fn(function* ({ id, news, olds }) {
266 if (!isResolved(news)) return undefined;
267 const oldName = yield* createName(id, olds);
268 const newName = yield* createName(id, news);
269 if (oldName !== newName || olds.vpcId !== news.vpcId) {
270 return { action: "replace" } as const;
271 }
272 }),
273
274 reconcile: Effect.fn(function* ({ id, news, output, session }) {
275 const name = output?.firewallName ?? (yield* createName(id, news));
276 const internalTags = yield* createInternalTags(id);
277 const desiredTags: Record<string, string> = {
278 ...news.tags,
279 ...internalTags,
280 };
281
282 // 1. Observe — cloud state is authoritative.
283 let observed = yield* describe(name);
284
285 // 2. Ensure — create if missing, then wait for READY so mutation
286 // calls below don't race endpoint provisioning.
287 if (observed?.Firewall === undefined) {
288 yield* session.note(`creating firewall ${name} (5-10 minutes)`);
289 yield* nfw.createFirewall({
290 FirewallName: name,
291 FirewallPolicyArn: news.firewallPolicyArn,
292 VpcId: news.vpcId,
293 SubnetMappings: news.subnetMappings,
294 DeleteProtection: news.deleteProtection ?? false,
295 SubnetChangeProtection: news.subnetChangeProtection ?? false,
296 FirewallPolicyChangeProtection:
297 news.firewallPolicyChangeProtection ?? false,
298 Description: news.description,
299 Tags: recordToNfwTags(desiredTags),
300 });
301 }
302 observed = yield* waitForReady(name);
303 let firewall = observed.Firewall!;
304
305 // 3. Sync — each mutable aspect re-reads for a fresh UpdateToken,
306 // diffs OBSERVED against desired, and applies only the delta.
307 const refresh = Effect.fn(function* () {
308 observed = yield* waitForReady(name);
309 firewall = observed.Firewall!;
310 });
311
312 // 3a. Firewall policy association.
313 if (firewall.FirewallPolicyArn !== news.firewallPolicyArn) {
314 yield* nfw.associateFirewallPolicy({
315 UpdateToken: observed.UpdateToken,
316 FirewallName: name,
317 FirewallPolicyArn: news.firewallPolicyArn,
318 });
319 yield* refresh();
320 }
321
322 // 3b. Subnet associations.
323 const observedSubnetIds = new Set(
324 firewall.SubnetMappings.map((m) => m.SubnetId),
325 );
326 const desiredSubnetIds = new Set(
327 news.subnetMappings.map((m) => m.SubnetId),
328 );
329 const toAssociate = news.subnetMappings.filter(
330 (m) => !observedSubnetIds.has(m.SubnetId),
331 );
332 const toDisassociate = [...observedSubnetIds].filter(
333 (subnetId) => !desiredSubnetIds.has(subnetId),
334 );
335 if (toAssociate.length > 0) {
336 yield* nfw.associateSubnets({
337 UpdateToken: observed.UpdateToken,
338 FirewallName: name,
339 SubnetMappings: toAssociate,
340 });
341 yield* refresh();
342 }
343 if (toDisassociate.length > 0) {
344 yield* nfw.disassociateSubnets({
345 UpdateToken: observed.UpdateToken,
346 FirewallName: name,
347 SubnetIds: toDisassociate,
348 });
349 yield* refresh();
350 }
351
352 // 3c. Description.
353 if ((news.description ?? undefined) !== firewall.Description) {
354 yield* nfw.updateFirewallDescription({
355 UpdateToken: observed.UpdateToken,
356 FirewallName: name,
357 Description: news.description,
358 });
359 yield* refresh();
360 }
361
362 // 3d. Protection flags.
363 const desiredDeleteProtection = news.deleteProtection ?? false;
364 if (
365 (firewall.DeleteProtection ?? false) !== desiredDeleteProtection
366 ) {
367 yield* nfw.updateFirewallDeleteProtection({
368 UpdateToken: observed.UpdateToken,
369 FirewallName: name,
370 DeleteProtection: desiredDeleteProtection,
371 });
372 yield* refresh();
373 }
374 const desiredSubnetChangeProtection =
375 news.subnetChangeProtection ?? false;
376 if (
377 (firewall.SubnetChangeProtection ?? false) !==
378 desiredSubnetChangeProtection
379 ) {
380 yield* nfw.updateSubnetChangeProtection({
381 UpdateToken: observed.UpdateToken,
382 FirewallName: name,
383 SubnetChangeProtection: desiredSubnetChangeProtection,
384 });
385 yield* refresh();
386 }
387 const desiredPolicyChangeProtection =
388 news.firewallPolicyChangeProtection ?? false;
389 if (
390 (firewall.FirewallPolicyChangeProtection ?? false) !==
391 desiredPolicyChangeProtection
392 ) {
393 yield* nfw.updateFirewallPolicyChangeProtection({
394 UpdateToken: observed.UpdateToken,
395 FirewallName: name,
396 FirewallPolicyChangeProtection: desiredPolicyChangeProtection,
397 });
398 yield* refresh();
399 }
400
401 // 3e. Tags — diff against OBSERVED cloud tags.
402 const observedTags = nfwTagsToRecord(firewall.Tags);
403 const { upsert, removed } = diffTags(observedTags, desiredTags);
404 const arn = firewall.FirewallArn!;
405 if (upsert.length > 0) {
406 yield* nfw.tagResource({ ResourceArn: arn, Tags: upsert });
407 }
408 if (removed.length > 0) {
409 yield* nfw.untagResource({ ResourceArn: arn, TagKeys: removed });
410 }
411
412 yield* session.note(name);
413 return toAttrs(firewall, observed.FirewallStatus);
414 }),
415
416 delete: Effect.fn(function* ({ output, session }) {
417 const name = output.firewallName;
418 const observed = yield* nfw
419 .describeFirewall({ FirewallName: name })
420 .pipe(
421 Effect.catchTag("ResourceNotFoundException", () =>
422 Effect.succeed(undefined),
423 ),
424 );
425 if (observed === undefined) return;
426
427 // Clear delete protection first — DeleteFirewall requires it off.
428 if (observed.Firewall?.DeleteProtection) {
429 yield* nfw
430 .updateFirewallDeleteProtection({
431 UpdateToken: observed.UpdateToken,
432 FirewallName: name,
433 DeleteProtection: false,
434 })
435 .pipe(
436 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
437 );
438 }
439
440 yield* session.note(
441 `deleting firewall ${name} (endpoints deprovision over several minutes)`,
442 );
443 yield* nfw.deleteFirewall({ FirewallName: name }).pipe(
444 retryWhileFirewallBusy,
445 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
446 );
447
448 // Wait until the firewall (and its endpoints) are fully gone so
449 // dependent deletions (subnets, VPC) don't hit DependencyViolations.
450 yield* Effect.gen(function* () {
451 const remaining = yield* nfw
452 .describeFirewall({ FirewallName: name })
453 .pipe(
454 Effect.catchTag("ResourceNotFoundException", () =>
455 Effect.succeed(undefined),
456 ),
457 );
458 if (remaining !== undefined) {
459 return yield* Effect.fail(
460 new FirewallNotDeleted({
461 message: `firewall '${name}' still deleting (status: ${remaining.FirewallStatus?.Status})`,
462 }),
463 );
464 }
465 }).pipe(retryWhileNotDeleted);
466 }),
467 });
468 }),
469 );