import type { Credentials } from "@distilled.cloud/aws/Credentials"; import type { Region } from "@distilled.cloud/aws/Region"; import * as ec2 from "@distilled.cloud/aws/ec2"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import type { HttpClient } from "effect/unstable/http/HttpClient"; // Explicitly-typed pipeable retry helpers. Inlining `Effect.retry` in a // provider lifecycle op leaks `Retry.Return`'s conditional into declaration // emit and widens the provider layer to `unknown` R for every consumer of // `AWS.providers()`. const retryWhileVolumeInUse = ( self: Effect.Effect, ): Effect.Effect => Effect.retry(self, { while: (e) => e._tag === "VolumeInUse", schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(15)]), }); const retryWhileIncorrectState = ( self: Effect.Effect, ): Effect.Effect => Effect.retry(self, { while: (e) => e._tag === "IncorrectState", schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(10)]), }); import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts"; import { isResolved } from "../../Diff.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import type { Providers } from "../Providers.ts"; import type { InstanceId } from "./Instance.ts"; import type { VolumeId } from "./Volume.ts"; export interface VolumeAttachmentProps { /** * The ID of the EBS volume to attach. Required. Changing it replaces the * attachment. */ volumeId: VolumeId; /** * The ID of the instance to attach the volume to. Required. Changing it * replaces the attachment. */ instanceId: InstanceId; /** * The device name to expose to the instance, e.g. `/dev/sdf` (Linux) or * `xvdf`. Required. Changing it replaces the attachment. */ device: string; /** * Whether to force-detach the volume on delete if a normal detach does not * complete. Forcing can corrupt data if the volume is still mounted — use * only when the instance is unresponsive. * @default true */ forceDetach?: boolean; } export interface VolumeAttachment extends Resource< "AWS.EC2.VolumeAttachment", VolumeAttachmentProps, { /** * The ID of the attached volume. */ volumeId: VolumeId; /** * The ID of the instance the volume is attached to. */ instanceId: InstanceId; /** * The device name exposed to the instance. */ device: string; /** * The attachment state. */ state: ec2.VolumeAttachmentState; }, never, Providers > {} /** * Attaches an EBS {@link Volume} to an EC2 {@link Instance} at a device name. * The volume and instance must be in the same Availability Zone. On delete the * volume is detached (and force-detached as a fallback) before the resource is * removed. * * This is an existence-style resource — its identity is the * `volumeId`/`instanceId`/`device` triple. Changing any of them replaces the * attachment. * * ### Attaching a Volume * **Example:** Attach a Volume to an Instance * ```typescript * const attachment = yield* AWS.EC2.VolumeAttachment("DataAttachment", { * volumeId: volume.volumeId, * instanceId: instance.instanceId, * device: "/dev/sdf", * }); * ``` * * The volume appears to the instance as a block device at `device`. On modern * Linux AMIs the kernel may rename `/dev/sdf` to `/dev/xvdf` — check * `lsblk` inside the instance. The volume and instance must share an AZ. * * @resource */ export const VolumeAttachment = Resource( "AWS.EC2.VolumeAttachment", ); export const VolumeAttachmentProvider = () => Provider.effect( VolumeAttachment, Effect.gen(function* () { return { stables: ["volumeId", "instanceId", "device"], diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return; if ( olds.volumeId !== news.volumeId || olds.instanceId !== news.instanceId || olds.device !== news.device ) { return { action: "replace" }; } }), reconcile: Effect.fn(function* ({ news, session }) { // 1. OBSERVE — attachments are embedded in the volume; find one for // this instance. const lookup = yield* ec2 .describeVolumes({ VolumeIds: [news.volumeId] }) .pipe( Effect.catchTag("InvalidVolume.NotFound", () => Effect.succeed({ Volumes: [] }), ), ); const volume = lookup.Volumes?.[0]; let attachment = volume?.Attachments?.find( (a) => a.InstanceId === news.instanceId && a.State !== "detaching" && a.State !== "detached", ); // 2. ENSURE — attach the volume when it is not already attached to // this instance. if (attachment === undefined) { const result = yield* ec2 .attachVolume({ VolumeId: news.volumeId, InstanceId: news.instanceId, Device: news.device, DryRun: false, }) .pipe( // The volume can briefly report VolumeInUse while a prior // detach settles — retry until it frees up. retryWhileVolumeInUse, ); yield* session.note( `Volume ${news.volumeId} attaching to ${news.instanceId} at ${news.device}`, ); attachment = { VolumeId: result.VolumeId, InstanceId: result.InstanceId, Device: result.Device, State: result.State, }; } // 3. WAIT — until the attachment is fully attached. const state = yield* waitForAttachmentState( news.volumeId, news.instanceId, "attached", session, ); return { volumeId: news.volumeId, instanceId: news.instanceId, device: attachment.Device ?? news.device, state, }; }), // Attachments are embedded in volumes; there is no standalone // enumeration keyed to this resource's identity. list: () => Effect.succeed([]), delete: Effect.fn(function* ({ output, olds, session }) { const { volumeId, instanceId, device } = output; const force = olds?.forceDetach ?? true; yield* session.note( `Detaching volume ${volumeId} from ${instanceId}`, ); // If the volume is already gone, nothing to detach. const lookup = yield* ec2 .describeVolumes({ VolumeIds: [volumeId] }) .pipe( Effect.catchTag("InvalidVolume.NotFound", () => Effect.succeed({ Volumes: [] }), ), ); const volume = lookup.Volumes?.[0]; if (!volume) { return; } const attached = volume.Attachments?.some( (a) => a.InstanceId === instanceId && a.State !== "detached", ); if (!attached) { return; } // Normal detach, retrying while the volume is still in a transient // state (attaching / busy → IncorrectState). yield* ec2 .detachVolume({ VolumeId: volumeId, InstanceId: instanceId, Device: device, DryRun: false, }) .pipe( Effect.catchTag("InvalidVolume.NotFound", () => Effect.void), retryWhileIncorrectState, // Fall back to a forced detach if it still won't release. // Explicit return type: the ternary's branches are two // structurally different Effects and TS cannot unify them on // its own. Effect.catchTag( "IncorrectState", ( e, ): Effect.Effect< ec2.VolumeAttachment | void, ec2.DetachVolumeError, Credentials | Region | HttpClient > => force ? ec2 .detachVolume({ VolumeId: volumeId, InstanceId: instanceId, Device: device, Force: true, DryRun: false, }) .pipe( Effect.catchTag( "InvalidVolume.NotFound", () => Effect.void, ), Effect.catchTag("IncorrectState", () => Effect.void), ) : Effect.fail(e), ), ); // Wait for the volume to return to 'available'. yield* waitForVolumeDetached(volumeId, session); yield* session.note(`Volume ${volumeId} detached from ${instanceId}`); }), }; }), ); class AttachmentNotReady extends Data.TaggedError("AttachmentNotReady")<{ volumeId: string; state: string; }> {} class VolumeStillAttached extends Data.TaggedError("VolumeStillAttached")<{ volumeId: string; state: string; }> {} /** * Wait for the attachment of `volumeId` to `instanceId` to reach a target * state. */ const waitForAttachmentState = ( volumeId: string, instanceId: string, target: ec2.VolumeAttachmentState, session?: ScopedPlanStatusSession, ): Effect.Effect< ec2.VolumeAttachmentState, ec2.DescribeVolumesError | AttachmentNotReady, Credentials | Region | HttpClient > => Effect.gen(function* () { const result = yield* ec2.describeVolumes({ VolumeIds: [volumeId] }); const attachment = result.Volumes?.[0]?.Attachments?.find( (a) => a.InstanceId === instanceId, ); const state = attachment?.State; if (state === target) { return state; } return yield* new AttachmentNotReady({ volumeId, state: state ?? "unknown", }); }).pipe( Effect.retry({ while: (e) => e instanceof AttachmentNotReady, schedule: Schedule.max([ Schedule.fixed(2000), Schedule.recurs(20), // max ~40s ]).pipe( Schedule.tap(({ attempt }) => session ? session.note( `Waiting for volume attachment... (${(attempt + 1) * 2}s)`, ) : Effect.void, ), ), }), ); /** * Wait for the volume to return to `available` after a detach. */ const waitForVolumeDetached = ( volumeId: string, session?: ScopedPlanStatusSession, ) => Effect.gen(function* () { const result = yield* ec2 .describeVolumes({ VolumeIds: [volumeId] }) .pipe( Effect.catchTag("InvalidVolume.NotFound", () => Effect.succeed({ Volumes: [] }), ), ); const volume = result.Volumes?.[0]; if (!volume || volume.State === "available") { return; } return yield* new VolumeStillAttached({ volumeId, state: volume.State ?? "unknown", }); }).pipe( Effect.retry({ while: (e) => e instanceof VolumeStillAttached, schedule: Schedule.max([ Schedule.fixed(2000), Schedule.recurs(20), // max ~40s ]).pipe( Schedule.tap(({ attempt }) => session ? session.note( `Waiting for volume to detach... (${(attempt + 1) * 2}s)`, ) : Effect.void, ), ), }), );