1import * as AWS from "@/AWS";
2import { Volume } from "@/AWS/EC2";
3import { Alias, Key, type AliasName } from "@/AWS/KMS";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as EC2 from "@distilled.cloud/aws/ec2";
7import * as kms from "@distilled.cloud/aws/kms";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21// The testing account is in us-west-2.
22const AZ = "us-west-2a";
23
24test.provider(
25 "create, verify, and delete a gp3 volume",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29
30 const volume = yield* stack.deploy(
31 Effect.gen(function* () {
32 return yield* Volume("TestGp3Volume", {
33 availabilityZone: AZ,
34 size: 1,
35 volumeType: "gp3",
36 });
37 }),
38 );
39
40 expect(volume.volumeId).toMatch(/^vol-/);
41 expect(volume.availabilityZone).toBe(AZ);
42 expect(volume.volumeType).toBe("gp3");
43 expect(volume.size).toBe(1);
44
45 // Out-of-band verification.
46 const observed = yield* EC2.describeVolumes({
47 VolumeIds: [volume.volumeId],
48 });
49 const v = observed.Volumes?.[0];
50 expect(v?.VolumeType).toBe("gp3");
51 expect(v?.Size).toBe(1);
52 expect(v?.AvailabilityZone).toBe(AZ);
53 expect(v?.State === "available" || v?.State === "creating").toBe(true);
54 expect(
55 v?.Tags?.find((tag) => tag.Key === "alchemy::instance")?.Value,
56 ).toBeTruthy();
57
58 // list() enumerates the deployed volume.
59 const provider = yield* Provider.findProvider(Volume);
60 const all = yield* provider.list();
61 expect(all.some((x) => x.volumeId === volume.volumeId)).toBe(true);
62
63 yield* stack.destroy();
64 yield* assertVolumeDeleted(volume.volumeId);
65 }).pipe(logLevel),
66 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
67);
68
69class AliasStillExists extends Data.TaggedError("AliasStillExists") {}
70class KeyNotPendingDeletion extends Data.TaggedError("KeyNotPendingDeletion") {}
71
72const kmsObservationSchedule = Schedule.max([
73 Schedule.fixed("1 second"),
74 Schedule.recurs(30),
75]);
76
77/**
78 * Idempotent out-of-band backstop for the stack-managed fixture. KMS keys
79 * cannot be hard-deleted, so the clean terminal state is alias NotFound plus
80 * key PendingDeletion. The stack provider remains the owner; this finalizer
81 * closes failure/timeout gaps and observes both terminal states before return.
82 */
83const releaseManagedKey = (
84 aliasName: AliasName | undefined,
85 keyId: string | undefined,
86) =>
87 Effect.gen(function* () {
88 let targetKeyId = keyId;
89 if (aliasName !== undefined) {
90 const viaAlias = yield* kms.describeKey({ KeyId: aliasName }).pipe(
91 Effect.map((response) => response.KeyMetadata),
92 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
93 );
94 targetKeyId ??= viaAlias?.KeyId;
95
96 yield* kms.deleteAlias({ AliasName: aliasName }).pipe(
97 Effect.retry({
98 while: (error) =>
99 error._tag === "DependencyTimeoutException" ||
100 error._tag === "KMSInternalException",
101 schedule: Schedule.max([
102 Schedule.fixed("500 millis"),
103 Schedule.recurs(8),
104 ]),
105 }),
106 Effect.catchTag("NotFoundException", () => Effect.void),
107 );
108
109 yield* kms.describeKey({ KeyId: aliasName }).pipe(
110 Effect.flatMap(() => Effect.fail(new AliasStillExists())),
111 Effect.catchTag("NotFoundException", () => Effect.void),
112 Effect.retry({
113 while: (error) => error._tag === "AliasStillExists",
114 schedule: kmsObservationSchedule,
115 }),
116 );
117 }
118
119 if (targetKeyId !== undefined) {
120 const metadata = yield* kms.describeKey({ KeyId: targetKeyId }).pipe(
121 Effect.map((response) => response.KeyMetadata),
122 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
123 );
124 if (metadata && metadata.KeyState !== "PendingDeletion") {
125 yield* kms
126 .scheduleKeyDeletion({
127 KeyId: targetKeyId,
128 PendingWindowInDays: 7,
129 })
130 .pipe(
131 Effect.retry({
132 while: (error) =>
133 error._tag === "DependencyTimeoutException" ||
134 error._tag === "KMSInternalException",
135 schedule: Schedule.max([
136 Schedule.fixed("500 millis"),
137 Schedule.recurs(8),
138 ]),
139 }),
140 Effect.catchTag(
141 ["KMSInvalidStateException", "NotFoundException"],
142 () => Effect.void,
143 ),
144 );
145 }
146
147 yield* kms.describeKey({ KeyId: targetKeyId }).pipe(
148 Effect.flatMap((response) =>
149 response.KeyMetadata?.KeyState === "PendingDeletion"
150 ? Effect.void
151 : Effect.fail(new KeyNotPendingDeletion()),
152 ),
153 Effect.catchTag("NotFoundException", () => Effect.void),
154 Effect.retry({
155 while: (error) => error._tag === "KeyNotPendingDeletion",
156 schedule: kmsObservationSchedule,
157 }),
158 );
159 }
160 });
161
162test.provider(
163 "create an encrypted volume with a managed KMS key and alias",
164 (stack) => {
165 let fixtureAliasName: AliasName | undefined;
166 let fixtureKeyId: string | undefined;
167 return Effect.gen(function* () {
168 yield* stack.destroy();
169
170 const deployed = yield* stack.deploy(
171 Effect.gen(function* () {
172 const key = yield* Key("EncryptedVolumeKey", {
173 description: "Alchemy EC2 encrypted-volume test key",
174 deletionWindow: "7 days",
175 tags: { fixture: "ec2-volume" },
176 });
177 const alias = yield* Alias("EncryptedVolumeAlias", {
178 targetKeyId: key.keyId,
179 });
180 const volume = yield* Volume("TestEncryptedVolume", {
181 availabilityZone: AZ,
182 size: 1,
183 volumeType: "gp3",
184 encrypted: true,
185 kmsKeyId: alias.aliasName,
186 });
187 return { alias, key, volume };
188 }),
189 );
190 fixtureAliasName = deployed.alias.aliasName;
191 fixtureKeyId = deployed.key.keyId;
192
193 expect(deployed.volume.encrypted).toBe(true);
194
195 const observed = yield* EC2.describeVolumes({
196 VolumeIds: [deployed.volume.volumeId],
197 });
198 const v = observed.Volumes?.[0];
199 expect(v?.Encrypted).toBe(true);
200 expect(v?.KmsKeyId).toBe(deployed.key.keyArn);
201
202 yield* stack.destroy();
203 yield* assertVolumeDeleted(deployed.volume.volumeId);
204 yield* releaseManagedKey(fixtureAliasName, fixtureKeyId);
205 }).pipe(
206 logLevel,
207 // Always run both the state-owned destroy and the direct idempotent
208 // backstop. This covers assertions, failures, and runner interruption
209 // while preserving dependency order (Volume -> Alias -> Key).
210 Effect.ensuring(
211 Effect.gen(function* () {
212 yield* stack.destroy().pipe(Effect.ignore);
213 // Finalizers require a `never` error channel. Escalate a cleanup
214 // failure to a visible defect instead of swallowing it.
215 yield* releaseManagedKey(fixtureAliasName, fixtureKeyId).pipe(
216 Effect.orDie,
217 );
218 }).pipe(logLevel),
219 ),
220 );
221 },
222 {
223 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:kms", "live"],
224 timeout: 120_000,
225 },
226);
227
228const assertVolumeDeleted = Effect.fn(function* (volumeId: string) {
229 yield* EC2.describeVolumes({ VolumeIds: [volumeId] }).pipe(
230 Effect.flatMap((result) => {
231 const state = result.Volumes?.[0]?.State;
232 return state === undefined || state === "deleted"
233 ? Effect.void
234 : Effect.fail(new VolumeStillExists());
235 }),
236 Effect.retry({
237 while: (e) => e instanceof VolumeStillExists,
238 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(8)]),
239 }),
240 Effect.catchTag("InvalidVolume.NotFound", () => Effect.void),
241 );
242});
243
244class VolumeStillExists extends Data.TaggedError("VolumeStillExists") {}