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";
6
7import type { ScopedPlanStatusSession } from "../../Report.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
12import type { AccountID } from "../Environment.ts";
13import { AWSEnvironment } from "../Environment.ts";
14import type { RegionID } from "../Region.ts";
15import type { Providers } from "../Providers.ts";
16import type { SecurityGroupId } from "./SecurityGroup.ts";
17import type { SubnetId } from "./Subnet.ts";
18import type { VpcId } from "./Vpc.ts";
19
20export type NetworkInterfaceId<ID extends string = string> = `eni-${ID}`;
21export const NetworkInterfaceId = <ID extends string>(
22 id: ID,
23): ID & NetworkInterfaceId<ID> => `eni-${id}` as ID & NetworkInterfaceId<ID>;
24
25export type NetworkInterfaceArn =
26 `arn:aws:ec2:${RegionID}:${AccountID}:network-interface/${NetworkInterfaceId}`;
27
28export interface NetworkInterfaceProps {
29 /**
30 * The ID of the subnet to create the network interface in. Required.
31 * Changing it replaces the interface.
32 */
33 subnetId: SubnetId;
34
35 /**
36 * A description for the network interface. Mutable in place.
37 */
38 description?: string;
39
40 /**
41 * The primary private IPv4 address to assign. If omitted, AWS selects one
42 * from the subnet range. Changing it replaces the interface.
43 */
44 privateIpAddress?: string;
45
46 /**
47 * Additional (secondary) private IPv4 addresses to assign at creation.
48 */
49 privateIpAddresses?: string[];
50
51 /**
52 * The security groups to associate with the interface. Mutable in place.
53 */
54 securityGroupIds?: SecurityGroupId[];
55
56 /**
57 * Whether source/destination checking is enabled. Disable it for NAT
58 * instances and other appliances that forward traffic. Mutable in place.
59 * @default true
60 */
61 sourceDestCheck?: boolean;
62
63 /**
64 * The type of network interface.
65 * @default "interface"
66 */
67 interfaceType?: ec2.NetworkInterfaceCreationType;
68
69 /**
70 * Tags to assign to the network interface. Merged with alchemy auto-tags
71 * (alchemy::stack, alchemy::stage, alchemy::id).
72 */
73 tags?: Record<string, string>;
74}
75
76export interface NetworkInterface extends Resource<
77 "AWS.EC2.NetworkInterface",
78 NetworkInterfaceProps,
79 {
80 /**
81 * The ID of the network interface.
82 */
83 networkInterfaceId: NetworkInterfaceId;
84
85 /**
86 * The Amazon Resource Name (ARN) of the network interface.
87 */
88 networkInterfaceArn: NetworkInterfaceArn;
89
90 /**
91 * The ID of the subnet the interface is in.
92 */
93 subnetId: SubnetId;
94
95 /**
96 * The ID of the VPC the interface is in.
97 */
98 vpcId: VpcId;
99
100 /**
101 * The Availability Zone of the interface.
102 */
103 availabilityZone: string;
104
105 /**
106 * The primary private IPv4 address.
107 */
108 privateIpAddress?: string;
109
110 /**
111 * All private IPv4 addresses assigned to the interface.
112 */
113 privateIpAddresses: string[];
114
115 /**
116 * The MAC address of the interface.
117 */
118 macAddress?: string;
119
120 /**
121 * The security groups associated with the interface.
122 */
123 securityGroupIds: SecurityGroupId[];
124
125 /**
126 * Whether source/destination checking is enabled.
127 */
128 sourceDestCheck: boolean;
129
130 /**
131 * The current status of the interface.
132 */
133 status: ec2.NetworkInterfaceStatus;
134
135 /**
136 * The ID of the AWS account that owns the interface.
137 */
138 ownerId?: string;
139 },
140 never,
141 Providers
142> {}
143/**
144 * An Elastic Network Interface (ENI) — a virtual network card in a VPC subnet
145 * with its own private IPs, MAC address, and security groups. Attach one to an
146 * instance via a {@link NetworkInterfaceAttachment} for stable-IP and
147 * multi-homing patterns.
148 *
149 * Changing `subnetId` or the primary `privateIpAddress` replaces the interface.
150 * `description`, `securityGroupIds`, and `sourceDestCheck` are applied in place.
151 *
152 * ### Creating a Network Interface
153 * **Example:** Basic ENI
154 * ```typescript
155 * const eni = yield* AWS.EC2.NetworkInterface("AppEni", {
156 * subnetId: subnet.subnetId,
157 * description: "stable IP for the app server",
158 * securityGroupIds: [securityGroup.groupId],
159 * });
160 * ```
161 *
162 * The interface gets a private IP from the subnet's range. Its IP survives
163 * instance replacement — detach it from a failed instance and attach it to a
164 * new one to keep the same address.
165 *
166 * ### Fixed Private IP
167 * **Example:** ENI with a Fixed Private IP
168 * ```typescript
169 * const eni = yield* AWS.EC2.NetworkInterface("FixedIpEni", {
170 * subnetId: subnet.subnetId,
171 * privateIpAddress: "10.0.1.50",
172 * securityGroupIds: [securityGroup.groupId],
173 * });
174 * ```
175 *
176 * Pinning `privateIpAddress` gives the interface a predictable address —
177 * useful for appliances and services other resources reference by IP.
178 *
179 * ### Forwarding Appliances
180 * **Example:** ENI with Source/Dest Check Disabled
181 * ```typescript
182 * const eni = yield* AWS.EC2.NetworkInterface("NatEni", {
183 * subnetId: subnet.subnetId,
184 * sourceDestCheck: false,
185 * securityGroupIds: [securityGroup.groupId],
186 * });
187 * ```
188 *
189 * Disable `sourceDestCheck` when the interface belongs to a NAT instance,
190 * firewall, or router that forwards packets not addressed to itself.
191 *
192 * @resource
193 */
194export const NetworkInterface = Resource<NetworkInterface>(
195 "AWS.EC2.NetworkInterface",
196);
197
198export const NetworkInterfaceProvider = () =>
199 Provider.effect(
200 NetworkInterface,
201 Effect.gen(function* () {
202 return {
203 stables: [
204 "networkInterfaceId",
205 "networkInterfaceArn",
206 "subnetId",
207 "vpcId",
208 "availabilityZone",
209 "ownerId",
210 ],
211
212 diff: Effect.fn(function* ({ news, olds }) {
213 if (!isResolved(news)) return;
214 if (
215 olds.subnetId !== news.subnetId ||
216 olds.privateIpAddress !== news.privateIpAddress
217 ) {
218 return { action: "replace" };
219 }
220 }),
221
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const { accountId, region } = yield* AWSEnvironment.current;
224 const alchemyTags = yield* createInternalTags(id);
225 const desiredTags = { ...alchemyTags, ...news.tags };
226
227 // 1. OBSERVE — output is only an id cache.
228 let eni: ec2.NetworkInterface | undefined;
229 if (output?.networkInterfaceId) {
230 const lookup = yield* ec2
231 .describeNetworkInterfaces({
232 NetworkInterfaceIds: [output.networkInterfaceId],
233 })
234 .pipe(
235 Effect.catchTag("InvalidNetworkInterfaceID.NotFound", () =>
236 Effect.succeed({ NetworkInterfaces: [] }),
237 ),
238 );
239 eni = lookup.NetworkInterfaces?.[0];
240 }
241
242 // 2. ENSURE — create the interface when missing.
243 if (eni === undefined) {
244 const secondary = (news.privateIpAddresses ?? []).map((ip) => ({
245 Primary: false,
246 PrivateIpAddress: ip,
247 }));
248 const created = yield* ec2.createNetworkInterface({
249 SubnetId: news.subnetId,
250 Description: news.description,
251 PrivateIpAddress: news.privateIpAddress,
252 PrivateIpAddresses: secondary.length > 0 ? secondary : undefined,
253 Groups: news.securityGroupIds,
254 InterfaceType: news.interfaceType,
255 TagSpecifications: [
256 {
257 ResourceType: "network-interface",
258 Tags: createTagsList(desiredTags),
259 },
260 ],
261 DryRun: false,
262 });
263 eni = created.NetworkInterface!;
264 yield* session.note(
265 `Network interface created: ${eni.NetworkInterfaceId}`,
266 );
267 eni = yield* waitForNetworkInterface(
268 eni.NetworkInterfaceId!,
269 session,
270 );
271 }
272
273 const eniId = eni.NetworkInterfaceId! as NetworkInterfaceId;
274
275 // 3. SYNC — description, security groups, source/dest check.
276 if (
277 news.description !== undefined &&
278 news.description !== (eni.Description ?? "")
279 ) {
280 yield* ec2.modifyNetworkInterfaceAttribute({
281 NetworkInterfaceId: eniId,
282 Description: { Value: news.description },
283 DryRun: false,
284 });
285 yield* session.note("Updated network interface description");
286 }
287
288 if (news.securityGroupIds !== undefined) {
289 const observed = (eni.Groups ?? []).map((g) => g.GroupId!).sort();
290 const desired = [...news.securityGroupIds].sort();
291 if (JSON.stringify(observed) !== JSON.stringify(desired)) {
292 yield* ec2.modifyNetworkInterfaceAttribute({
293 NetworkInterfaceId: eniId,
294 Groups: news.securityGroupIds,
295 DryRun: false,
296 });
297 yield* session.note("Updated network interface security groups");
298 }
299 }
300
301 const desiredSourceDestCheck = news.sourceDestCheck ?? true;
302 if ((eni.SourceDestCheck ?? true) !== desiredSourceDestCheck) {
303 yield* ec2.modifyNetworkInterfaceAttribute({
304 NetworkInterfaceId: eniId,
305 SourceDestCheck: { Value: desiredSourceDestCheck },
306 DryRun: false,
307 });
308 yield* session.note(
309 `Updated source/dest check: ${desiredSourceDestCheck}`,
310 );
311 }
312
313 // 3b. SYNC TAGS — diff against observed cloud tags.
314 const currentTags = Object.fromEntries(
315 (eni.TagSet ?? []).map((t) => [t.Key!, t.Value!]),
316 ) as Record<string, string>;
317 const { removed, upsert } = diffTags(currentTags, desiredTags);
318 if (removed.length > 0) {
319 yield* ec2.deleteTags({
320 Resources: [eniId],
321 Tags: removed.map((key) => ({ Key: key })),
322 DryRun: false,
323 });
324 }
325 if (upsert.length > 0) {
326 yield* ec2.createTags({
327 Resources: [eniId],
328 Tags: upsert,
329 DryRun: false,
330 });
331 }
332
333 // 4. RETURN fresh attributes.
334 const finalLookup = yield* ec2.describeNetworkInterfaces({
335 NetworkInterfaceIds: [eniId],
336 });
337 const final = finalLookup.NetworkInterfaces?.[0] ?? eni;
338 return toNetworkInterfaceAttributes(final, region, accountId);
339 }),
340
341 // Enumerate every network interface in the ambient account/region.
342 list: () =>
343 Effect.gen(function* () {
344 const { accountId, region } = yield* AWSEnvironment.current;
345 const chunk = yield* ec2.describeNetworkInterfaces
346 .pages({})
347 .pipe(Stream.runCollect);
348 return Array.from(chunk).flatMap((page) =>
349 (page.NetworkInterfaces ?? []).map((n) =>
350 toNetworkInterfaceAttributes(n, region, accountId),
351 ),
352 );
353 }),
354
355 delete: Effect.fn(function* ({ output, session }) {
356 const eniId = output.networkInterfaceId;
357 yield* session.note(`Deleting network interface: ${eniId}`);
358 yield* ec2
359 .deleteNetworkInterface({
360 NetworkInterfaceId: eniId,
361 DryRun: false,
362 })
363 .pipe(
364 Effect.tapError(Effect.logDebug),
365 Effect.catchTag(
366 "InvalidNetworkInterfaceID.NotFound",
367 () => Effect.void,
368 ),
369 // A just-detached interface can briefly report InUse — retry
370 // until the attachment fully clears.
371 Effect.retry({
372 while: (e) => e._tag === "InvalidNetworkInterface.InUse",
373 schedule: Schedule.max([
374 Schedule.fixed(3000),
375 Schedule.recurs(20),
376 ]).pipe(
377 Schedule.tap(({ attempt }) =>
378 session.note(
379 `Waiting for interface to detach... (attempt ${attempt + 1})`,
380 ),
381 ),
382 ),
383 }),
384 );
385 yield* session.note(`Network interface ${eniId} deleted`);
386 }),
387 };
388 }),
389 );
390
391const toNetworkInterfaceAttributes = (
392 eni: ec2.NetworkInterface,
393 region: RegionID,
394 accountId: AccountID,
395): NetworkInterface["Attributes"] => {
396 const eniId = eni.NetworkInterfaceId! as NetworkInterfaceId;
397 return {
398 networkInterfaceId: eniId,
399 networkInterfaceArn:
400 `arn:aws:ec2:${region}:${accountId}:network-interface/${eniId}` as NetworkInterfaceArn,
401 subnetId: eni.SubnetId! as SubnetId,
402 vpcId: eni.VpcId! as VpcId,
403 availabilityZone: eni.AvailabilityZone!,
404 privateIpAddress: eni.PrivateIpAddress,
405 privateIpAddresses: (eni.PrivateIpAddresses ?? [])
406 .map((p) => p.PrivateIpAddress!)
407 .filter((ip): ip is string => ip !== undefined),
408 macAddress: eni.MacAddress,
409 securityGroupIds: (eni.Groups ?? [])
410 .map((g) => g.GroupId! as SecurityGroupId)
411 .filter((g): g is SecurityGroupId => g !== undefined),
412 sourceDestCheck: eni.SourceDestCheck ?? true,
413 status: eni.Status ?? "available",
414 ownerId: eni.OwnerId,
415 };
416};
417
418class NetworkInterfacePending extends Data.TaggedError(
419 "NetworkInterfacePending",
420)<{
421 networkInterfaceId: string;
422 status: string;
423}> {}
424
425/**
426 * Wait for the network interface to reach an `available` (or in-use) status.
427 */
428const waitForNetworkInterface = (
429 networkInterfaceId: string,
430 session?: ScopedPlanStatusSession,
431) =>
432 Effect.gen(function* () {
433 const result = yield* ec2.describeNetworkInterfaces({
434 NetworkInterfaceIds: [networkInterfaceId],
435 });
436 const eni = result.NetworkInterfaces?.[0];
437 if (!eni) {
438 return yield* Effect.fail(
439 new Error(`Network interface ${networkInterfaceId} not found`),
440 );
441 }
442 if (eni.Status === "available" || eni.Status === "in-use") {
443 return eni;
444 }
445 return yield* new NetworkInterfacePending({
446 networkInterfaceId,
447 status: eni.Status!,
448 });
449 }).pipe(
450 Effect.retry({
451 while: (e) => e instanceof NetworkInterfacePending,
452 schedule: Schedule.max([
453 Schedule.fixed(2000),
454 Schedule.recurs(20), // max ~40s
455 ]).pipe(
456 Schedule.tap(({ attempt }) =>
457 session
458 ? session.note(
459 `Waiting for network interface... (${(attempt + 1) * 2}s)`,
460 )
461 : Effect.void,
462 ),
463 ),
464 }),
465 );