1import type { Credentials } from "@distilled.cloud/aws/Credentials";
2import type { Region } from "@distilled.cloud/aws/Region";
3import * as ec2 from "@distilled.cloud/aws/ec2";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import type { HttpClient } from "effect/http/HttpClient";
8
9// Explicitly-typed pipeable retry helpers. Inlining `Effect.retry` in a
10// provider lifecycle op leaks `Retry.Return`'s conditional into declaration
11// emit and widens the provider layer to `unknown` R for every consumer of
12// `AWS.providers()`.
13const retryWhileVolumeInUse = <A, E extends { readonly _tag: string }, R>(
14 self: Effect.Effect<A, E, R>,
15): Effect.Effect<A, E, R> =>
16 Effect.retry(self, {
17 while: (e) => e._tag === "VolumeInUse",
18 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(15)]),
19 });
20
21const retryWhileIncorrectState = <A, E extends { readonly _tag: string }, R>(
22 self: Effect.Effect<A, E, R>,
23): Effect.Effect<A, E, R> =>
24 Effect.retry(self, {
25 while: (e) => e._tag === "IncorrectState",
26 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(10)]),
27 });
28
29import type { ScopedPlanStatusSession } from "../../Report.ts";
30import { isResolved } from "../../Diff.ts";
31import * as Provider from "../../Provider.ts";
32import { Resource } from "../../Resource.ts";
33import type { Providers } from "../Providers.ts";
34import type { InstanceId } from "./Instance.ts";
35import type { VolumeId } from "./Volume.ts";
36
37export interface VolumeAttachmentProps {
38 /**
39 * The ID of the EBS volume to attach. Required. Changing it replaces the
40 * attachment.
41 */
42 volumeId: VolumeId;
43
44 /**
45 * The ID of the instance to attach the volume to. Required. Changing it
46 * replaces the attachment.
47 */
48 instanceId: InstanceId;
49
50 /**
51 * The device name to expose to the instance, e.g. `/dev/sdf` (Linux) or
52 * `xvdf`. Required. Changing it replaces the attachment.
53 */
54 device: string;
55
56 /**
57 * Whether to force-detach the volume on delete if a normal detach does not
58 * complete. Forcing can corrupt data if the volume is still mounted — use
59 * only when the instance is unresponsive.
60 * @default true
61 */
62 forceDetach?: boolean;
63}
64
65export interface VolumeAttachment extends Resource<
66 "AWS.EC2.VolumeAttachment",
67 VolumeAttachmentProps,
68 {
69 /**
70 * The ID of the attached volume.
71 */
72 volumeId: VolumeId;
73
74 /**
75 * The ID of the instance the volume is attached to.
76 */
77 instanceId: InstanceId;
78
79 /**
80 * The device name exposed to the instance.
81 */
82 device: string;
83
84 /**
85 * The attachment state.
86 */
87 state: ec2.VolumeAttachmentState;
88 },
89 never,
90 Providers
91> {}
92/**
93 * Attaches an EBS {@link Volume} to an EC2 {@link Instance} at a device name.
94 * The volume and instance must be in the same Availability Zone. On delete the
95 * volume is detached (and force-detached as a fallback) before the resource is
96 * removed.
97 *
98 * This is an existence-style resource — its identity is the
99 * `volumeId`/`instanceId`/`device` triple. Changing any of them replaces the
100 * attachment.
101 *
102 * ### Attaching a Volume
103 * **Example:** Attach a Volume to an Instance
104 * ```typescript
105 * const attachment = yield* AWS.EC2.VolumeAttachment("DataAttachment", {
106 * volumeId: volume.volumeId,
107 * instanceId: instance.instanceId,
108 * device: "/dev/sdf",
109 * });
110 * ```
111 *
112 * The volume appears to the instance as a block device at `device`. On modern
113 * Linux AMIs the kernel may rename `/dev/sdf` to `/dev/xvdf` — check
114 * `lsblk` inside the instance. The volume and instance must share an AZ.
115 *
116 * @resource
117 */
118export const VolumeAttachment = Resource<VolumeAttachment>(
119 "AWS.EC2.VolumeAttachment",
120);
121
122export const VolumeAttachmentProvider = () =>
123 Provider.effect(
124 VolumeAttachment,
125 Effect.gen(function* () {
126 return {
127 stables: ["volumeId", "instanceId", "device"],
128
129 diff: Effect.fn(function* ({ news, olds }) {
130 if (!isResolved(news)) return;
131 if (
132 olds.volumeId !== news.volumeId ||
133 olds.instanceId !== news.instanceId ||
134 olds.device !== news.device
135 ) {
136 return { action: "replace" };
137 }
138 }),
139
140 reconcile: Effect.fn(function* ({ news, session }) {
141 // 1. OBSERVE — attachments are embedded in the volume; find one for
142 // this instance.
143 const lookup = yield* ec2
144 .describeVolumes({ VolumeIds: [news.volumeId] })
145 .pipe(
146 Effect.catchTag("InvalidVolume.NotFound", () =>
147 Effect.succeed({ Volumes: [] }),
148 ),
149 );
150 const volume = lookup.Volumes?.[0];
151 let attachment = volume?.Attachments?.find(
152 (a) =>
153 a.InstanceId === news.instanceId &&
154 a.State !== "detaching" &&
155 a.State !== "detached",
156 );
157
158 // 2. ENSURE — attach the volume when it is not already attached to
159 // this instance.
160 if (attachment === undefined) {
161 const result = yield* ec2
162 .attachVolume({
163 VolumeId: news.volumeId,
164 InstanceId: news.instanceId,
165 Device: news.device,
166 DryRun: false,
167 })
168 .pipe(
169 // The volume can briefly report VolumeInUse while a prior
170 // detach settles — retry until it frees up.
171 retryWhileVolumeInUse,
172 );
173 yield* session.note(
174 `Volume ${news.volumeId} attaching to ${news.instanceId} at ${news.device}`,
175 );
176 attachment = {
177 VolumeId: result.VolumeId,
178 InstanceId: result.InstanceId,
179 Device: result.Device,
180 State: result.State,
181 };
182 }
183
184 // 3. WAIT — until the attachment is fully attached.
185 const state = yield* waitForAttachmentState(
186 news.volumeId,
187 news.instanceId,
188 "attached",
189 session,
190 );
191
192 return {
193 volumeId: news.volumeId,
194 instanceId: news.instanceId,
195 device: attachment.Device ?? news.device,
196 state,
197 };
198 }),
199
200 // Attachments are embedded in volumes; there is no standalone
201 // enumeration keyed to this resource's identity.
202 list: () => Effect.succeed([]),
203
204 delete: Effect.fn(function* ({ output, olds, session }) {
205 const { volumeId, instanceId, device } = output;
206 const force = olds?.forceDetach ?? true;
207 yield* session.note(
208 `Detaching volume ${volumeId} from ${instanceId}`,
209 );
210
211 // If the volume is already gone, nothing to detach.
212 const lookup = yield* ec2
213 .describeVolumes({ VolumeIds: [volumeId] })
214 .pipe(
215 Effect.catchTag("InvalidVolume.NotFound", () =>
216 Effect.succeed({ Volumes: [] }),
217 ),
218 );
219 const volume = lookup.Volumes?.[0];
220 if (!volume) {
221 return;
222 }
223 const attached = volume.Attachments?.some(
224 (a) => a.InstanceId === instanceId && a.State !== "detached",
225 );
226 if (!attached) {
227 return;
228 }
229
230 // Normal detach, retrying while the volume is still in a transient
231 // state (attaching / busy → IncorrectState).
232 yield* ec2
233 .detachVolume({
234 VolumeId: volumeId,
235 InstanceId: instanceId,
236 Device: device,
237 DryRun: false,
238 })
239 .pipe(
240 Effect.catchTag("InvalidVolume.NotFound", () => Effect.void),
241 retryWhileIncorrectState,
242 // Fall back to a forced detach if it still won't release.
243 // Explicit return type: the ternary's branches are two
244 // structurally different Effects and TS cannot unify them on
245 // its own.
246 Effect.catchTag(
247 "IncorrectState",
248 (
249 e,
250 ): Effect.Effect<
251 ec2.VolumeAttachment | void,
252 ec2.DetachVolumeError,
253 Credentials | Region | HttpClient
254 > =>
255 force
256 ? ec2
257 .detachVolume({
258 VolumeId: volumeId,
259 InstanceId: instanceId,
260 Device: device,
261 Force: true,
262 DryRun: false,
263 })
264 .pipe(
265 Effect.catchTag(
266 "InvalidVolume.NotFound",
267 () => Effect.void,
268 ),
269 Effect.catchTag("IncorrectState", () => Effect.void),
270 )
271 : Effect.fail(e),
272 ),
273 );
274
275 // Wait for the volume to return to 'available'.
276 yield* waitForVolumeDetached(volumeId, session);
277 yield* session.note(`Volume ${volumeId} detached from ${instanceId}`);
278 }),
279 };
280 }),
281 );
282
283class AttachmentNotReady extends Data.TaggedError("AttachmentNotReady")<{
284 volumeId: string;
285 state: string;
286}> {}
287
288class VolumeStillAttached extends Data.TaggedError("VolumeStillAttached")<{
289 volumeId: string;
290 state: string;
291}> {}
292
293/**
294 * Wait for the attachment of `volumeId` to `instanceId` to reach a target
295 * state.
296 */
297const waitForAttachmentState = (
298 volumeId: string,
299 instanceId: string,
300 target: ec2.VolumeAttachmentState,
301 session?: ScopedPlanStatusSession,
302): Effect.Effect<
303 ec2.VolumeAttachmentState,
304 ec2.DescribeVolumesError | AttachmentNotReady,
305 Credentials | Region | HttpClient
306> =>
307 Effect.gen(function* () {
308 const result = yield* ec2.describeVolumes({ VolumeIds: [volumeId] });
309 const attachment = result.Volumes?.[0]?.Attachments?.find(
310 (a) => a.InstanceId === instanceId,
311 );
312 const state = attachment?.State;
313 if (state === target) {
314 return state;
315 }
316 return yield* new AttachmentNotReady({
317 volumeId,
318 state: state ?? "unknown",
319 });
320 }).pipe(
321 Effect.retry({
322 while: (e) => e instanceof AttachmentNotReady,
323 schedule: Schedule.max([
324 Schedule.fixed(2000),
325 Schedule.recurs(20), // max ~40s
326 ]).pipe(
327 Schedule.tap(({ attempt }) =>
328 session
329 ? session.note(
330 `Waiting for volume attachment... (${(attempt + 1) * 2}s)`,
331 )
332 : Effect.void,
333 ),
334 ),
335 }),
336 );
337
338/**
339 * Wait for the volume to return to `available` after a detach.
340 */
341const waitForVolumeDetached = (
342 volumeId: string,
343 session?: ScopedPlanStatusSession,
344) =>
345 Effect.gen(function* () {
346 const result = yield* ec2
347 .describeVolumes({ VolumeIds: [volumeId] })
348 .pipe(
349 Effect.catchTag("InvalidVolume.NotFound", () =>
350 Effect.succeed({ Volumes: [] }),
351 ),
352 );
353 const volume = result.Volumes?.[0];
354 if (!volume || volume.State === "available") {
355 return;
356 }
357 return yield* new VolumeStillAttached({
358 volumeId,
359 state: volume.State ?? "unknown",
360 });
361 }).pipe(
362 Effect.retry({
363 while: (e) => e instanceof VolumeStillAttached,
364 schedule: Schedule.max([
365 Schedule.fixed(2000),
366 Schedule.recurs(20), // max ~40s
367 ]).pipe(
368 Schedule.tap(({ attempt }) =>
369 session
370 ? session.note(
371 `Waiting for volume to detach... (${(attempt + 1) * 2}s)`,
372 )
373 : Effect.void,
374 ),
375 ),
376 }),
377 );