1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
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 { getDefaultVpcScope } from "./defaultVpcScope.ts";
15import type { VpcId } from "./Vpc.ts";
16
17export type DhcpOptionsId<ID extends string = string> = `dopt-${ID}`;
18
19export type DhcpOptionsArn<ID extends DhcpOptionsId = DhcpOptionsId> =
20 `arn:aws:ec2:${RegionID}:${AccountID}:dhcp-options/${ID}`;
21
22export interface DhcpOptionsProps {
23 /**
24 * The domain name for hosts in the VPC (e.g. `example.internal`). Immutable —
25 * a DHCP options set cannot be edited, so any change replaces it.
26 */
27 domainName?: string;
28
29 /**
30 * The IP addresses (up to four) of the domain name servers, or
31 * `AmazonProvidedDNS`. Immutable.
32 */
33 domainNameServers?: string[];
34
35 /**
36 * The IP addresses (up to four) of the NTP servers. Immutable.
37 */
38 ntpServers?: string[];
39
40 /**
41 * The IP addresses (up to four) of the NetBIOS name servers. Immutable.
42 */
43 netbiosNameServers?: string[];
44
45 /**
46 * The NetBIOS node type (1, 2, 4, or 8). AWS recommends 2. Immutable.
47 */
48 netbiosNodeType?: "1" | "2" | "4" | "8";
49
50 /**
51 * The ID of a VPC to associate this DHCP options set with. When set, the VPC
52 * is associated on create/update; on delete (or when this is cleared) the VPC
53 * is re-associated with the account's default options set. Mutable.
54 */
55 vpcId?: VpcId;
56
57 /**
58 * Tags to assign to the DHCP options set.
59 */
60 tags?: Record<string, string>;
61}
62
63export interface DhcpOptions extends Resource<
64 "AWS.EC2.DhcpOptions",
65 DhcpOptionsProps,
66 {
67 /**
68 * The ID of the DHCP options set (prefixed `dopt-`).
69 */
70 dhcpOptionsId: DhcpOptionsId;
71
72 /**
73 * The Amazon Resource Name (ARN) of the DHCP options set.
74 */
75 dhcpOptionsArn: DhcpOptionsArn;
76
77 /**
78 * The AWS account ID of the DHCP options set owner.
79 */
80 ownerId: string;
81
82 /**
83 * The ID of the VPC this options set is associated with, if any.
84 */
85 vpcId?: VpcId;
86 },
87 never,
88 Providers
89> {}
90
91/**
92 * A DHCP options set configures the DHCP parameters (domain name, DNS servers,
93 * NTP servers, NetBIOS settings) that a VPC hands out to the instances launched
94 * inside it. Attach a custom set to a VPC to override the AWS defaults — for
95 * example to point instances at your own DNS or an internal search domain.
96 *
97 * A DHCP options set is immutable: AWS provides no edit API, so changing any
98 * DHCP parameter replaces the set. Setting `vpcId` associates the set with a
99 * VPC; clearing it (or deleting the resource) re-associates the VPC with the
100 * account's default options set, since a set must be disassociated from every
101 * VPC before it can be deleted.
102 *
103 * ### Creating a DHCP Options Set
104 * **Example:** Custom DNS and Search Domain
105 * ```typescript
106 * const dhcp = yield* AWS.EC2.DhcpOptions("CorpDhcp", {
107 * domainName: "corp.internal",
108 * domainNameServers: ["10.0.0.2", "AmazonProvidedDNS"],
109 * vpcId: myVpc.vpcId,
110 * });
111 * ```
112 * Creates the options set and associates it with the VPC in one step.
113 * Instances launched into the VPC receive the `corp.internal` search domain and
114 * the listed DNS servers.
115 *
116 * **Example:** NTP and NetBIOS Configuration
117 * ```typescript
118 * const dhcp = yield* AWS.EC2.DhcpOptions("Dhcp", {
119 * ntpServers: ["169.254.169.123"],
120 * netbiosNameServers: ["10.0.0.5"],
121 * netbiosNodeType: "2",
122 * });
123 * ```
124 * Creates an unassociated options set that you can associate later by setting
125 * `vpcId`.
126 *
127 * @resource
128 */
129export const DhcpOptions = Resource<DhcpOptions>("AWS.EC2.DhcpOptions");
130
131class DhcpOptionsStillVisible extends Data.TaggedError(
132 "DhcpOptionsStillVisible",
133)<{
134 dhcpOptionsId: string;
135}> {}
136
137// Build the NewDhcpConfiguration list AWS expects from the flat props.
138const buildConfigurations = (
139 props: DhcpOptionsProps,
140): ec2.NewDhcpConfiguration[] => {
141 const configs: ec2.NewDhcpConfiguration[] = [];
142 if (props.domainName !== undefined) {
143 configs.push({ Key: "domain-name", Values: [props.domainName] });
144 }
145 if (props.domainNameServers !== undefined) {
146 configs.push({
147 Key: "domain-name-servers",
148 Values: props.domainNameServers,
149 });
150 }
151 if (props.ntpServers !== undefined) {
152 configs.push({ Key: "ntp-servers", Values: props.ntpServers });
153 }
154 if (props.netbiosNameServers !== undefined) {
155 configs.push({
156 Key: "netbios-name-servers",
157 Values: props.netbiosNameServers,
158 });
159 }
160 if (props.netbiosNodeType !== undefined) {
161 configs.push({
162 Key: "netbios-node-type",
163 Values: [props.netbiosNodeType],
164 });
165 }
166 return configs;
167};
168
169export const DhcpOptionsProvider = () =>
170 Provider.effect(
171 DhcpOptions,
172 Effect.gen(function* () {
173 const createTags = Effect.fn(function* (
174 id: string,
175 tags?: Record<string, string>,
176 ) {
177 return {
178 Name: id,
179 ...(yield* createInternalTags(id)),
180 ...tags,
181 };
182 });
183
184 const describeDhcpOptions = (dhcpOptionsId: string) =>
185 ec2.describeDhcpOptions({ DhcpOptionsIds: [dhcpOptionsId] }).pipe(
186 Effect.map((r) => r.DhcpOptions?.[0]),
187 Effect.catchTag("InvalidDhcpOptionID.NotFound", () =>
188 Effect.succeed(undefined),
189 ),
190 Effect.catchTag("InvalidDhcpOptionsID.NotFound", () =>
191 Effect.succeed(undefined),
192 ),
193 );
194
195 const waitUntilDhcpOptionsGone = (dhcpOptionsId: string) =>
196 describeDhcpOptions(dhcpOptionsId).pipe(
197 Effect.flatMap((options) =>
198 options === undefined
199 ? Effect.void
200 : Effect.fail(new DhcpOptionsStillVisible({ dhcpOptionsId })),
201 ),
202 Effect.retry({
203 while: (error) => error._tag === "DhcpOptionsStillVisible",
204 schedule: Schedule.max([Schedule.fixed(1000), Schedule.recurs(15)]),
205 }),
206 );
207
208 // Which VPC (if any) currently points at this options set.
209 const findAssociatedVpc = (dhcpOptionsId: string) =>
210 ec2
211 .describeVpcs({
212 Filters: [{ Name: "dhcp-options-id", Values: [dhcpOptionsId] }],
213 })
214 .pipe(Effect.map((r) => r.Vpcs?.[0]?.VpcId as VpcId | undefined));
215
216 const toAttrs = (opts: ec2.DhcpOptions, vpcId?: VpcId) =>
217 AWSEnvironment.current.pipe(
218 Effect.map((env) => ({
219 dhcpOptionsId: opts.DhcpOptionsId as DhcpOptionsId,
220 dhcpOptionsArn:
221 `arn:aws:ec2:${env.region}:${env.accountId}:dhcp-options/${opts.DhcpOptionsId}` as DhcpOptionsArn,
222 ownerId: opts.OwnerId!,
223 vpcId,
224 })),
225 );
226
227 return {
228 stables: ["dhcpOptionsId", "dhcpOptionsArn", "ownerId"],
229
230 list: () =>
231 Effect.gen(function* () {
232 const env = yield* AWSEnvironment.current;
233 // The options set the default VPC references is the account
234 // default AWS provisions; never census/nuke it.
235 const defaultVpc = yield* getDefaultVpcScope;
236 const items = yield* ec2.describeDhcpOptions.pages({}).pipe(
237 Stream.runCollect,
238 Effect.map((chunk) =>
239 Array.from(chunk).flatMap((page) =>
240 (page.DhcpOptions ?? [])
241 .filter(
242 (o): o is ec2.DhcpOptions & { DhcpOptionsId: string } =>
243 o.DhcpOptionsId != null,
244 )
245 .filter(
246 (o) =>
247 defaultVpc.dhcpOptionsId === undefined ||
248 o.DhcpOptionsId !== defaultVpc.dhcpOptionsId,
249 )
250 .map((o) => ({
251 dhcpOptionsId: o.DhcpOptionsId as DhcpOptionsId,
252 dhcpOptionsArn:
253 `arn:aws:ec2:${env.region}:${env.accountId}:dhcp-options/${o.DhcpOptionsId}` as DhcpOptionsArn,
254 ownerId: o.OwnerId!,
255 vpcId: undefined,
256 })),
257 ),
258 ),
259 );
260 return items satisfies DhcpOptions["Attributes"][];
261 }),
262
263 read: Effect.fn(function* ({ output }) {
264 if (!output) return undefined;
265 const opts = yield* describeDhcpOptions(output.dhcpOptionsId);
266 if (!opts) return undefined;
267 const vpcId = yield* findAssociatedVpc(output.dhcpOptionsId);
268 return yield* toAttrs(opts, vpcId);
269 }),
270
271 diff: Effect.fn(function* ({ news, olds }) {
272 if (!isResolved(news)) return;
273 // The DHCP configuration itself is immutable — a change replaces.
274 const eq = (a?: string[], b?: string[]) =>
275 JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
276 if (
277 news.domainName !== olds.domainName ||
278 !eq(news.domainNameServers, olds.domainNameServers) ||
279 !eq(news.ntpServers, olds.ntpServers) ||
280 !eq(news.netbiosNameServers, olds.netbiosNameServers) ||
281 news.netbiosNodeType !== olds.netbiosNodeType
282 ) {
283 return { action: "replace" };
284 }
285 // vpcId association and tags are mutable in place.
286 }),
287
288 reconcile: Effect.fn(function* ({ id, news, output, session }) {
289 const desiredTags = yield* createTags(id, news.tags);
290
291 // Observe — find the options set via the cached id, else create.
292 let opts: ec2.DhcpOptions | undefined;
293 if (output?.dhcpOptionsId) {
294 opts = yield* describeDhcpOptions(output.dhcpOptionsId);
295 }
296
297 // Ensure — create if missing.
298 if (opts === undefined) {
299 yield* session.note("Creating DHCP options set...");
300 const result = yield* ec2.createDhcpOptions({
301 DhcpConfigurations: buildConfigurations(news),
302 TagSpecifications: [
303 {
304 ResourceType: "dhcp-options",
305 Tags: createTagsList(desiredTags),
306 },
307 ],
308 });
309 opts = result.DhcpOptions!;
310 yield* session.note(
311 `DHCP options set created: ${opts.DhcpOptionsId}`,
312 );
313 }
314
315 const dhcpOptionsId = opts.DhcpOptionsId!;
316
317 // Sync association — associate the desired VPC, and detach any VPC we
318 // previously associated that is no longer desired (re-point it at the
319 // default options set).
320 const previousVpc = output?.vpcId;
321 if (news.vpcId) {
322 yield* ec2.associateDhcpOptions({
323 DhcpOptionsId: dhcpOptionsId,
324 VpcId: news.vpcId as string,
325 });
326 }
327 if (previousVpc && previousVpc !== news.vpcId) {
328 yield* ec2.associateDhcpOptions({
329 DhcpOptionsId: "default",
330 VpcId: previousVpc as string,
331 });
332 }
333
334 // Sync tags — observed cloud tags vs desired.
335 const currentTags =
336 (yield* ec2
337 .describeTags({
338 Filters: [
339 { Name: "resource-id", Values: [dhcpOptionsId] },
340 { Name: "resource-type", Values: ["dhcp-options"] },
341 ],
342 })
343 .pipe(
344 Effect.map(
345 (r) =>
346 Object.fromEntries(
347 r.Tags?.map((t) => [t.Key!, t.Value!]) ?? [],
348 ) as Record<string, string>,
349 ),
350 )) ?? {};
351 const { removed, upsert } = diffTags(currentTags, desiredTags);
352 if (removed.length > 0) {
353 yield* ec2.deleteTags({
354 Resources: [dhcpOptionsId],
355 Tags: removed.map((key) => ({ Key: key })),
356 });
357 }
358 if (upsert.length > 0) {
359 yield* ec2.createTags({
360 Resources: [dhcpOptionsId],
361 Tags: upsert,
362 });
363 }
364
365 return yield* toAttrs(opts, news.vpcId);
366 }),
367
368 delete: Effect.fn(function* ({ output, session }) {
369 const dhcpOptionsId = output.dhcpOptionsId;
370
371 // Disassociate any VPC first — a set cannot be deleted while attached.
372 if (output.vpcId) {
373 yield* session.note(
374 `Restoring default DHCP options on ${output.vpcId}...`,
375 );
376 yield* ec2
377 .associateDhcpOptions({
378 DhcpOptionsId: "default",
379 VpcId: output.vpcId as string,
380 })
381 .pipe(
382 Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void),
383 );
384 }
385
386 yield* session.note(`Deleting DHCP options set: ${dhcpOptionsId}`);
387 yield* ec2.deleteDhcpOptions({ DhcpOptionsId: dhcpOptionsId }).pipe(
388 Effect.catchTag("InvalidDhcpOptionID.NotFound", () => Effect.void),
389 Effect.catchTag("InvalidDhcpOptionsID.NotFound", () => Effect.void),
390 // A VPC association may still be clearing.
391 Effect.retry({
392 while: (e: { _tag: string }) => e._tag === "DependencyViolation",
393 schedule: Schedule.max([
394 Schedule.fixed(3000),
395 Schedule.recurs(20),
396 ]),
397 }),
398 );
399 yield* waitUntilDhcpOptionsGone(dhcpOptionsId);
400 }),
401 };
402 }),
403 );