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";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
9import type { AccountID } from "../Environment.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import type { RegionID } from "../Region.ts";
13
14export type PrefixListId<ID extends string = string> = `pl-${ID}`;
15export const PrefixListId = <ID extends string>(
16 id: ID,
17): ID & PrefixListId<ID> => `pl-${id}` as ID & PrefixListId<ID>;
18
19export type PrefixListArn<ID extends PrefixListId = PrefixListId> =
20 `arn:aws:ec2:${RegionID}:${AccountID}:prefix-list/${ID}`;
21
22/**
23 * A single CIDR entry in a managed prefix list.
24 */
25export interface PrefixListEntry {
26 /**
27 * The CIDR block for this entry. Must match the list's `addressFamily`
28 * (an IPv4 CIDR for `IPv4`, an IPv6 CIDR for `IPv6`).
29 */
30 cidr: string;
31 /**
32 * An optional description for the entry (up to 255 characters).
33 */
34 description?: string;
35}
36
37export interface PrefixListProps {
38 /**
39 * A name for the prefix list. If omitted, a unique name is generated from
40 * the app, stage, and logical ID. Up to 255 characters. Mutable.
41 */
42 prefixListName?: string;
43
44 /**
45 * The IP address family of the prefix list. Determines the CIDR format
46 * accepted in `entries`. Immutable — changing it replaces the list.
47 * @default "IPv4"
48 */
49 addressFamily?: "IPv4" | "IPv6";
50
51 /**
52 * The maximum number of entries the prefix list can hold. Required by AWS.
53 * Can be increased in place (not decreased) — a decrease replaces the list.
54 */
55 maxEntries: number;
56
57 /**
58 * The CIDR entries in the prefix list. Added/removed in place via versioned
59 * modifications.
60 * @default []
61 */
62 entries?: PrefixListEntry[];
63
64 /**
65 * Tags to assign to the prefix list.
66 */
67 tags?: Record<string, string>;
68}
69
70export interface PrefixList extends Resource<
71 "AWS.EC2.PrefixList",
72 PrefixListProps,
73 {
74 /**
75 * The ID of the prefix list (prefixed `pl-`).
76 */
77 prefixListId: PrefixListId;
78
79 /**
80 * The Amazon Resource Name (ARN) of the prefix list.
81 */
82 prefixListArn: PrefixListArn;
83
84 /**
85 * The name of the prefix list.
86 */
87 prefixListName: string;
88
89 /**
90 * The IP address family of the prefix list.
91 */
92 addressFamily: "IPv4" | "IPv6";
93
94 /**
95 * The maximum number of entries the prefix list can hold.
96 */
97 maxEntries: number;
98
99 /**
100 * The current version of the prefix list. Incremented on every entry
101 * modification.
102 */
103 version: number;
104
105 /**
106 * The AWS account ID of the prefix list owner.
107 */
108 ownerId: string;
109 },
110 never,
111 Providers
112> {}
113
114/**
115 * A managed prefix list is a named, reusable set of CIDR blocks that you
116 * reference by ID from security group rules and route tables. Instead of
117 * duplicating the same IP ranges across many rules, you maintain them in one
118 * place and every rule that references the list picks up changes automatically.
119 *
120 * The list is versioned: each entry modification bumps `version`, and AWS
121 * limits a list to `maxEntries` CIDRs (you provision headroom up front and can
122 * only grow it, never shrink it, in place). `addressFamily` fixes whether the
123 * list holds IPv4 or IPv6 CIDRs and is immutable.
124 *
125 * ### Creating a Prefix List
126 * **Example:** Basic IPv4 Prefix List
127 * ```typescript
128 * const corpNetworks = yield* AWS.EC2.PrefixList("CorpNetworks", {
129 * maxEntries: 10,
130 * entries: [
131 * { cidr: "10.0.0.0/16", description: "vpc-a" },
132 * { cidr: "10.1.0.0/16", description: "vpc-b" },
133 * ],
134 * });
135 * ```
136 * Creates a prefix list with two IPv4 CIDRs. The resulting `prefixListId`
137 * (prefixed `pl-`) can be referenced from security group rules and routes.
138 *
139 * **Example:** IPv6 Prefix List
140 * ```typescript
141 * const ipv6List = yield* AWS.EC2.PrefixList("Ipv6List", {
142 * addressFamily: "IPv6",
143 * maxEntries: 5,
144 * entries: [{ cidr: "2001:db8::/32" }],
145 * });
146 * ```
147 * `addressFamily: "IPv6"` makes the list accept IPv6 CIDRs. Because the family
148 * is intrinsic to the list, changing it later replaces the resource.
149 *
150 * ### Referencing a Prefix List from a Security Group Rule
151 * **Example:** Allow Inbound from a Prefix List
152 * ```typescript
153 * const corpNetworks = yield* AWS.EC2.PrefixList("CorpNetworks", {
154 * maxEntries: 10,
155 * entries: [{ cidr: "10.0.0.0/16" }],
156 * });
157 *
158 * const rule = yield* AWS.EC2.SecurityGroupRule("AllowCorp", {
159 * groupId: sg.groupId,
160 * type: "ingress",
161 * ipProtocol: "tcp",
162 * fromPort: 443,
163 * toPort: 443,
164 * prefixListId: corpNetworks.prefixListId,
165 * });
166 * ```
167 * The rule allows HTTPS from every CIDR in the list. Editing the list's
168 * `entries` updates what the rule permits without touching the rule itself.
169 *
170 * @resource
171 */
172export const PrefixList = Resource<PrefixList>("AWS.EC2.PrefixList");
173
174// A prefix list is stable (safe to modify/delete) only when its state ends in
175// "-complete". "-in-progress" states are transient; "-failed" is terminal.
176const isStableState = (state: string | undefined) =>
177 state === undefined || state.endsWith("-complete");
178
179export const PrefixListProvider = () =>
180 Provider.effect(
181 PrefixList,
182 Effect.gen(function* () {
183 const createTags = Effect.fn(function* (
184 id: string,
185 tags?: Record<string, string>,
186 ) {
187 return {
188 Name: id,
189 ...(yield* createInternalTags(id)),
190 ...tags,
191 };
192 });
193
194 const describePrefixList = (prefixListId: string) =>
195 ec2.describeManagedPrefixLists({ PrefixListIds: [prefixListId] }).pipe(
196 Effect.map((r) => r.PrefixLists?.[0]),
197 Effect.catchTag("InvalidPrefixListID.NotFound", () =>
198 Effect.succeed(undefined),
199 ),
200 );
201
202 // Poll until the list leaves any "-in-progress" state.
203 const waitStable = (prefixListId: string) =>
204 describePrefixList(prefixListId).pipe(
205 Effect.flatMap((pl) =>
206 isStableState(pl?.State)
207 ? Effect.succeed(pl)
208 : Effect.fail(new PrefixListNotStable()),
209 ),
210 Effect.retry({
211 while: (e) => e instanceof PrefixListNotStable,
212 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(30)]),
213 }),
214 );
215
216 const getEntries = (prefixListId: string) =>
217 ec2.getManagedPrefixListEntries
218 .pages({ PrefixListId: prefixListId })
219 .pipe(
220 Stream.runCollect,
221 Effect.map((chunk) =>
222 Array.from(chunk).flatMap((page) => page.Entries ?? []),
223 ),
224 );
225
226 const toAttrs = (pl: ec2.ManagedPrefixList) =>
227 AWSEnvironment.current.pipe(
228 Effect.map((env) => ({
229 prefixListId: pl.PrefixListId as PrefixListId,
230 prefixListArn:
231 (pl.PrefixListArn as PrefixListArn) ??
232 (`arn:aws:ec2:${env.region}:${env.accountId}:prefix-list/${pl.PrefixListId}` as PrefixListArn),
233 prefixListName: pl.PrefixListName!,
234 addressFamily: pl.AddressFamily as "IPv4" | "IPv6",
235 maxEntries: pl.MaxEntries!,
236 version: pl.Version!,
237 ownerId: pl.OwnerId!,
238 })),
239 );
240
241 return {
242 stables: ["prefixListId", "prefixListArn", "addressFamily", "ownerId"],
243
244 list: () =>
245 Effect.gen(function* () {
246 const env = yield* AWSEnvironment.current;
247 const items = yield* ec2.describeManagedPrefixLists.pages({}).pipe(
248 Stream.runCollect,
249 Effect.map((chunk) =>
250 Array.from(chunk).flatMap((page) =>
251 (page.PrefixLists ?? [])
252 // Skip AWS-managed lists (e.g. com.amazonaws.*), which
253 // are owned by "AWS" and have no PrefixListName we set.
254 .filter(
255 (
256 pl,
257 ): pl is ec2.ManagedPrefixList & {
258 PrefixListId: string;
259 } => pl.PrefixListId != null && pl.OwnerId !== "AWS",
260 )
261 .map((pl) => ({
262 prefixListId: pl.PrefixListId as PrefixListId,
263 prefixListArn:
264 (pl.PrefixListArn as PrefixListArn) ??
265 (`arn:aws:ec2:${env.region}:${env.accountId}:prefix-list/${pl.PrefixListId}` as PrefixListArn),
266 prefixListName: pl.PrefixListName!,
267 addressFamily: pl.AddressFamily as "IPv4" | "IPv6",
268 maxEntries: pl.MaxEntries!,
269 version: pl.Version!,
270 ownerId: pl.OwnerId!,
271 })),
272 ),
273 ),
274 );
275 return items satisfies PrefixList["Attributes"][];
276 }),
277
278 read: Effect.fn(function* ({ output }) {
279 if (!output) return undefined;
280 const pl = yield* describePrefixList(output.prefixListId);
281 if (!pl) return undefined;
282 return yield* toAttrs(pl);
283 }),
284
285 diff: Effect.fn(function* ({ news, olds }) {
286 if (!isResolved(news)) return;
287 const oldFamily = olds.addressFamily ?? "IPv4";
288 const newFamily = news.addressFamily ?? "IPv4";
289 if (oldFamily !== newFamily) {
290 return { action: "replace" };
291 }
292 // MaxEntries can only be increased in place; a decrease requires
293 // replacement.
294 if (news.maxEntries < olds.maxEntries) {
295 return { action: "replace" };
296 }
297 // prefixListName, maxEntries (increase), entries, tags → in-place.
298 }),
299
300 reconcile: Effect.fn(function* ({ id, news, output, session }) {
301 const desiredTags = yield* createTags(id, news.tags);
302 const desiredName =
303 news.prefixListName ?? output?.prefixListName ?? id;
304 const desiredFamily = news.addressFamily ?? "IPv4";
305 const desiredEntries = news.entries ?? [];
306
307 // Observe — find the list via the cached id, else fall through.
308 let pl: ec2.ManagedPrefixList | undefined;
309 if (output?.prefixListId) {
310 pl = yield* describePrefixList(output.prefixListId);
311 }
312
313 // Ensure — create if missing.
314 if (pl === undefined) {
315 yield* session.note("Creating managed prefix list...");
316 const result = yield* ec2.createManagedPrefixList({
317 PrefixListName: desiredName,
318 AddressFamily: desiredFamily,
319 MaxEntries: news.maxEntries,
320 Entries: desiredEntries.map((e) => ({
321 Cidr: e.cidr,
322 Description: e.description,
323 })),
324 TagSpecifications: [
325 {
326 ResourceType: "prefix-list",
327 Tags: createTagsList(desiredTags),
328 },
329 ],
330 });
331 const created = result.PrefixList!;
332 yield* session.note(
333 `Managed prefix list created: ${created.PrefixListId}`,
334 );
335 pl = yield* waitStable(created.PrefixListId!);
336 }
337
338 const prefixListId = pl!.PrefixListId!;
339
340 // Sync max-entries — an increase is a standalone modify (AWS forbids
341 // changing size and entries in the same call).
342 pl = yield* waitStable(prefixListId);
343 if ((pl?.MaxEntries ?? 0) < news.maxEntries) {
344 yield* session.note(
345 `Increasing prefix list max entries to ${news.maxEntries}...`,
346 );
347 yield* ec2.modifyManagedPrefixList({
348 PrefixListId: prefixListId,
349 CurrentVersion: pl!.Version,
350 MaxEntries: news.maxEntries,
351 });
352 pl = yield* waitStable(prefixListId);
353 }
354
355 // Sync entries + name — diff observed entries against desired.
356 const currentEntries = yield* getEntries(prefixListId);
357 const currentByCidr = new Map(
358 currentEntries.map((e) => [e.Cidr!, e.Description ?? ""]),
359 );
360 const desiredByCidr = new Map(
361 desiredEntries.map((e) => [e.cidr, e.description ?? ""]),
362 );
363
364 const addEntries = desiredEntries
365 .filter(
366 (e) =>
367 !currentByCidr.has(e.cidr) ||
368 currentByCidr.get(e.cidr) !== (e.description ?? ""),
369 )
370 .map((e) => ({ Cidr: e.cidr, Description: e.description }));
371 const removeEntries = currentEntries
372 .filter((e) => !desiredByCidr.has(e.Cidr!))
373 .map((e) => ({ Cidr: e.Cidr! }));
374 const nameChanged = (pl?.PrefixListName ?? "") !== desiredName;
375
376 if (
377 addEntries.length > 0 ||
378 removeEntries.length > 0 ||
379 nameChanged
380 ) {
381 yield* session.note("Updating managed prefix list entries...");
382 yield* ec2.modifyManagedPrefixList({
383 PrefixListId: prefixListId,
384 CurrentVersion: pl!.Version,
385 ...(nameChanged ? { PrefixListName: desiredName } : {}),
386 ...(addEntries.length > 0 ? { AddEntries: addEntries } : {}),
387 ...(removeEntries.length > 0
388 ? { RemoveEntries: removeEntries }
389 : {}),
390 });
391 pl = yield* waitStable(prefixListId);
392 }
393
394 // Sync tags — observed cloud tags vs desired.
395 const currentTags =
396 (yield* ec2
397 .describeTags({
398 Filters: [
399 { Name: "resource-id", Values: [prefixListId] },
400 { Name: "resource-type", Values: ["prefix-list"] },
401 ],
402 })
403 .pipe(
404 Effect.map(
405 (r) =>
406 Object.fromEntries(
407 r.Tags?.map((t) => [t.Key!, t.Value!]) ?? [],
408 ) as Record<string, string>,
409 ),
410 )) ?? {};
411 const { removed, upsert } = diffTags(currentTags, desiredTags);
412 if (removed.length > 0) {
413 yield* ec2.deleteTags({
414 Resources: [prefixListId],
415 Tags: removed.map((key) => ({ Key: key })),
416 });
417 }
418 if (upsert.length > 0) {
419 yield* ec2.createTags({ Resources: [prefixListId], Tags: upsert });
420 }
421
422 const final = yield* describePrefixList(prefixListId);
423 return yield* toAttrs(final!);
424 }),
425
426 delete: Effect.fn(function* ({ output, session }) {
427 const prefixListId = output.prefixListId;
428 yield* session.note(`Deleting managed prefix list: ${prefixListId}`);
429 yield* ec2
430 .deleteManagedPrefixList({ PrefixListId: prefixListId })
431 .pipe(
432 Effect.catchTag(
433 "InvalidPrefixListID.NotFound",
434 () => Effect.void,
435 ),
436 // Routes / SG rules still referencing the list surface as a
437 // DependencyViolation while they tear down.
438 Effect.retry({
439 while: (e: { _tag: string }) =>
440 e._tag === "DependencyViolation",
441 schedule: Schedule.max([
442 Schedule.fixed(5000),
443 Schedule.recurs(20),
444 ]),
445 }),
446 );
447 }),
448 };
449 }),
450 );
451
452class PrefixListNotStable {
453 readonly _tag = "PrefixListNotStable";
454}