| 1 | import * as cloudhsm from "@distilled.cloud/aws/cloudhsm-v2"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { |
| 11 | createInternalTags, |
| 12 | createTagsList, |
| 13 | diffTags, |
| 14 | hasAlchemyTags, |
| 15 | } from "../../Tags.ts"; |
| 16 | import { toWireDays } from "../../Util/Duration.ts"; |
| 17 | import type { Providers } from "../Providers.ts"; |
| 18 | import { findClusterById, sameStringSet, toTagRecord } from "./internal.ts"; |
| 19 | |
| 20 | export 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 | |
| 63 | export 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 | */ |
| 161 | export const Cluster = Resource<Cluster>("AWS.CloudHSMV2.Cluster"); |
| 162 | |
| 163 | /** Cluster states that mean "gone" for reconciliation purposes. */ |
| 164 | const isGone = (cluster: cloudhsm.Cluster | undefined) => |
| 165 | cluster === undefined || |
| 166 | cluster.State === "DELETED" || |
| 167 | cluster.State === "DELETE_IN_PROGRESS"; |
| 168 | |
| 169 | export 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 | ); |