1import * as AWS from "@/AWS";
2import {
3 amazonLinux2023,
4 Instance,
5 NetworkInterface,
6 NetworkInterfaceAttachment,
7 SecurityGroup,
8 Subnet,
9 Volume,
10 VolumeAttachment,
11 Vpc,
12} from "@/AWS/EC2";
13import * as Test from "./VpcTest.ts";
14import * as EC2 from "@distilled.cloud/aws/ec2";
15import { expect } from "alchemy-test";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import { MinimumLogLevel } from "effect/References";
19import * as Schedule from "effect/Schedule";
20
21const { test } = Test.make({ providers: AWS.providers() });
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28const AZ = "us-west-2a";
29
30// A single cheap t3.micro instance hosts both the EBS volume attachment and the
31// ENI attachment so we pay for exactly one instance launch. The instance,
32// subnet, volume, and ENI all share one AZ (EBS/ENI attachment requires it).
33test.provider(
34 "attach a volume and an ENI to a t3.micro instance, then detach and delete",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38
39 const imageId = amazonLinux2023();
40
41 const deployed = yield* stack.deploy(
42 Effect.gen(function* () {
43 const vpc = yield* Vpc("StorageVpc", { cidrBlock: "10.90.0.0/16" });
44 const subnet = yield* Subnet("StorageSubnet", {
45 vpcId: vpc.vpcId,
46 cidrBlock: "10.90.1.0/24",
47 availabilityZone: AZ,
48 mapPublicIpOnLaunch: true,
49 });
50 const sg = yield* SecurityGroup("StorageSg", {
51 vpcId: vpc.vpcId,
52 description: "alchemy storage smoke test",
53 egress: [
54 {
55 ipProtocol: "-1",
56 cidrIpv4: "0.0.0.0/0",
57 description: "all outbound",
58 },
59 ],
60 });
61 const instance = yield* Instance("StorageInstance", {
62 imageId,
63 instanceType: "t3.micro",
64 subnetId: subnet.subnetId,
65 securityGroupIds: [sg.groupId],
66 });
67 const volume = yield* Volume("StorageVolume", {
68 availabilityZone: AZ,
69 size: 1,
70 volumeType: "gp3",
71 });
72 const volumeAttachment = yield* VolumeAttachment(
73 "StorageVolumeAttachment",
74 {
75 volumeId: volume.volumeId,
76 instanceId: instance.instanceId,
77 device: "/dev/sdf",
78 },
79 );
80 const eni = yield* NetworkInterface("StorageEni", {
81 subnetId: subnet.subnetId,
82 description: "alchemy storage smoke test secondary eni",
83 securityGroupIds: [sg.groupId],
84 });
85 const eniAttachment = yield* NetworkInterfaceAttachment(
86 "StorageEniAttachment",
87 {
88 networkInterfaceId: eni.networkInterfaceId,
89 instanceId: instance.instanceId,
90 deviceIndex: 1,
91 },
92 );
93 return {
94 instance,
95 volume,
96 volumeAttachment,
97 eni,
98 eniAttachment,
99 };
100 }),
101 );
102
103 const { instance, volume, volumeAttachment, eni, eniAttachment } =
104 deployed;
105
106 // Volume attachment reached 'attached'.
107 expect(volumeAttachment.state).toBe("attached");
108 expect(volumeAttachment.instanceId).toBe(instance.instanceId);
109
110 const observedVolume = yield* EC2.describeVolumes({
111 VolumeIds: [volume.volumeId],
112 });
113 const attachment = observedVolume.Volumes?.[0]?.Attachments?.find(
114 (a) => a.InstanceId === instance.instanceId,
115 );
116 expect(attachment?.State).toBe("attached");
117 expect(attachment?.Device).toBe("/dev/sdf");
118
119 // ENI attachment reached 'attached' on device index 1.
120 expect(eniAttachment.status).toBe("attached");
121 expect(eniAttachment.attachmentId).toMatch(/^eni-attach-/);
122
123 const observedEni = yield* EC2.describeNetworkInterfaces({
124 NetworkInterfaceIds: [eni.networkInterfaceId],
125 });
126 const eniAttach = observedEni.NetworkInterfaces?.[0]?.Attachment;
127 expect(eniAttach?.Status).toBe("attached");
128 expect(eniAttach?.InstanceId).toBe(instance.instanceId);
129 expect(eniAttach?.DeviceIndex).toBe(1);
130
131 // Tear everything down and verify the volume + ENI are gone out-of-band.
132 yield* stack.destroy();
133
134 yield* assertVolumeGone(volume.volumeId);
135 yield* assertEniGone(eni.networkInterfaceId);
136 }).pipe(logLevel),
137 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 240_000 },
138);
139
140const assertVolumeGone = Effect.fn(function* (volumeId: string) {
141 yield* EC2.describeVolumes({ VolumeIds: [volumeId] }).pipe(
142 Effect.flatMap((result) => {
143 const state = result.Volumes?.[0]?.State;
144 // `deleting` is terminal — EC2 can keep a deleted volume visible in
145 // this state for many minutes of internal bookkeeping.
146 return state === undefined || state === "deleted" || state === "deleting"
147 ? Effect.void
148 : Effect.fail(new StillExists());
149 }),
150 Effect.retry({
151 while: (e) => e instanceof StillExists,
152 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(20)]),
153 }),
154 Effect.catchTag("InvalidVolume.NotFound", () => Effect.void),
155 );
156});
157
158const assertEniGone = Effect.fn(function* (eniId: string) {
159 yield* EC2.describeNetworkInterfaces({
160 NetworkInterfaceIds: [eniId],
161 }).pipe(
162 Effect.flatMap(() => Effect.fail(new StillExists())),
163 Effect.retry({
164 while: (e) => e instanceof StillExists,
165 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(20)]),
166 }),
167 Effect.catchTag("InvalidNetworkInterfaceID.NotFound", () => Effect.void),
168 );
169});
170
171class StillExists extends Data.TaggedError("StillExists") {}