1import * as cloudhsm from "@distilled.cloud/aws/cloudhsm-v2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { findClusterById } from "./internal.ts";
10
11export interface HsmProps {
12 /**
13 * ID of the {@link Cluster} the HSM is created in. Changing the cluster
14 * replaces the HSM.
15 */
16 clusterId: string;
17 /**
18 * Availability Zone the HSM is placed into, e.g. `"us-west-2a"`. Must be
19 * one of the AZs covered by the cluster's subnets. Changing the AZ
20 * replaces the HSM.
21 */
22 availabilityZone: string;
23 /**
24 * IP address for the HSM's elastic network interface. Must be free in the
25 * subnet of the chosen Availability Zone. Changing the address replaces
26 * the HSM.
27 * @default an address picked by AWS from the subnet
28 */
29 ipAddress?: string;
30}
31
32export interface Hsm extends Resource<
33 "AWS.CloudHSMV2.Hsm",
34 HsmProps,
35 {
36 /**
37 * The unique identifier of the HSM.
38 */
39 hsmId: string;
40 /**
41 * The cluster the HSM belongs to.
42 */
43 clusterId: string;
44 /**
45 * The Availability Zone the HSM was placed in.
46 */
47 availabilityZone: string | undefined;
48 /**
49 * The subnet the HSM's ENI lives in.
50 */
51 subnetId: string | undefined;
52 /**
53 * The elastic network interface attached to the HSM.
54 */
55 eniId: string | undefined;
56 /**
57 * The IP address of the HSM's ENI.
58 */
59 eniIp: string | undefined;
60 /**
61 * Current state of the HSM (e.g. `ACTIVE`).
62 */
63 state: string;
64 },
65 never,
66 Providers
67> {}
68
69/**
70 * A hardware security module (HSM) inside an AWS CloudHSM {@link Cluster}.
71 *
72 * HSMs take roughly 10-20 minutes to provision and are billed hourly while
73 * they exist. The cluster must be in the `UNINITIALIZED`, `ACTIVE`, or
74 * `DEGRADED` state to accept a new HSM. Destroy HSMs you are not using.
75 * ### Creating an HSM
76 * **Example:** HSM in a Cluster's Availability Zone
77 * ```typescript
78 * const hsm = yield* Hsm("Primary", {
79 * clusterId: cluster.clusterId,
80 * availabilityZone: "us-west-2a",
81 * });
82 * ```
83 *
84 * **Example:** HSM with a Fixed ENI Address
85 * ```typescript
86 * const hsm = yield* Hsm("Primary", {
87 * clusterId: cluster.clusterId,
88 * availabilityZone: "us-west-2a",
89 * ipAddress: "10.0.1.20",
90 * });
91 * ```
92 *
93 * @resource
94 */
95export const Hsm = Resource<Hsm>("AWS.CloudHSMV2.Hsm");
96
97export const HsmProvider = () =>
98 Provider.effect(
99 Hsm,
100 Effect.gen(function* () {
101 // HSMs are enumerated through their cluster — describeClusters embeds
102 // each cluster's Hsms.
103 const findHsm = Effect.fn(function* (clusterId: string, hsmId: string) {
104 const cluster = yield* findClusterById(clusterId);
105 return cluster?.Hsms?.find(
106 (hsm) => hsm.HsmId === hsmId && hsm.State !== "DELETED",
107 );
108 });
109
110 // Bounded readiness wait. HSM provisioning typically completes in
111 // 10-20 minutes; budget ~25 min (100 * 15s).
112 const waitForActive = Effect.fn(function* (
113 clusterId: string,
114 hsmId: string,
115 ) {
116 const policy = Schedule.max([
117 Schedule.fixed("15 seconds"),
118 Schedule.recurs(100),
119 ]);
120 return yield* findHsm(clusterId, hsmId).pipe(
121 Effect.flatMap((hsm) => {
122 if (hsm === undefined) {
123 return Effect.fail(
124 new Error(`HSM '${hsmId}' not found in cluster '${clusterId}'`),
125 );
126 }
127 if (hsm.State !== "ACTIVE") {
128 return Effect.fail(
129 new Error(`HSM '${hsmId}' not active (state: ${hsm.State})`),
130 );
131 }
132 return Effect.succeed(hsm);
133 }),
134 Effect.retry({ schedule: policy }),
135 );
136 });
137
138 // Bounded wait-until-gone after delete initiation (~15 min = 60 * 15s).
139 // The whole cluster disappearing counts as gone too.
140 const waitUntilGone = Effect.fn(function* (
141 clusterId: string,
142 hsmId: string,
143 ) {
144 const policy = Schedule.max([
145 Schedule.fixed("15 seconds"),
146 Schedule.recurs(60),
147 ]);
148 yield* findHsm(clusterId, hsmId).pipe(
149 Effect.flatMap((hsm) =>
150 hsm === undefined
151 ? Effect.void
152 : Effect.fail(
153 new Error(
154 `HSM '${hsmId}' still deleting (state: ${hsm.State})`,
155 ),
156 ),
157 ),
158 Effect.retry({ schedule: policy }),
159 );
160 });
161
162 const toAttrs = Effect.fn(function* (hsm: cloudhsm.Hsm) {
163 if (!hsm.ClusterId) {
164 return yield* Effect.fail(
165 new Error(`HSM '${hsm.HsmId}' is missing its ClusterId`),
166 );
167 }
168 return {
169 hsmId: hsm.HsmId,
170 clusterId: hsm.ClusterId,
171 availabilityZone: hsm.AvailabilityZone,
172 subnetId: hsm.SubnetId,
173 eniId: hsm.EniId,
174 eniIp: hsm.EniIp,
175 state: hsm.State ?? "ACTIVE",
176 };
177 });
178
179 return {
180 stables: ["hsmId", "clusterId", "availabilityZone"],
181
182 diff: Effect.fn(function* ({ olds, news }) {
183 if (!isResolved(news)) return undefined;
184 const n = news ?? { clusterId: "", availabilityZone: "" };
185 const o = olds ?? { clusterId: "", availabilityZone: "" };
186 // Every HSM property is create-only.
187 if (n.clusterId !== o.clusterId) {
188 return { action: "replace" } as const;
189 }
190 if (n.availabilityZone !== o.availabilityZone) {
191 return { action: "replace" } as const;
192 }
193 if (n.ipAddress !== o.ipAddress) {
194 return { action: "replace" } as const;
195 }
196 }),
197
198 read: Effect.fn(function* ({ output }) {
199 // HSMs have no name, no tags, and an auto-assigned id — without a
200 // cached id there is nothing to correlate on.
201 if (output === undefined) return undefined;
202 const hsm = yield* findHsm(output.clusterId, output.hsmId);
203 if (hsm === undefined) return undefined;
204 return yield* toAttrs(hsm);
205 }),
206
207 reconcile: Effect.fn(function* ({ news, output, session }) {
208 const props = news!;
209
210 // 1. Observe — the cached id is only a hint; the cluster's HSM
211 // list is authoritative.
212 let observed = output
213 ? yield* findHsm(output.clusterId, output.hsmId)
214 : undefined;
215
216 // 2. Ensure — create if missing.
217 if (observed === undefined) {
218 const created = yield* cloudhsm.createHsm({
219 ClusterId: props.clusterId,
220 AvailabilityZone: props.availabilityZone,
221 IpAddress: props.ipAddress,
222 });
223 observed = created.Hsm;
224 }
225 const hsmId = observed?.HsmId;
226 if (!hsmId) {
227 return yield* Effect.fail(new Error("CreateHsm returned no HsmId"));
228 }
229
230 // 3. Wait for the HSM to become ACTIVE (nothing else is mutable).
231 const active = yield* waitForActive(props.clusterId, hsmId);
232
233 yield* session.note(hsmId);
234 return yield* toAttrs(active);
235 }),
236
237 delete: Effect.fn(function* ({ output }) {
238 yield* cloudhsm
239 .deleteHsm({ ClusterId: output.clusterId, HsmId: output.hsmId })
240 .pipe(
241 Effect.catchTag(
242 "CloudHsmResourceNotFoundException",
243 () => Effect.void,
244 ),
245 );
246 // Deletion is asynchronous and the parent cluster cannot be
247 // deleted until the HSM is gone — wait it out (bounded).
248 yield* waitUntilGone(output.clusterId, output.hsmId);
249 }),
250
251 list: () =>
252 Effect.gen(function* () {
253 const pages = yield* cloudhsm.describeClusters
254 .pages({})
255 .pipe(Stream.runCollect);
256 const hsms = Array.from(pages)
257 .flatMap((page) => page.Clusters ?? [])
258 .flatMap((cluster) => cluster.Hsms ?? [])
259 .filter((hsm) => hsm.State !== "DELETED");
260 return yield* Effect.forEach(hsms, (hsm) => toAttrs(hsm));
261 }),
262 };
263 }),
264 );