1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
10import type { AccountID } from "../Environment.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import type { RegionID } from "../Region.ts";
14import type { VpcId } from "./Vpc.ts";
15
16export type NetworkAclId<ID extends string = string> = `acl-${ID}`;
17export const NetworkAclId = <ID extends string>(
18 id: ID,
19): ID & NetworkAclId<ID> => `acl-${id}` as ID & NetworkAclId<ID>;
20
21export type NetworkAclArn<ID extends NetworkAclId = NetworkAclId> =
22 `arn:aws:ec2:${RegionID}:${AccountID}:network-acl/${ID}`;
23
24export interface NetworkAclProps {
25 /**
26 * The VPC to create the network ACL in.
27 */
28 vpcId: VpcId;
29
30 /**
31 * Tags to assign to the network ACL.
32 */
33 tags?: Record<string, string>;
34}
35
36export interface NetworkAcl extends Resource<
37 "AWS.EC2.NetworkAcl",
38 NetworkAclProps,
39 {
40 /**
41 * The ID of the network ACL.
42 */
43 networkAclId: NetworkAclId;
44 /**
45 * The Amazon Resource Name (ARN) of the network ACL.
46 */
47 networkAclArn: NetworkAclArn;
48 /**
49 * The ID of the VPC the network ACL belongs to.
50 */
51 vpcId: VpcId;
52 /**
53 * Whether this is the default network ACL for the VPC.
54 */
55 isDefault: boolean;
56 /**
57 * The ID of the AWS account that owns the network ACL.
58 */
59 ownerId: string;
60 /**
61 * The rule entries (inbound and outbound) currently defined on the ACL.
62 */
63 entries?: Array<{
64 /** The rule number; rules are evaluated lowest to highest. */
65 ruleNumber: number;
66 /** The protocol number ("-1" means all protocols). */
67 protocol: string;
68 /** Whether the rule allows or denies matching traffic. */
69 ruleAction: ec2.RuleAction;
70 /** Whether this is an egress (outbound) rule. */
71 egress: boolean;
72 /** The IPv4 CIDR block the rule applies to. */
73 cidrBlock?: string;
74 /** The IPv6 CIDR block the rule applies to. */
75 ipv6CidrBlock?: string;
76 /** The ICMP type and code, for ICMP rules. */
77 icmpTypeCode?: {
78 code?: number;
79 type?: number;
80 };
81 /** The port range, for TCP/UDP rules. */
82 portRange?: {
83 from?: number;
84 to?: number;
85 };
86 }>;
87 /**
88 * The subnet associations currently attached to the network ACL.
89 */
90 associations?: Array<{
91 /** The ID of the association between the ACL and the subnet. */
92 networkAclAssociationId: string;
93 /** The ID of the associated network ACL. */
94 networkAclId: string;
95 /** The ID of the associated subnet. */
96 subnetId: string;
97 }>;
98 },
99 never,
100 Providers
101> {}
102/**
103 * A network ACL — a stateless firewall that controls inbound and outbound
104 * traffic at the *subnet* level, evaluated as an ordered list of numbered
105 * allow/deny rules.
106 *
107 * Unlike security groups (which are stateful and attach to interfaces), a
108 * network ACL is associated with subnets and evaluates return traffic
109 * independently, so you typically pair each inbound rule with a matching
110 * ephemeral-port outbound rule. The ACL itself only takes `vpcId` and `tags`;
111 * the actual rules live in `NetworkAclEntry` resources and subnet attachments
112 * in `NetworkAclAssociation` resources. Changing `vpcId` replaces the ACL.
113 *
114 * ### Creating Network ACLs
115 * **Example:** Basic Network ACL
116 * ```typescript
117 * const acl = yield* AWS.EC2.NetworkAcl("PrivateNetworkAcl", {
118 * vpcId: vpc.vpcId,
119 * tags: { Name: "private-nacl" },
120 * });
121 * ```
122 * This creates an empty custom ACL in the VPC — it starts with only the implicit
123 * default-deny rules, so until you add entries it blocks all traffic on any
124 * subnet you associate with it.
125 *
126 * ### Composing Rules and Associations
127 * A network ACL is only useful once you attach rules and point subnets at it.
128 * The typical pattern is one `NetworkAcl`, several `NetworkAclEntry` rules, and
129 * one `NetworkAclAssociation` per subnet.
130 * **Example:** Network ACL with an Inbound Rule and Subnet Association
131 * ```typescript
132 * const acl = yield* AWS.EC2.NetworkAcl("PrivateNetworkAcl", {
133 * vpcId: vpc.vpcId,
134 * });
135 *
136 * const allowVpc = yield* AWS.EC2.NetworkAclEntry("AllowVpc", {
137 * networkAclId: acl.networkAclId,
138 * ruleNumber: 100,
139 * protocol: "-1",
140 * ruleAction: "allow",
141 * egress: false,
142 * cidrBlock: "10.0.0.0/16",
143 * });
144 *
145 * const association = yield* AWS.EC2.NetworkAclAssociation("SubnetAssoc", {
146 * networkAclId: acl.networkAclId,
147 * subnetId: privateSubnet.subnetId,
148 * });
149 * ```
150 * The entry allows all traffic from within the VPC CIDR and the association
151 * makes the subnet use this ACL instead of the VPC default. Build up the full
152 * rule set by adding more `NetworkAclEntry` resources with increasing
153 * `ruleNumber`s.
154 *
155 * @resource
156 */
157export const NetworkAcl = Resource<NetworkAcl>("AWS.EC2.NetworkAcl");
158
159export const NetworkAclProvider = () =>
160 Provider.effect(
161 NetworkAcl,
162 Effect.gen(function* () {
163 const createTags = Effect.fn(function* (
164 id: string,
165 tags?: Record<string, string>,
166 ) {
167 return {
168 Name: id,
169 ...(yield* createInternalTags(id)),
170 ...tags,
171 };
172 });
173
174 const describeNetworkAcl = (networkAclId: string) =>
175 ec2.describeNetworkAcls({ NetworkAclIds: [networkAclId] }).pipe(
176 Effect.map((r) => r.NetworkAcls?.[0]),
177 Effect.flatMap((acl) =>
178 acl
179 ? Effect.succeed(acl)
180 : Effect.fail(new Error(`Network ACL ${networkAclId} not found`)),
181 ),
182 );
183
184 const toAttrs = Effect.fn(function* (acl: ec2.NetworkAcl) {
185 const { accountId, region } = yield* AWSEnvironment.current;
186 return {
187 networkAclId: acl.NetworkAclId as NetworkAclId,
188 networkAclArn:
189 `arn:aws:ec2:${region}:${accountId}:network-acl/${acl.NetworkAclId}` as NetworkAclArn,
190 vpcId: acl.VpcId as VpcId,
191 isDefault: acl.IsDefault ?? false,
192 ownerId: acl.OwnerId!,
193 entries: acl.Entries?.map((e) => ({
194 ruleNumber: e.RuleNumber!,
195 protocol: e.Protocol!,
196 ruleAction: e.RuleAction!,
197 egress: e.Egress!,
198 cidrBlock: e.CidrBlock,
199 ipv6CidrBlock: e.Ipv6CidrBlock,
200 icmpTypeCode: e.IcmpTypeCode
201 ? {
202 code: e.IcmpTypeCode.Code,
203 type: e.IcmpTypeCode.Type,
204 }
205 : undefined,
206 portRange: e.PortRange
207 ? {
208 from: e.PortRange.From,
209 to: e.PortRange.To,
210 }
211 : undefined,
212 })),
213 associations: acl.Associations?.map((a) => ({
214 networkAclAssociationId: a.NetworkAclAssociationId!,
215 networkAclId: a.NetworkAclId!,
216 subnetId: a.SubnetId!,
217 })),
218 } satisfies NetworkAcl["Attributes"];
219 });
220
221 return {
222 stables: ["networkAclId", "networkAclArn", "ownerId", "isDefault"],
223
224 read: Effect.fn(function* ({ output }) {
225 if (!output) return undefined;
226 const acl = yield* describeNetworkAcl(output.networkAclId);
227 return yield* toAttrs(acl);
228 }),
229
230 list: () =>
231 Effect.gen(function* () {
232 // Filter default NACLs at the API: every VPC has one, they
233 // cannot be deleted, and paging through them under a concurrent
234 // suite stalls the list test past the per-test timeout — which
235 // then skips destroy and leaks the custom ACL + VPC.
236 const acls = yield* ec2.describeNetworkAcls
237 .pages({
238 Filters: [{ Name: "default", Values: ["false"] }],
239 })
240 .pipe(
241 Stream.runCollect,
242 Effect.map((chunk) =>
243 Array.from(chunk).flatMap((page) =>
244 (page.NetworkAcls ?? []).filter(
245 (acl): acl is ec2.NetworkAcl & { NetworkAclId: string } =>
246 acl.NetworkAclId != null && acl.IsDefault !== true,
247 ),
248 ),
249 ),
250 );
251 return yield* Effect.forEach(acls, (acl) => toAttrs(acl));
252 }),
253
254 diff: Effect.fn(function* ({ news, olds }) {
255 if (!isResolved(news)) return;
256 // VPC change requires replacement
257 if (news.vpcId !== olds.vpcId) {
258 return { action: "replace" };
259 }
260 // Tags can be updated in-place
261 }),
262
263 reconcile: Effect.fn(function* ({ id, news, output, session }) {
264 const desiredTags = yield* createTags(id, news.tags);
265
266 // Observe — find the NACL via cached id, else fall through to create.
267 let acl: ec2.NetworkAcl | undefined;
268 if (output?.networkAclId) {
269 const lookup = yield* ec2
270 .describeNetworkAcls({ NetworkAclIds: [output.networkAclId] })
271 .pipe(
272 Effect.catchTag("InvalidNetworkAclID.NotFound", () =>
273 Effect.succeed({ NetworkAcls: [] }),
274 ),
275 );
276 acl = lookup.NetworkAcls?.[0];
277 }
278
279 // Ensure — create the NACL when missing.
280 if (acl === undefined) {
281 yield* session.note("Creating Network ACL...");
282 const result = yield* ec2.createNetworkAcl({
283 VpcId: news.vpcId as string,
284 TagSpecifications: [
285 {
286 ResourceType: "network-acl",
287 Tags: createTagsList(desiredTags),
288 },
289 ],
290 DryRun: false,
291 });
292 const newAclId = result.NetworkAcl!.NetworkAclId!;
293 yield* session.note(`Network ACL created: ${newAclId}`);
294 acl = yield* describeNetworkAcl(newAclId);
295 }
296
297 const networkAclId = acl.NetworkAclId!;
298
299 // Sync tags — observed cloud tags vs desired.
300 const currentTags = Object.fromEntries(
301 (acl.Tags ?? []).map((t) => [t.Key!, t.Value!]),
302 ) as Record<string, string>;
303 const { removed, upsert } = diffTags(currentTags, desiredTags);
304 if (removed.length > 0) {
305 yield* ec2.deleteTags({
306 Resources: [networkAclId],
307 Tags: removed.map((key) => ({ Key: key })),
308 DryRun: false,
309 });
310 }
311 if (upsert.length > 0) {
312 yield* ec2.createTags({
313 Resources: [networkAclId],
314 Tags: upsert,
315 DryRun: false,
316 });
317 }
318
319 // Re-read final state.
320 const final = yield* describeNetworkAcl(networkAclId);
321 return yield* toAttrs(final);
322 }),
323
324 delete: Effect.fn(function* ({ output, session }) {
325 const networkAclId = output.networkAclId;
326
327 yield* session.note(`Deleting Network ACL: ${networkAclId}`);
328
329 yield* ec2
330 .deleteNetworkAcl({
331 NetworkAclId: networkAclId,
332 DryRun: false,
333 })
334 .pipe(
335 Effect.catchTag(
336 "InvalidNetworkAclID.NotFound",
337 () => Effect.void,
338 ),
339 // Retry on dependency violations (e.g., associations still being removed)
340 Effect.retry({
341 while: (e) => {
342 return e._tag === "DependencyViolation";
343 },
344 schedule: Schedule.max([
345 Schedule.exponential(1000, 1.5),
346 Schedule.recurs(15),
347 ]).pipe(
348 Schedule.tap(({ attempt }) =>
349 session.note(
350 `Waiting for dependencies to clear... (attempt ${attempt})`,
351 ),
352 ),
353 ),
354 }),
355 );
356
357 yield* session.note(`Network ACL ${networkAclId} deleted`);
358 }),
359 };
360 }),
361 );