1import * as AWS from "@/AWS";
2import { Cluster, Hsm } from "@/AWS/CloudHSMV2";
3import * as Test from "@/Test/Alchemy";
4import * as cloudhsm from "@distilled.cloud/aws/cloudhsm-v2";
5import * as EC2 from "@distilled.cloud/aws/ec2";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import { getDefaultVpc } from "../DefaultVpc.ts";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Ungated probes: describeClusters is a filtered list — an unknown cluster id
14// yields an empty page rather than a typed NotFound. Proves auth + the
15// response schema decode in every CI pass at near-zero cost.
16test.provider(
17 "describeClusters with an unknown cluster id filter returns an empty page",
18 () =>
19 Effect.gen(function* () {
20 const response = yield* cloudhsm.describeClusters({
21 Filters: { clusterIds: ["cluster-aaaaaaaaaaa"] },
22 });
23 expect(response.Clusters ?? []).toHaveLength(0);
24 }),
25 { tags: ["provider:aws", "provider:aws:cloudhsmv2", "live"] },
26);
27
28// Ungated typed-error probe: prove the distilled union carries the not-found
29// tag the provider's delete path depends on.
30test.provider(
31 "deleteCluster on a nonexistent cluster fails with CloudHsmResourceNotFoundException",
32 () =>
33 Effect.gen(function* () {
34 const error = yield* Effect.flip(
35 cloudhsm.deleteCluster({ ClusterId: "cluster-aaaaaaaaaaa" }),
36 );
37 expect(error._tag).toBe("CloudHsmResourceNotFoundException");
38 }),
39 { tags: ["provider:aws", "provider:aws:cloudhsmv2", "live"] },
40);
41
42// Resolve two default-for-AZ subnets (distinct AZs) from the default VPC.
43const defaultNetwork = Effect.gen(function* () {
44 const vpc = yield* getDefaultVpc;
45 const subnets = yield* EC2.describeSubnets({
46 Filters: [
47 { Name: "vpc-id", Values: [vpc.vpcId] },
48 { Name: "default-for-az", Values: ["true"] },
49 ],
50 });
51 // CloudHSM is not offered in every AZ — pick subnets from the region's
52 // first AZs (suffix a/b) only.
53 const picked = (subnets.Subnets ?? [])
54 .filter((s) => /[ab]$/.test(s.AvailabilityZone ?? ""))
55 .sort((l, r) =>
56 (l.AvailabilityZone ?? "").localeCompare(r.AvailabilityZone ?? ""),
57 )
58 .slice(0, 2);
59 const subnetIds = picked
60 .map((s) => s.SubnetId)
61 .filter((id): id is string => id !== undefined);
62 const availabilityZones = picked
63 .map((s) => s.AvailabilityZone)
64 .filter((az): az is string => az !== undefined);
65 if (subnetIds.length < 2) {
66 return yield* Effect.die(
67 new Error("default VPC is missing default-for-az subnets in AZs a/b"),
68 );
69 }
70 return { subnetIds, availabilityZones };
71});
72
73// Deletion is verified as INITIATED (DELETE_IN_PROGRESS, irreversible) or
74// fully gone (absent / DELETED).
75const assertClusterDeleting = (clusterId: string) =>
76 Effect.gen(function* () {
77 const response = yield* cloudhsm.describeClusters({
78 Filters: { clusterIds: [clusterId] },
79 });
80 const state = response.Clusters?.[0]?.State ?? "gone";
81 if (
82 state !== "gone" &&
83 state !== "DELETED" &&
84 state !== "DELETE_IN_PROGRESS"
85 ) {
86 return yield* Effect.fail(
87 new Error(`cluster '${clusterId}' still exists (state: ${state})`),
88 );
89 }
90 }).pipe(
91 Effect.retry({
92 schedule: Schedule.max([
93 Schedule.fixed("10 seconds"),
94 Schedule.recurs(18),
95 ]),
96 }),
97 );
98
99// A CloudHSM cluster provisions in a few minutes but its HSM takes ~10-20
100// minutes and bills hourly while it exists (~$1.45/hr). The full lifecycle is
101// gated behind AWS_TEST_CLOUDHSM=1 and always destroys what it created.
102test.provider.skipIf(!process.env.AWS_TEST_CLOUDHSM)(
103 "create CloudHSM cluster with one HSM, verify, destroy",
104 (stack) =>
105 Effect.gen(function* () {
106 yield* stack.destroy();
107
108 const network = yield* defaultNetwork;
109
110 const { cluster, hsm } = yield* stack.deploy(
111 Effect.gen(function* () {
112 const cluster = yield* Cluster("HsmCluster", {
113 hsmType: "hsm2m.medium",
114 subnetIds: network.subnetIds,
115 tags: { fixture: "cloudhsm-cluster" },
116 });
117 const hsm = yield* Hsm("Primary", {
118 clusterId: cluster.clusterId,
119 availabilityZone: network.availabilityZones[0],
120 });
121 return { cluster, hsm };
122 }),
123 );
124
125 expect(cluster.clusterId).toMatch(/^cluster-/);
126 expect(["UNINITIALIZED", "INITIALIZED", "ACTIVE", "DEGRADED"]).toContain(
127 cluster.state,
128 );
129 expect(cluster.hsmType).toBe("hsm2m.medium");
130 expect(cluster.vpcId).toBeDefined();
131 expect(cluster.securityGroup).toBeDefined();
132 expect(cluster.subnetIds.length).toBe(2);
133 expect(hsm.hsmId).toMatch(/^hsm-/);
134 expect(hsm.clusterId).toBe(cluster.clusterId);
135 expect(hsm.state).toBe("ACTIVE");
136 expect(hsm.eniIp).toBeDefined();
137
138 // Out-of-band verification via distilled.
139 const described = yield* cloudhsm.describeClusters({
140 Filters: { clusterIds: [cluster.clusterId] },
141 });
142 const observed = described.Clusters?.[0];
143 expect(observed?.ClusterId).toBe(cluster.clusterId);
144 expect(observed?.HsmType).toBe("hsm2m.medium");
145 expect(
146 observed?.TagList?.some(
147 (tag) => tag.Key === "fixture" && tag.Value === "cloudhsm-cluster",
148 ),
149 ).toBe(true);
150 expect(observed?.Hsms?.some((h) => h.HsmId === hsm.hsmId)).toBe(true);
151
152 // Destroy immediately — HSMs bill hourly — and verify deletion was
153 // initiated out-of-band (HSM first, then cluster).
154 yield* stack.destroy();
155 yield* assertClusterDeleting(cluster.clusterId);
156 }),
157 // HSM create (~10-20 min) + HSM delete wait + cluster delete, one test.
158 {
159 tags: [
160 "provider:aws",
161 "provider:aws:cloudhsmv2",
162 "provider:aws:ec2",
163 "live",
164 ],
165 timeout: 2_400_000,
166 },
167);