1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { getDefaultVpcScope } from "./defaultVpcScope.ts";
11import type { NetworkAclId } from "./NetworkAcl.ts";
12import type { SubnetId } from "./Subnet.ts";
13
14export type NetworkAclAssociationId<ID extends string = string> =
15 `aclassoc-${ID}`;
16export const NetworkAclAssociationId = <ID extends string>(
17 id: ID,
18): ID & NetworkAclAssociationId<ID> =>
19 `aclassoc-${id}` as ID & NetworkAclAssociationId<ID>;
20
21export interface NetworkAclAssociationProps {
22 /**
23 * The ID of the new network ACL to associate with the subnet.
24 */
25 networkAclId: NetworkAclId;
26
27 /**
28 * The ID of the subnet to associate with the network ACL.
29 */
30 subnetId: SubnetId;
31}
32
33export interface NetworkAclAssociation extends Resource<
34 "AWS.EC2.NetworkAclAssociation",
35 NetworkAclAssociationProps,
36 {
37 /**
38 * The ID of the association between the network ACL and the subnet.
39 */
40 associationId: NetworkAclAssociationId;
41 /**
42 * The ID of the network ACL the subnet is associated with.
43 */
44 networkAclId: NetworkAclId;
45 /**
46 * The ID of the associated subnet.
47 */
48 subnetId: SubnetId;
49 },
50 never,
51 Providers
52> {}
53/**
54 * Associates a subnet with a `NetworkAcl`, replacing whichever ACL the subnet
55 * currently uses (every subnet is always associated with exactly one network
56 * ACL — the VPC's default until you point it at a custom one).
57 *
58 * Changing `subnetId` replaces the association, while changing only
59 * `networkAclId` re-points the same subnet at a different ACL in place. On
60 * delete, the subnet is reverted to the VPC's default network ACL so it is never
61 * left without one.
62 *
63 * ### Associating Subnets
64 * A subnet starts out attached to the VPC's default ACL; this resource moves it
65 * onto a custom ACL so the rules you defined with `NetworkAclEntry` take effect
66 * for that subnet.
67 * **Example:** Move a Subnet onto a Custom Network ACL
68 * ```typescript
69 * const association = yield* AWS.EC2.NetworkAclAssociation("PrivateSubnetNaclAssoc", {
70 * networkAclId: privateNetworkAcl.networkAclId,
71 * subnetId: privateSubnet.subnetId,
72 * });
73 * ```
74 * This detaches the subnet from the default ACL and attaches it to your custom
75 * ACL; destroying the association automatically reverts the subnet to the
76 * default ACL, which is the safe way to "remove" a custom ACL from a subnet.
77 *
78 * @resource
79 */
80export const NetworkAclAssociation = Resource<NetworkAclAssociation>(
81 "AWS.EC2.NetworkAclAssociation",
82);
83
84export const NetworkAclAssociationProvider = () =>
85 Provider.effect(
86 NetworkAclAssociation,
87 Effect.gen(function* () {
88 const findAssociation = (subnetId: string) =>
89 ec2
90 .describeNetworkAcls({
91 Filters: [{ Name: "association.subnet-id", Values: [subnetId] }],
92 })
93 .pipe(
94 Effect.map((r) => {
95 const acl = r.NetworkAcls?.[0];
96 const assoc = acl?.Associations?.find(
97 (a) => a.SubnetId === subnetId,
98 );
99 return assoc
100 ? {
101 associationId: assoc.NetworkAclAssociationId!,
102 networkAclId: assoc.NetworkAclId!,
103 subnetId: assoc.SubnetId!,
104 }
105 : undefined;
106 }),
107 );
108
109 return NetworkAclAssociation.Provider.of({
110 stables: ["subnetId"],
111
112 // NACL associations are embedded in describeNetworkAcls. Each NetworkAcl
113 // carries an Associations[] of {subnet, acl, associationId}; flatten
114 // every page's associations to enumerate them all in the region.
115 list: () =>
116 Effect.gen(function* () {
117 // Associations on the default VPC's default network ACL are
118 // furniture AWS auto-provisions for its subnets; never
119 // census/nuke them. Associations to user-created (non-default)
120 // ACLs inside the default VPC are still listed.
121 const defaultVpc = yield* getDefaultVpcScope;
122 return yield* ec2.describeNetworkAcls.pages({}).pipe(
123 Stream.runCollect,
124 Effect.map((chunk) =>
125 Array.from(chunk).flatMap((page) =>
126 (page.NetworkAcls ?? [])
127 .filter(
128 (acl) =>
129 defaultVpc.vpcId === undefined ||
130 !(acl.VpcId === defaultVpc.vpcId && acl.IsDefault),
131 )
132 .flatMap((acl) =>
133 (acl.Associations ?? [])
134 .filter(
135 (
136 a,
137 ): a is ec2.NetworkAclAssociation & {
138 NetworkAclAssociationId: string;
139 NetworkAclId: string;
140 SubnetId: string;
141 } =>
142 a.NetworkAclAssociationId != null &&
143 a.NetworkAclId != null &&
144 a.SubnetId != null,
145 )
146 .map((a) => ({
147 associationId:
148 a.NetworkAclAssociationId as NetworkAclAssociationId,
149 networkAclId: a.NetworkAclId as NetworkAclId,
150 subnetId: a.SubnetId as SubnetId,
151 })),
152 ),
153 ),
154 ),
155 );
156 }),
157
158 read: Effect.fn(function* ({ olds }) {
159 if (!olds) return undefined;
160 const assoc = yield* findAssociation(olds.subnetId as string);
161 if (!assoc) {
162 return yield* Effect.fail(
163 new Error(
164 `Network ACL Association not found for subnet ${olds.subnetId}`,
165 ),
166 );
167 }
168 return {
169 associationId: assoc.associationId as NetworkAclAssociationId,
170 networkAclId: assoc.networkAclId as NetworkAclId,
171 subnetId: assoc.subnetId as SubnetId,
172 };
173 }),
174
175 diff: Effect.fn(function* ({ news, olds }) {
176 if (!isResolved(news)) return;
177 // Subnet change requires replacement
178 if (news.subnetId !== olds.subnetId) {
179 return { action: "replace" };
180 }
181 // Network ACL change can be done via replaceNetworkAclAssociation
182 }),
183
184 reconcile: Effect.fn(function* ({ news, session }) {
185 // Observe — find the subnet's current association. EC2 guarantees
186 // every subnet always has exactly one NACL association, so the
187 // lookup always returns something for a live subnet.
188 const currentAssoc = yield* findAssociation(news.subnetId as string);
189 if (!currentAssoc) {
190 return yield* Effect.fail(
191 new Error(
192 `No existing Network ACL Association found for subnet ${news.subnetId}`,
193 ),
194 );
195 }
196
197 // Sync — if the subnet already points at the desired NACL, the
198 // association is already correct and we just report it. Otherwise
199 // ReplaceNetworkAclAssociation atomically swaps it.
200 if (currentAssoc.networkAclId === (news.networkAclId as string)) {
201 return {
202 associationId:
203 currentAssoc.associationId as NetworkAclAssociationId,
204 networkAclId: news.networkAclId as NetworkAclId,
205 subnetId: news.subnetId as SubnetId,
206 };
207 }
208
209 yield* session.note(
210 `Associating subnet ${news.subnetId} with NACL ${news.networkAclId}...`,
211 );
212 const result = yield* ec2.replaceNetworkAclAssociation({
213 AssociationId: currentAssoc.associationId,
214 NetworkAclId: news.networkAclId as string,
215 DryRun: false,
216 });
217 const newAssociationId = result.NewAssociationId!;
218 yield* session.note(`Network ACL Association: ${newAssociationId}`);
219 return {
220 associationId: newAssociationId as NetworkAclAssociationId,
221 networkAclId: news.networkAclId as NetworkAclId,
222 subnetId: news.subnetId as SubnetId,
223 };
224 }),
225
226 delete: Effect.fn(function* ({ olds, output, session }) {
227 yield* session.note(`Deleting Network ACL Association...`);
228
229 // When deleting, we need to associate the subnet back to the default NACL
230 // Find the default NACL for the VPC
231 const subnetResult = yield* ec2
232 .describeSubnets({
233 SubnetIds: [olds.subnetId as string],
234 })
235 .pipe(
236 // If subnet is already deleted, association is gone too
237 Effect.catchTag("InvalidSubnetID.NotFound", () =>
238 Effect.succeed({ Subnets: [] }),
239 ),
240 );
241 const vpcId = subnetResult.Subnets?.[0]?.VpcId;
242
243 if (!vpcId) {
244 // Subnet is already deleted, so the association is gone
245 yield* session.note(`Subnet already deleted, association is gone`);
246 return;
247 }
248
249 const defaultAcl = yield* ec2.describeNetworkAcls
250 .items({
251 Filters: [
252 { Name: "vpc-id", Values: [vpcId] },
253 { Name: "default", Values: ["true"] },
254 ],
255 })
256 .pipe(Stream.runHead, Effect.map(Option.getOrUndefined));
257
258 const defaultAclId = defaultAcl?.NetworkAclId;
259
260 if (defaultAclId && defaultAclId !== (olds.networkAclId as string)) {
261 // Replace with default NACL
262 yield* ec2
263 .replaceNetworkAclAssociation({
264 AssociationId: output.associationId,
265 NetworkAclId: defaultAclId,
266 DryRun: false,
267 })
268 .pipe(
269 Effect.catchTag(
270 "InvalidAssociationID.NotFound",
271 () => Effect.void,
272 ),
273 );
274
275 yield* session.note(`Network ACL Association reverted to default`);
276 } else {
277 yield* session.note(
278 `Already using default Network ACL, nothing to do`,
279 );
280 }
281 }),
282 });
283 }),
284 );