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, somePropsAreDifferent } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createAlchemyTagFilters,
13 createInternalTags,
14 createTagsList,
15 diffTags,
16} from "../../Tags.ts";
17import type { AccountID } from "../Environment.ts";
18import { AWSEnvironment } from "../Environment.ts";
19import type { RegionID } from "../Region.ts";
20import type { Providers } from "../Providers.ts";
21
22export type VolumeId<ID extends string = string> = `vol-${ID}`;
23export const VolumeId = <ID extends string>(id: ID): ID & VolumeId<ID> =>
24 `vol-${id}` as ID & VolumeId<ID>;
25
26export type VolumeArn =
27 `arn:aws:ec2:${RegionID}:${AccountID}:volume/${VolumeId}`;
28
29export interface VolumeProps {
30 /**
31 * The Availability Zone in which to create the volume, e.g. `us-east-1a`.
32 * Required. An EBS volume is bound to a single AZ; changing it replaces the
33 * volume.
34 */
35 availabilityZone: string;
36
37 /**
38 * The size of the volume, in GiB. Required unless you create the volume from
39 * a `snapshotId` (in which case it defaults to the snapshot size). The size
40 * can be increased in place via `modifyVolume`; decreasing it replaces the
41 * volume.
42 */
43 size?: number;
44
45 /**
46 * The volume type.
47 * @default "gp3"
48 */
49 volumeType?: ec2.VolumeType;
50
51 /**
52 * The number of I/O operations per second (IOPS). Only valid for `io1`,
53 * `io2`, and `gp3` volumes. Mutable in place via `modifyVolume`.
54 */
55 iops?: number;
56
57 /**
58 * The throughput to provision, in MiB/s. Only valid for `gp3` volumes.
59 * Mutable in place via `modifyVolume`.
60 */
61 throughput?: number;
62
63 /**
64 * Whether the volume is encrypted. Changing this replaces the volume.
65 * @default false
66 */
67 encrypted?: boolean;
68
69 /**
70 * The identifier of the KMS key to use for encryption. Can be a key ID, key
71 * ARN, alias name (`alias/my-key`), or alias ARN. Implies `encrypted: true`.
72 * Changing this replaces the volume.
73 */
74 kmsKeyId?: string;
75
76 /**
77 * The snapshot from which to create the volume. Changing this replaces the
78 * volume.
79 */
80 snapshotId?: string;
81
82 /**
83 * Whether the volume can be attached to multiple instances (Multi-Attach).
84 * Only valid for `io1`/`io2` volumes.
85 * @default false
86 */
87 multiAttachEnabled?: boolean;
88
89 /**
90 * Tags to assign to the volume. Merged with alchemy auto-tags
91 * (alchemy::stack, alchemy::stage, alchemy::id).
92 */
93 tags?: Record<string, string>;
94}
95
96export interface Volume extends Resource<
97 "AWS.EC2.Volume",
98 VolumeProps,
99 {
100 /**
101 * The ID of the volume.
102 */
103 volumeId: VolumeId;
104
105 /**
106 * The Amazon Resource Name (ARN) of the volume.
107 */
108 volumeArn: VolumeArn;
109
110 /**
111 * The Availability Zone of the volume.
112 */
113 availabilityZone: string;
114
115 /**
116 * The size of the volume, in GiB.
117 */
118 size: number;
119
120 /**
121 * The volume type.
122 */
123 volumeType: ec2.VolumeType;
124
125 /**
126 * The number of IOPS provisioned for the volume.
127 */
128 iops?: number;
129
130 /**
131 * The throughput provisioned for the volume, in MiB/s.
132 */
133 throughput?: number;
134
135 /**
136 * Whether the volume is encrypted.
137 */
138 encrypted: boolean;
139
140 /**
141 * The KMS key used for encryption, if any.
142 */
143 kmsKeyId?: string;
144
145 /**
146 * The snapshot the volume was created from, if any.
147 */
148 snapshotId?: string;
149
150 /**
151 * Whether Multi-Attach is enabled.
152 */
153 multiAttachEnabled: boolean;
154
155 /**
156 * The current state of the volume.
157 */
158 state: ec2.VolumeState;
159 },
160 never,
161 Providers
162> {}
163/**
164 * An Elastic Block Store (EBS) volume — durable block storage you attach to an
165 * EC2 instance via a {@link VolumeAttachment}. Volumes live in a single
166 * Availability Zone and persist independently of any instance.
167 *
168 * Changing `availabilityZone`, `encrypted`, `kmsKeyId`, `snapshotId`, or
169 * shrinking `size` replaces the volume. Growing `size` and changing `iops`,
170 * `throughput`, or `volumeType` are applied in place via `modifyVolume` (note
171 * AWS enforces a 6-hour cooldown between volume modifications).
172 *
173 * ### Creating a Volume
174 * **Example:** Basic gp3 Volume
175 * ```typescript
176 * const volume = yield* AWS.EC2.Volume("DataVolume", {
177 * availabilityZone: "us-east-1a",
178 * size: 20,
179 * volumeType: "gp3",
180 * });
181 * ```
182 *
183 * The minimal volume: a 20 GiB general-purpose `gp3` volume in one AZ. It must
184 * be in the same AZ as the instance you attach it to.
185 *
186 * ### Provisioned Performance
187 * **Example:** gp3 with Provisioned IOPS and Throughput
188 * ```typescript
189 * const fast = yield* AWS.EC2.Volume("FastVolume", {
190 * availabilityZone: "us-east-1a",
191 * size: 100,
192 * volumeType: "gp3",
193 * iops: 6000,
194 * throughput: 250,
195 * });
196 * ```
197 *
198 * `gp3` decouples IOPS and throughput from size, so you can provision up to
199 * 16,000 IOPS and 1,000 MiB/s independently. Use `io2` for the highest
200 * durability and IOPS ceilings.
201 *
202 * ### Encryption
203 * **Example:** Encrypted Volume with a KMS Key
204 * ```typescript
205 * const secure = yield* AWS.EC2.Volume("SecureVolume", {
206 * availabilityZone: "us-east-1a",
207 * size: 20,
208 * encrypted: true,
209 * kmsKeyId: "alias/my-app-key",
210 * });
211 * ```
212 *
213 * Setting `kmsKeyId` implies encryption. Omit it while setting `encrypted:
214 * true` to use the account's default EBS KMS key.
215 *
216 * ### Creating from a Snapshot
217 * **Example:** Restore a Volume from a Snapshot
218 * ```typescript
219 * const restored = yield* AWS.EC2.Volume("RestoredVolume", {
220 * availabilityZone: "us-east-1a",
221 * snapshotId: snapshot.snapshotId,
222 * });
223 * ```
224 *
225 * When you create a volume from a snapshot, `size` defaults to the snapshot's
226 * size and can only be grown, never shrunk.
227 *
228 * @resource
229 */
230export const Volume = Resource<Volume>("AWS.EC2.Volume");
231
232export const VolumeProvider = () =>
233 Provider.effect(
234 Volume,
235 Effect.gen(function* () {
236 // EBS volume IDs are server-assigned. Recover a successfully created
237 // volume after state persistence failure via the resource instance tag;
238 // logical-id-only tags are insufficient during replacement because the
239 // old and new volume instances can coexist.
240 const findVolumeByInstanceTags = Effect.fn(function* (
241 id: string,
242 instanceId: string,
243 ) {
244 const filters = yield* createAlchemyTagFilters(id);
245 const pages = yield* ec2.describeVolumes
246 .pages({
247 Filters: [
248 ...filters,
249 { Name: "tag:alchemy::instance", Values: [instanceId] },
250 ],
251 })
252 .pipe(Stream.runCollect);
253 return Array.from(pages)
254 .flatMap((page) => page.Volumes ?? [])
255 .find(
256 (volume) =>
257 volume.State !== "deleting" && volume.State !== "deleted",
258 );
259 });
260
261 return {
262 stables: [
263 "volumeId",
264 "volumeArn",
265 "availabilityZone",
266 "encrypted",
267 "kmsKeyId",
268 "snapshotId",
269 ],
270
271 diff: Effect.fn(function* ({ news, olds }) {
272 if (!isResolved(news)) return;
273 if (
274 somePropsAreDifferent(olds, news, [
275 "availabilityZone",
276 "encrypted",
277 "kmsKeyId",
278 "snapshotId",
279 ])
280 ) {
281 return { action: "replace" };
282 }
283 // Shrinking a volume is not supported in place — replace.
284 if (
285 olds.size !== undefined &&
286 news.size !== undefined &&
287 news.size < olds.size
288 ) {
289 return { action: "replace" };
290 }
291 }),
292
293 reconcile: Effect.fn(function* ({
294 id,
295 instanceId,
296 news,
297 output,
298 session,
299 }) {
300 const { accountId, region } = yield* AWSEnvironment.current;
301 const alchemyTags = yield* createInternalTags(id);
302 const desiredTags = {
303 ...news.tags,
304 ...alchemyTags,
305 "alchemy::instance": instanceId,
306 };
307
308 // 1. OBSERVE — cloud state is authoritative; output is only an id
309 // cache. A volume in a terminal state is treated as missing.
310 let volume: ec2.Volume | undefined;
311 if (output?.volumeId) {
312 const lookup = yield* ec2
313 .describeVolumes({ VolumeIds: [output.volumeId] })
314 .pipe(
315 Effect.catchTag("InvalidVolume.NotFound", () =>
316 Effect.succeed({ Volumes: [] }),
317 ),
318 );
319 const found = lookup.Volumes?.[0];
320 if (
321 found &&
322 found.State !== "deleting" &&
323 found.State !== "deleted"
324 ) {
325 volume = found;
326 }
327 }
328 if (volume === undefined) {
329 volume = yield* findVolumeByInstanceTags(id, instanceId);
330 }
331
332 // 2. ENSURE — create the volume when missing, then wait until it is
333 // available.
334 if (volume === undefined) {
335 const created = yield* ec2.createVolume({
336 AvailabilityZone: news.availabilityZone,
337 Size: news.size,
338 VolumeType: news.volumeType ?? "gp3",
339 Iops: news.iops,
340 Throughput: news.throughput,
341 Encrypted:
342 news.encrypted ??
343 (news.kmsKeyId !== undefined ? true : undefined),
344 KmsKeyId: news.kmsKeyId,
345 SnapshotId: news.snapshotId,
346 MultiAttachEnabled: news.multiAttachEnabled,
347 TagSpecifications: [
348 {
349 ResourceType: "volume",
350 Tags: createTagsList(desiredTags),
351 },
352 ],
353 DryRun: false,
354 });
355 const volumeId = created.VolumeId! as VolumeId;
356 yield* session.note(`Volume created: ${volumeId}`);
357 volume = yield* waitForVolumeAvailable(volumeId, session);
358 }
359
360 const volumeId = volume.VolumeId! as VolumeId;
361
362 // 3. SYNC — grow size / adjust performance in place. Only include a
363 // field when the user specified it AND it drifts from observed
364 // cloud state, so a no-op reconcile never triggers the 6h
365 // modification cooldown.
366 const modify: ec2.ModifyVolumeRequest = { VolumeId: volumeId };
367 let needsModify = false;
368 if (news.size !== undefined && news.size > (volume.Size ?? 0)) {
369 modify.Size = news.size;
370 needsModify = true;
371 }
372 if (
373 news.volumeType !== undefined &&
374 news.volumeType !== volume.VolumeType
375 ) {
376 modify.VolumeType = news.volumeType;
377 needsModify = true;
378 }
379 if (news.iops !== undefined && news.iops !== volume.Iops) {
380 modify.Iops = news.iops;
381 needsModify = true;
382 }
383 if (
384 news.throughput !== undefined &&
385 news.throughput !== volume.Throughput
386 ) {
387 modify.Throughput = news.throughput;
388 needsModify = true;
389 }
390 if (needsModify) {
391 yield* ec2.modifyVolume(modify);
392 yield* session.note(`Volume modified: ${volumeId}`);
393 }
394
395 // 3b. SYNC TAGS — diff against observed cloud tags.
396 const currentTags = Object.fromEntries(
397 (volume.Tags ?? []).map((t) => [t.Key!, t.Value!]),
398 ) as Record<string, string>;
399 const { removed, upsert } = diffTags(currentTags, desiredTags);
400 if (removed.length > 0) {
401 yield* ec2.deleteTags({
402 Resources: [volumeId],
403 Tags: removed.map((key) => ({ Key: key })),
404 DryRun: false,
405 });
406 }
407 if (upsert.length > 0) {
408 yield* ec2.createTags({
409 Resources: [volumeId],
410 Tags: upsert,
411 DryRun: false,
412 });
413 }
414
415 // 4. RETURN fresh attributes.
416 const finalLookup = yield* ec2.describeVolumes({
417 VolumeIds: [volumeId],
418 });
419 const final = finalLookup.Volumes?.[0] ?? volume;
420 return toVolumeAttributes(final, region, accountId);
421 }),
422
423 // Enumerate every volume in the ambient account/region.
424 list: () =>
425 Effect.gen(function* () {
426 const { accountId, region } = yield* AWSEnvironment.current;
427 const chunk = yield* ec2.describeVolumes
428 .pages({})
429 .pipe(Stream.runCollect);
430 return Array.from(chunk).flatMap((page) =>
431 (page.Volumes ?? []).map((v) =>
432 toVolumeAttributes(v, region, accountId),
433 ),
434 );
435 }),
436
437 delete: Effect.fn(function* ({ output, session }) {
438 const volumeId = output.volumeId;
439 yield* session.note(`Deleting volume: ${volumeId}`);
440
441 yield* ec2.deleteVolume({ VolumeId: volumeId, DryRun: false }).pipe(
442 Effect.tapError(Effect.logDebug),
443 Effect.catchTag("InvalidVolume.NotFound", () => Effect.void),
444 // A volume that is still detaching reports VolumeInUse — retry
445 // until the detach completes.
446 Effect.retry({
447 while: (e) => e._tag === "VolumeInUse",
448 schedule: Schedule.max([
449 Schedule.fixed(3000),
450 Schedule.recurs(20),
451 ]).pipe(
452 Schedule.tap(({ attempt }) =>
453 session.note(
454 `Waiting for volume to detach... (attempt ${attempt + 1})`,
455 ),
456 ),
457 ),
458 }),
459 );
460
461 yield* waitForVolumeDeleted(volumeId, session);
462 yield* session.note(`Volume ${volumeId} deleted successfully`);
463 }),
464 };
465 }),
466 );
467
468const toVolumeAttributes = (
469 volume: ec2.Volume,
470 region: RegionID,
471 accountId: AccountID,
472): Volume["Attributes"] => {
473 const volumeId = volume.VolumeId! as VolumeId;
474 return {
475 volumeId,
476 volumeArn:
477 `arn:aws:ec2:${region}:${accountId}:volume/${volumeId}` as VolumeArn,
478 availabilityZone: volume.AvailabilityZone!,
479 size: volume.Size ?? 0,
480 volumeType: volume.VolumeType ?? "gp3",
481 iops: volume.Iops,
482 throughput: volume.Throughput,
483 encrypted: volume.Encrypted ?? false,
484 kmsKeyId: volume.KmsKeyId,
485 snapshotId:
486 volume.SnapshotId && volume.SnapshotId.length > 0
487 ? volume.SnapshotId
488 : undefined,
489 multiAttachEnabled: volume.MultiAttachEnabled ?? false,
490 state: volume.State ?? "creating",
491 };
492};
493
494class VolumeNotReady extends Data.TaggedError("VolumeNotReady")<{
495 volumeId: string;
496 state: string;
497}> {}
498
499class VolumeStillExists extends Data.TaggedError("VolumeStillExists")<{
500 volumeId: string;
501 state: string;
502 message: string;
503}> {}
504
505/**
506 * Wait for the volume to reach the `available` state.
507 */
508const waitForVolumeAvailable = (
509 volumeId: string,
510 session?: ScopedPlanStatusSession,
511) =>
512 Effect.gen(function* () {
513 const result = yield* ec2.describeVolumes({ VolumeIds: [volumeId] });
514 const volume = result.Volumes?.[0];
515 if (!volume) {
516 return yield* Effect.fail(new Error(`Volume ${volumeId} not found`));
517 }
518 if (volume.State === "available" || volume.State === "in-use") {
519 return volume;
520 }
521 if (volume.State === "error") {
522 return yield* Effect.fail(
523 new Error(`Volume ${volumeId} entered error state`),
524 );
525 }
526 return yield* new VolumeNotReady({ volumeId, state: volume.State! });
527 }).pipe(
528 Effect.retry({
529 while: (e) => e instanceof VolumeNotReady,
530 schedule: Schedule.max([
531 Schedule.fixed(2000),
532 Schedule.recurs(30), // max ~60s
533 ]).pipe(
534 Schedule.tap(({ attempt }) =>
535 session
536 ? session.note(
537 `Waiting for volume to be available... (${(attempt + 1) * 2}s)`,
538 )
539 : Effect.void,
540 ),
541 ),
542 }),
543 );
544
545/**
546 * Wait for the volume to be fully deleted.
547 */
548const waitForVolumeDeleted = (
549 volumeId: string,
550 session: ScopedPlanStatusSession,
551) =>
552 Effect.gen(function* () {
553 const result = yield* ec2
554 .describeVolumes({ VolumeIds: [volumeId] })
555 .pipe(
556 Effect.catchTag("InvalidVolume.NotFound", () =>
557 Effect.succeed({ Volumes: [] }),
558 ),
559 );
560 const volume = result.Volumes?.[0];
561 // Do not treat `deleting` as deleted. The provider's delete contract is
562 // complete only after EC2 stops returning the volume; otherwise nuke can
563 // report success while the resource remains in its inventory.
564 if (!volume || volume.State === "deleted") {
565 return;
566 }
567 return yield* new VolumeStillExists({
568 volumeId,
569 state: volume.State ?? "unknown",
570 message:
571 `Volume ${volumeId} remains ${volume.State ?? "visible"} after the bounded delete wait; ` +
572 "AWS accepted DeleteVolume but has not removed it",
573 });
574 }).pipe(
575 Effect.retry({
576 while: (e) => e instanceof VolumeStillExists,
577 schedule: Schedule.max([
578 Schedule.fixed(2000),
579 // DeleteVolume is accepted immediately but describe can keep
580 // returning `deleting` well past a minute after a snapshot (the
581 // suite log hit the old 60s cap with the volume still present).
582 Schedule.recurs(45),
583 ]).pipe(
584 Schedule.tap(({ attempt }) =>
585 session.note(
586 `Waiting for volume deletion... (${(attempt + 1) * 2}s)`,
587 ),
588 ),
589 ),
590 }),
591 );