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";
5
6import type { ScopedPlanStatusSession } from "../../Report.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import type { InstanceId } from "./Instance.ts";
12import type { NetworkInterfaceId } from "./NetworkInterface.ts";
13
14export type NetworkInterfaceAttachmentId<ID extends string = string> =
15 `eni-attach-${ID}`;
16
17export interface NetworkInterfaceAttachmentProps {
18 /**
19 * The ID of the network interface to attach. Required. Changing it replaces
20 * the attachment.
21 */
22 networkInterfaceId: NetworkInterfaceId;
23
24 /**
25 * The ID of the instance to attach the interface to. Required. Changing it
26 * replaces the attachment.
27 */
28 instanceId: InstanceId;
29
30 /**
31 * The device index for the interface on the instance (0 is the primary
32 * interface). Required. Changing it replaces the attachment.
33 */
34 deviceIndex: number;
35
36 /**
37 * Whether to force-detach the interface on delete if a normal detach does
38 * not complete.
39 * @default true
40 */
41 forceDetach?: boolean;
42}
43
44export interface NetworkInterfaceAttachment extends Resource<
45 "AWS.EC2.NetworkInterfaceAttachment",
46 NetworkInterfaceAttachmentProps,
47 {
48 /**
49 * The ID of the attachment.
50 */
51 attachmentId: NetworkInterfaceAttachmentId;
52
53 /**
54 * The ID of the attached network interface.
55 */
56 networkInterfaceId: NetworkInterfaceId;
57
58 /**
59 * The ID of the instance the interface is attached to.
60 */
61 instanceId: InstanceId;
62
63 /**
64 * The device index of the interface on the instance.
65 */
66 deviceIndex: number;
67
68 /**
69 * The attachment status.
70 */
71 status: ec2.AttachmentStatus;
72 },
73 never,
74 Providers
75> {}
76/**
77 * Attaches an {@link NetworkInterface} (ENI) to an EC2 {@link Instance} at a
78 * device index. The interface and instance must be in the same Availability
79 * Zone. On delete the interface is detached (force-detached as a fallback)
80 * before the resource is removed.
81 *
82 * This is an existence-style resource — its identity is the
83 * `networkInterfaceId`/`instanceId`/`deviceIndex` triple. Changing any of them
84 * replaces the attachment.
85 *
86 * ### Attaching a Network Interface
87 * **Example:** Attach a Secondary ENI to an Instance
88 * ```typescript
89 * const attachment = yield* AWS.EC2.NetworkInterfaceAttachment("SecondaryEni", {
90 * networkInterfaceId: eni.networkInterfaceId,
91 * instanceId: instance.instanceId,
92 * deviceIndex: 1,
93 * });
94 * ```
95 *
96 * Device index 0 is the instance's primary interface, so secondary interfaces
97 * use index 1 and up. The ENI's IPs and security groups now apply to the
98 * instance on that interface.
99 *
100 * @resource
101 */
102export const NetworkInterfaceAttachment = Resource<NetworkInterfaceAttachment>(
103 "AWS.EC2.NetworkInterfaceAttachment",
104);
105
106export const NetworkInterfaceAttachmentProvider = () =>
107 Provider.effect(
108 NetworkInterfaceAttachment,
109 Effect.gen(function* () {
110 return {
111 stables: [
112 "attachmentId",
113 "networkInterfaceId",
114 "instanceId",
115 "deviceIndex",
116 ],
117
118 diff: Effect.fn(function* ({ news, olds }) {
119 if (!isResolved(news)) return;
120 if (
121 olds.networkInterfaceId !== news.networkInterfaceId ||
122 olds.instanceId !== news.instanceId ||
123 olds.deviceIndex !== news.deviceIndex
124 ) {
125 return { action: "replace" };
126 }
127 }),
128
129 reconcile: Effect.fn(function* ({ news, output, session }) {
130 // 1. OBSERVE — the attachment lives on the interface.
131 const lookup = yield* ec2
132 .describeNetworkInterfaces({
133 NetworkInterfaceIds: [news.networkInterfaceId],
134 })
135 .pipe(
136 Effect.catchTag("InvalidNetworkInterfaceID.NotFound", () =>
137 Effect.succeed({ NetworkInterfaces: [] }),
138 ),
139 );
140 const eni = lookup.NetworkInterfaces?.[0];
141 const existing = eni?.Attachment;
142 const attachedToInstance =
143 existing?.InstanceId === news.instanceId &&
144 existing?.Status !== "detaching" &&
145 existing?.Status !== "detached";
146
147 // 2. ENSURE — attach when not already attached to this instance.
148 let attachmentId =
149 attachedToInstance && existing?.AttachmentId
150 ? (existing.AttachmentId as NetworkInterfaceAttachmentId)
151 : output?.attachmentId;
152 if (!attachedToInstance) {
153 const result = yield* ec2.attachNetworkInterface({
154 NetworkInterfaceId: news.networkInterfaceId,
155 InstanceId: news.instanceId,
156 DeviceIndex: news.deviceIndex,
157 DryRun: false,
158 });
159 attachmentId = result.AttachmentId! as NetworkInterfaceAttachmentId;
160 yield* session.note(
161 `Network interface ${news.networkInterfaceId} attaching to ${news.instanceId}`,
162 );
163 }
164
165 // 3. WAIT — until attached.
166 const status = yield* waitForEniAttachmentState(
167 news.networkInterfaceId,
168 "attached",
169 session,
170 );
171
172 return {
173 attachmentId: attachmentId!,
174 networkInterfaceId: news.networkInterfaceId,
175 instanceId: news.instanceId,
176 deviceIndex: news.deviceIndex,
177 status,
178 };
179 }),
180
181 // Attachments are embedded in interfaces; there is no standalone
182 // enumeration keyed to this resource's identity.
183 list: () => Effect.succeed([]),
184
185 delete: Effect.fn(function* ({ output, olds, session }) {
186 const { attachmentId, networkInterfaceId } = output;
187 const force = olds?.forceDetach ?? true;
188 yield* session.note(
189 `Detaching network interface ${networkInterfaceId} (${attachmentId})`,
190 );
191
192 yield* ec2
193 .detachNetworkInterface({
194 AttachmentId: attachmentId,
195 Force: force,
196 DryRun: false,
197 })
198 .pipe(
199 Effect.catchTag(
200 "InvalidAttachmentID.NotFound",
201 () => Effect.void,
202 ),
203 // The interface can still be in-use momentarily — retry.
204 Effect.retry({
205 while: (e) => e._tag === "DependencyViolation",
206 schedule: Schedule.max([
207 Schedule.fixed(3000),
208 Schedule.recurs(15),
209 ]),
210 }),
211 );
212
213 // Wait for the interface to return to 'available'.
214 yield* waitForEniDetached(networkInterfaceId, session);
215 yield* session.note(
216 `Network interface ${networkInterfaceId} detached`,
217 );
218 }),
219 };
220 }),
221 );
222
223class EniAttachmentPending extends Data.TaggedError("EniAttachmentPending")<{
224 networkInterfaceId: string;
225 status: string;
226}> {}
227
228class EniStillAttached extends Data.TaggedError("EniStillAttached")<{
229 networkInterfaceId: string;
230 status: string;
231}> {}
232
233/**
234 * Wait for the interface's attachment to reach a target status.
235 */
236const waitForEniAttachmentState = (
237 networkInterfaceId: string,
238 target: ec2.AttachmentStatus,
239 session?: ScopedPlanStatusSession,
240) =>
241 Effect.gen(function* () {
242 const result = yield* ec2.describeNetworkInterfaces({
243 NetworkInterfaceIds: [networkInterfaceId],
244 });
245 const status = result.NetworkInterfaces?.[0]?.Attachment?.Status;
246 if (status === target) {
247 return status;
248 }
249 return yield* new EniAttachmentPending({
250 networkInterfaceId,
251 status: status ?? "unknown",
252 });
253 }).pipe(
254 Effect.retry({
255 while: (e) => e instanceof EniAttachmentPending,
256 schedule: Schedule.max([
257 Schedule.fixed(2000),
258 Schedule.recurs(20), // max ~40s
259 ]).pipe(
260 Schedule.tap(({ attempt }) =>
261 session
262 ? session.note(
263 `Waiting for interface attachment... (${(attempt + 1) * 2}s)`,
264 )
265 : Effect.void,
266 ),
267 ),
268 }),
269 );
270
271/**
272 * Wait for the interface to return to `available` after a detach.
273 */
274const waitForEniDetached = (
275 networkInterfaceId: string,
276 session?: ScopedPlanStatusSession,
277) =>
278 Effect.gen(function* () {
279 const result = yield* ec2
280 .describeNetworkInterfaces({
281 NetworkInterfaceIds: [networkInterfaceId],
282 })
283 .pipe(
284 Effect.catchTag("InvalidNetworkInterfaceID.NotFound", () =>
285 Effect.succeed({ NetworkInterfaces: [] }),
286 ),
287 );
288 const eni = result.NetworkInterfaces?.[0];
289 if (!eni || eni.Status === "available" || eni.Attachment === undefined) {
290 return;
291 }
292 return yield* new EniStillAttached({
293 networkInterfaceId,
294 status: eni.Status ?? "unknown",
295 });
296 }).pipe(
297 Effect.retry({
298 while: (e) => e instanceof EniStillAttached,
299 schedule: Schedule.max([
300 Schedule.fixed(2000),
301 Schedule.recurs(20), // max ~40s
302 ]).pipe(
303 Schedule.tap(({ attempt }) =>
304 session
305 ? session.note(
306 `Waiting for interface to detach... (${(attempt + 1) * 2}s)`,
307 )
308 : Effect.void,
309 ),
310 ),
311 }),
312 );