1import * as cloudhsm from "@distilled.cloud/aws/cloudhsm-v2";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 createTagsList,
13 diffTags,
14 hasAlchemyTags,
15} from "../../Tags.ts";
16import { toWireDays } from "../../Util/Duration.ts";
17import type { Providers } from "../Providers.ts";
18import { findClusterById, sameStringSet, toTagRecord } from "./internal.ts";
19
20export interface ClusterProps {
21 /**
22 * Type of HSM the cluster hosts, e.g. `"hsm2m.medium"` (current
23 * generation) or `"hsm1.medium"` (legacy). Changing the HSM type replaces
24 * the cluster.
25 */
26 hsmType: string;
27 /**
28 * IDs of the subnets the cluster's HSMs may be placed into. Each subnet
29 * must be in a different Availability Zone of the same VPC. Changing the
30 * subnets replaces the cluster.
31 */
32 subnetIds: string[];
33 /**
34 * ID (or, cross-account, the full ARN) of a cluster backup to restore the
35 * new cluster from. Changing the source backup replaces the cluster.
36 */
37 sourceBackupId?: string;
38 /**
39 * IP address type of the cluster's endpoints — `"IPV4"` or `"DUALSTACK"`.
40 * Changing the network type replaces the cluster.
41 * @default "IPV4"
42 */
43 networkType?: cloudhsm.NetworkType;
44 /**
45 * Whether the cluster runs in `"FIPS"` or `"NON_FIPS"` mode. Changing the
46 * mode replaces the cluster.
47 * @default "FIPS"
48 */
49 mode?: cloudhsm.ClusterMode;
50 /**
51 * How long automatic backups of the cluster are retained (e.g. `"30 days"`
52 * or `Duration.days(30)`; a bare number is milliseconds). Converted to
53 * whole days on the wire. Updated in place via ModifyCluster.
54 * @default 90 days
55 */
56 backupRetention?: Duration.Input;
57 /**
58 * User-defined tags for the cluster.
59 */
60 tags?: Record<string, string>;
61}
62
63export interface Cluster extends Resource<
64 "AWS.CloudHSMV2.Cluster",
65 ClusterProps,
66 {
67 /**
68 * The unique identifier of the cluster.
69 */
70 clusterId: string;
71 /**
72 * Current state of the cluster (e.g. `UNINITIALIZED`, `ACTIVE`).
73 */
74 state: string;
75 /**
76 * The type of HSM in the cluster (e.g. `hsm2m.medium`).
77 */
78 hsmType: string;
79 /**
80 * The FIPS mode of the cluster (`FIPS` or `NON_FIPS`).
81 */
82 mode: string | undefined;
83 /**
84 * The network type of the cluster (`IPV4` or `DUALSTACK`).
85 */
86 networkType: string | undefined;
87 /**
88 * The VPC the cluster's HSMs live in.
89 */
90 vpcId: string | undefined;
91 /**
92 * The security group CloudHSM created for the cluster's ENIs.
93 */
94 securityGroup: string | undefined;
95 /**
96 * The subnets (one per AZ) the cluster spans.
97 */
98 subnetIds: string[];
99 /**
100 * The certificate signing request to sign when initializing the cluster
101 * (present while `UNINITIALIZED`).
102 */
103 clusterCsr: string | undefined;
104 /**
105 * How many days backups are retained.
106 */
107 backupRetentionDays: number | undefined;
108 /**
109 * Current tags on the cluster.
110 */
111 tags: Record<string, string>;
112 },
113 never,
114 Providers
115> {}
116
117/**
118 * An AWS CloudHSM cluster — a fleet of FIPS-validated, single-tenant
119 * hardware security modules (HSMs) inside your VPC.
120 *
121 * A fresh cluster provisions to the `UNINITIALIZED` state in a few minutes
122 * and holds no HSMs; add {@link Hsm} resources to place HSMs into its
123 * subnets' Availability Zones (each HSM is billed hourly). Activating the
124 * cluster (signing the cluster CSR and calling InitializeCluster) is an
125 * offline certificate-authority ceremony that stays out of band — the
126 * `clusterCsr` attribute exposes the CSR to sign.
127 * ### Creating a Cluster
128 * **Example:** Cluster Spanning Two Availability Zones
129 * ```typescript
130 * const cluster = yield* Cluster("HsmCluster", {
131 * hsmType: "hsm2m.medium",
132 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
133 * });
134 * ```
135 *
136 * **Example:** Non-FIPS Cluster with Custom Backup Retention
137 * ```typescript
138 * const cluster = yield* Cluster("HsmCluster", {
139 * hsmType: "hsm2m.medium",
140 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
141 * mode: "NON_FIPS",
142 * backupRetention: "30 days",
143 * });
144 * ```
145 *
146 * ### Adding HSMs
147 * **Example:** Cluster with One HSM
148 * ```typescript
149 * const cluster = yield* Cluster("HsmCluster", {
150 * hsmType: "hsm2m.medium",
151 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
152 * });
153 * const hsm = yield* Hsm("Primary", {
154 * clusterId: cluster.clusterId,
155 * availabilityZone: "us-west-2a",
156 * });
157 * ```
158 *
159 * @resource
160 */
161export const Cluster = Resource<Cluster>("AWS.CloudHSMV2.Cluster");
162
163/** Cluster states that mean "gone" for reconciliation purposes. */
164const isGone = (cluster: cloudhsm.Cluster | undefined) =>
165 cluster === undefined ||
166 cluster.State === "DELETED" ||
167 cluster.State === "DELETE_IN_PROGRESS";
168
169export const ClusterProvider = () =>
170 Provider.effect(
171 Cluster,
172 Effect.gen(function* () {
173 // Read the observed tags for a cluster. A cluster mid-transition can
174 // transiently reject listTags; treat failure as "no observed tags" so
175 // tag reconciliation re-runs on the next pass.
176 const readClusterTags = Effect.fn(function* (clusterId: string) {
177 const response = yield* cloudhsm
178 .listTags({ ResourceId: clusterId })
179 .pipe(Effect.catch(() => Effect.succeed(undefined)));
180 return toTagRecord(response?.TagList);
181 });
182
183 // CloudHSM clusters have no caller-chosen name — the id is
184 // auto-assigned. When we have no cached id (state persistence failed
185 // mid-reconcile), recover ownership by scanning for the cluster
186 // carrying our alchemy tags.
187 const findClusterByAlchemyTags = Effect.fn(function* (id: string) {
188 const pages = yield* cloudhsm.describeClusters
189 .pages({})
190 .pipe(Stream.runCollect);
191 for (const page of pages) {
192 for (const cluster of page.Clusters ?? []) {
193 if (isGone(cluster)) continue;
194 if (yield* hasAlchemyTags(id, toTagRecord(cluster.TagList))) {
195 return cluster;
196 }
197 }
198 }
199 return undefined;
200 });
201
202 // Bounded settle wait: creation and modification park the cluster in a
203 // *_IN_PROGRESS state; the cluster itself settles in a few minutes
204 // (budget 10 min = 40 * 15s).
205 const waitUntilSettled = Effect.fn(function* (clusterId: string) {
206 const policy = Schedule.max([
207 Schedule.fixed("15 seconds"),
208 Schedule.recurs(40),
209 ]);
210 return yield* findClusterById(clusterId).pipe(
211 Effect.flatMap((cluster) => {
212 if (cluster === undefined) {
213 return Effect.fail(
214 new Error(`CloudHSM cluster '${clusterId}' not found`),
215 );
216 }
217 if (cluster.State?.endsWith("_IN_PROGRESS")) {
218 return Effect.fail(
219 new Error(
220 `CloudHSM cluster '${clusterId}' still settling (state: ${cluster.State})`,
221 ),
222 );
223 }
224 return Effect.succeed(cluster);
225 }),
226 Effect.retry({ schedule: policy }),
227 );
228 });
229
230 const toAttrs = Effect.fn(function* (cluster: cloudhsm.Cluster) {
231 if (!cluster.ClusterId) {
232 return yield* Effect.fail(
233 new Error("CloudHSM cluster is missing its ClusterId"),
234 );
235 }
236 return {
237 clusterId: cluster.ClusterId,
238 state: cluster.State ?? "UNINITIALIZED",
239 hsmType: cluster.HsmType ?? "",
240 mode: cluster.Mode,
241 networkType: cluster.NetworkType,
242 vpcId: cluster.VpcId,
243 securityGroup: cluster.SecurityGroup,
244 subnetIds: Object.values(cluster.SubnetMapping ?? {}).filter(
245 (s): s is string => s !== undefined,
246 ),
247 clusterCsr: cluster.Certificates?.ClusterCsr,
248 backupRetentionDays:
249 cluster.BackupRetentionPolicy?.Value !== undefined
250 ? Number(cluster.BackupRetentionPolicy.Value)
251 : undefined,
252 tags: toTagRecord(cluster.TagList),
253 };
254 });
255
256 return {
257 stables: ["clusterId", "vpcId", "securityGroup"],
258
259 diff: Effect.fn(function* ({ olds, news }) {
260 if (!isResolved(news)) return undefined;
261 const n = news ?? { hsmType: "", subnetIds: [] };
262 const o = olds ?? { hsmType: "", subnetIds: [] };
263 // Create-only properties force a replacement.
264 if (n.hsmType !== o.hsmType) {
265 return { action: "replace" } as const;
266 }
267 if (!sameStringSet(n.subnetIds, o.subnetIds)) {
268 return { action: "replace" } as const;
269 }
270 if (n.sourceBackupId !== o.sourceBackupId) {
271 return { action: "replace" } as const;
272 }
273 if ((n.networkType ?? "IPV4") !== (o.networkType ?? "IPV4")) {
274 return { action: "replace" } as const;
275 }
276 if ((n.mode ?? undefined) !== (o.mode ?? undefined)) {
277 return { action: "replace" } as const;
278 }
279 }),
280
281 read: Effect.fn(function* ({ id, output }) {
282 const cluster = output?.clusterId
283 ? yield* findClusterById(output.clusterId)
284 : yield* findClusterByAlchemyTags(id);
285 if (cluster === undefined || cluster.State === "DELETED") {
286 return undefined;
287 }
288 const attrs = yield* toAttrs(cluster);
289 return (yield* hasAlchemyTags(id, attrs.tags))
290 ? attrs
291 : Unowned(attrs);
292 }),
293
294 reconcile: Effect.fn(function* ({ id, news, output, session }) {
295 const props = news!;
296 const internalTags = yield* createInternalTags(id);
297 const desiredTags = { ...internalTags, ...props.tags };
298 const backupRetentionDays = toWireDays(props.backupRetention);
299
300 // 1. Observe — cloud state is authoritative; output is only an id
301 // cache. Without one, recover by alchemy tags (crash recovery /
302 // adoption both land here).
303 let observed = output?.clusterId
304 ? yield* findClusterById(output.clusterId)
305 : yield* findClusterByAlchemyTags(id);
306
307 // 2. Ensure — create if missing (or terminally deleted).
308 if (isGone(observed)) {
309 const created = yield* cloudhsm.createCluster({
310 HsmType: props.hsmType,
311 SubnetIds: props.subnetIds,
312 SourceBackupId: props.sourceBackupId,
313 NetworkType: props.networkType,
314 Mode: props.mode,
315 BackupRetentionPolicy:
316 backupRetentionDays !== undefined
317 ? { Type: "DAYS", Value: String(backupRetentionDays) }
318 : undefined,
319 TagList: createTagsList(desiredTags),
320 });
321 observed = created.Cluster;
322 }
323 const clusterId = observed?.ClusterId;
324 if (!clusterId) {
325 return yield* Effect.fail(
326 new Error("CreateCluster returned no ClusterId"),
327 );
328 }
329
330 // Creation/modification park the cluster in *_IN_PROGRESS; wait
331 // (bounded) so subsequent modify calls do not race the transition.
332 observed = yield* waitUntilSettled(clusterId);
333
334 // 3. Sync backup retention — diff OBSERVED policy against desired.
335 if (
336 backupRetentionDays !== undefined &&
337 String(backupRetentionDays) !==
338 observed.BackupRetentionPolicy?.Value
339 ) {
340 yield* cloudhsm.modifyCluster({
341 ClusterId: clusterId,
342 BackupRetentionPolicy: {
343 Type: "DAYS",
344 Value: String(backupRetentionDays),
345 },
346 });
347 observed = yield* waitUntilSettled(clusterId);
348 }
349
350 // 3b. Sync tags — diff against OBSERVED cloud tags.
351 const observedTags = yield* readClusterTags(clusterId);
352 const { removed, upsert } = diffTags(observedTags, desiredTags);
353 if (upsert.length > 0) {
354 yield* cloudhsm.tagResource({
355 ResourceId: clusterId,
356 TagList: upsert,
357 });
358 }
359 if (removed.length > 0) {
360 yield* cloudhsm.untagResource({
361 ResourceId: clusterId,
362 TagKeyList: removed,
363 });
364 }
365
366 yield* session.note(clusterId);
367 return yield* toAttrs(observed);
368 }),
369
370 delete: Effect.fn(function* ({ output }) {
371 const clusterId = output.clusterId;
372 const observed = yield* findClusterById(clusterId);
373 if (isGone(observed)) return;
374 // Before a cluster can be deleted all of its HSMs must be gone;
375 // HSM deletion initiated by dependent resources may still be
376 // propagating, which surfaces as CloudHsmInvalidRequestException.
377 // Bounded retry through that window (10 min = 40 * 15s).
378 yield* cloudhsm.deleteCluster({ ClusterId: clusterId }).pipe(
379 Effect.retry({
380 while: (e) => e._tag === "CloudHsmInvalidRequestException",
381 schedule: Schedule.max([
382 Schedule.fixed("15 seconds"),
383 Schedule.recurs(40),
384 ]),
385 }),
386 Effect.catchTag(
387 "CloudHsmResourceNotFoundException",
388 () => Effect.void,
389 ),
390 );
391 }),
392
393 list: () =>
394 Effect.gen(function* () {
395 const pages = yield* cloudhsm.describeClusters
396 .pages({})
397 .pipe(Stream.runCollect);
398 const clusters = Array.from(pages)
399 .flatMap((page) => page.Clusters ?? [])
400 .filter(
401 (cluster) =>
402 cluster.ClusterId !== undefined &&
403 cluster.State !== "DELETED",
404 );
405 return yield* Effect.forEach(clusters, (cluster) =>
406 toAttrs(cluster),
407 );
408 }),
409 };
410 }),
411 );