1import * as docdb from "@distilled.cloud/aws/docdb";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import type * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { toWireDays } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13import { createInternalTags, diffTags } from "../../Tags.ts";
14
15export interface DBClusterProps {
16 /**
17 * Cluster identifier. If omitted, Alchemy generates one.
18 */
19 dbClusterIdentifier?: string;
20 /**
21 * Database engine. DocumentDB only supports `docdb`.
22 * Changing it forces replacement.
23 * @default "docdb"
24 */
25 engine?: string;
26 /**
27 * Optional engine version, e.g. `5.0.0`. Changed in place via
28 * `modifyDBCluster` (may require `allowMajorVersionUpgrade`).
29 */
30 engineVersion?: string;
31 /**
32 * Subnet group the cluster is placed into. DocumentDB is VPC-only, so this
33 * is effectively required. Immutable — forces replacement.
34 */
35 dbSubnetGroupName?: string;
36 /**
37 * Cluster parameter group name. In-place modify.
38 */
39 dbClusterParameterGroupName?: string;
40 /**
41 * Security groups attached to the cluster. In-place modify.
42 */
43 vpcSecurityGroupIds?: string[];
44 /**
45 * Listener port.
46 * @default 27017
47 */
48 port?: number;
49 /**
50 * Availability zones for cluster placement. Immutable — forces replacement.
51 */
52 availabilityZones?: string[];
53 /**
54 * Backup retention period, e.g. `"7 days"` or `Duration.days(7)`.
55 * Rounded to whole days on the wire. In-place modify.
56 */
57 backupRetentionPeriod?: Duration.Input;
58 /**
59 * Daily backup window, e.g. `07:00-09:00`. In-place modify.
60 */
61 preferredBackupWindow?: string;
62 /**
63 * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify.
64 */
65 preferredMaintenanceWindow?: string;
66 /**
67 * Log types to export to CloudWatch Logs (`audit`, `profiler`). Diffed
68 * against observed state and applied via the delta-shaped
69 * `CloudwatchLogsExportConfiguration` on modify.
70 */
71 enableCloudwatchLogsExports?: string[];
72 /**
73 * Block accidental deletion. In-place modify.
74 */
75 deletionProtection?: boolean;
76 /**
77 * Whether the storage is encrypted. Immutable — forces replacement.
78 */
79 storageEncrypted?: boolean;
80 /**
81 * Optional KMS key used for storage encryption. Immutable — forces replace.
82 */
83 kmsKeyId?: string;
84 /**
85 * Storage type, e.g. `standard` | `iopt1`. In-place modify.
86 */
87 storageType?: string;
88 /**
89 * Network type: `IPV4` | `DUAL`. In-place modify.
90 */
91 networkType?: string;
92 /**
93 * Join this cluster to a DocumentDB global cluster. Immutable on create.
94 */
95 globalClusterIdentifier?: string;
96 /**
97 * Master username. Immutable — forces replacement.
98 */
99 masterUsername?: string;
100 /**
101 * Master password. In-place modify.
102 */
103 masterUserPassword?: Redacted.Redacted<string>;
104 /**
105 * Let DocumentDB manage the master user password in Secrets Manager.
106 */
107 manageMasterUserPassword?: boolean;
108 /**
109 * Rotate the managed master user password on the next reconcile.
110 */
111 rotateMasterUserPassword?: boolean;
112 /**
113 * KMS key used to encrypt the managed master user secret. In-place modify.
114 */
115 masterUserSecretKmsKeyId?: string;
116 /**
117 * Allow a major engine-version upgrade during a modify. Modify-only flag.
118 */
119 allowMajorVersionUpgrade?: boolean;
120 /**
121 * User-defined tags.
122 */
123 tags?: Record<string, string>;
124}
125
126export interface DBCluster extends Resource<
127 "AWS.DocDB.DBCluster",
128 DBClusterProps,
129 {
130 /** The cluster identifier (unique per account/region). */
131 dbClusterIdentifier: string;
132 /** The ARN of the cluster. */
133 dbClusterArn: string;
134 /** The DB subnet group the cluster is deployed into. */
135 dbSubnetGroupName: string | undefined;
136 /** The writer endpoint hostname of the cluster. */
137 endpoint: string | undefined;
138 /** The load-balanced reader endpoint hostname. */
139 readerEndpoint: string | undefined;
140 /** The port the cluster accepts connections on. */
141 port: number | undefined;
142 /** The database engine (`docdb`). */
143 engine: string;
144 /** The engine version running on the cluster. */
145 engineVersion: string | undefined;
146 /** The current status of the cluster, e.g. `available`. */
147 status: string | undefined;
148 /** The master (admin) username. */
149 masterUsername: string | undefined;
150 /** ARN of the Secrets Manager secret holding the master credentials (when managed). */
151 masterUserSecretArn: string | undefined;
152 /** The VPC security groups attached to the cluster. */
153 vpcSecurityGroupIds: string[];
154 /** Number of days automated backups are retained. */
155 backupRetentionPeriod: number | undefined;
156 /** The daily window during which automated backups run. */
157 preferredBackupWindow: string | undefined;
158 /** The weekly window during which maintenance can occur. */
159 preferredMaintenanceWindow: string | undefined;
160 /** Whether cluster storage is encrypted at rest. */
161 storageEncrypted: boolean | undefined;
162 /** The KMS key used for storage encryption. */
163 kmsKeyId: string | undefined;
164 /** Whether deletion protection is enabled. */
165 deletionProtection: boolean | undefined;
166 /** The instances that are members of the cluster. */
167 dbClusterMembers: Array<{
168 /** The identifier of the member instance. */
169 dbInstanceIdentifier: string | undefined;
170 /** Whether this member is the cluster writer. */
171 isClusterWriter: boolean | undefined;
172 /** The failover promotion tier of the member. */
173 promotionTier: number | undefined;
174 }>;
175 /** The immutable region-unique resource ID of the cluster. */
176 dbClusterResourceId: string | undefined;
177 /** The Route 53 hosted zone ID of the cluster endpoints. */
178 hostedZoneId: string | undefined;
179 /** Whether the cluster has instances in multiple Availability Zones. */
180 multiAZ: boolean | undefined;
181 /** The log types exported to CloudWatch Logs, e.g. `audit`, `profiler`. */
182 enabledCloudwatchLogsExports: string[];
183 /** When the cluster was created (ISO timestamp). */
184 clusterCreateTime: string | undefined;
185 /** The storage type, e.g. `standard` or `iopt1`. */
186 storageType: string | undefined;
187 /** The network type, e.g. `IPV4` or `DUAL`. */
188 networkType: string | undefined;
189 /** The tags attached to the cluster. */
190 tags: Record<string, string>;
191 },
192 never,
193 Providers
194> {}
195
196/**
197 * An Amazon DocumentDB (MongoDB-compatible) cluster.
198 *
199 * `DBCluster` owns the writer and reader endpoints and cluster-wide
200 * networking; instances are added via {@link DBInstance}. It can bootstrap
201 * master credentials directly or let DocumentDB manage them in Secrets Manager.
202 * Provisioning a cluster (and its first instance) takes several minutes.
203 *
204 * Mutable fields are reconciled in place against the observed cloud state;
205 * immutable fields (`engine`, `dbSubnetGroupName`, `storageEncrypted`,
206 * `kmsKeyId`, `globalClusterIdentifier`, `availabilityZones`,
207 * `masterUsername`) force a replacement.
208 * ### Creating a Cluster
209 * **Example:** DocumentDB cluster with a managed master secret
210 * ```typescript
211 * const cluster = yield* DBCluster("Docs", {
212 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
213 * vpcSecurityGroupIds: [sg.groupId],
214 * masterUsername: "alchemy",
215 * manageMasterUserPassword: true,
216 * backupRetentionPeriod: "7 days",
217 * deletionProtection: false,
218 * });
219 * ```
220 *
221 * ### Logs & Encryption
222 * **Example:** Export audit logs and encrypt storage
223 * ```typescript
224 * const cluster = yield* DBCluster("Docs", {
225 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
226 * masterUsername: "alchemy",
227 * masterUserPassword: Redacted.make("supersecret"),
228 * storageEncrypted: true,
229 * enableCloudwatchLogsExports: ["audit", "profiler"],
230 * });
231 * ```
232 *
233 * @resource
234 */
235export const DBCluster = Resource<DBCluster>("AWS.DocDB.DBCluster");
236
237const toTagRecord = (
238 tags: Array<{ Key?: string; Value?: string }> | undefined,
239): Record<string, string> =>
240 Object.fromEntries(
241 (tags ?? [])
242 .filter(
243 (tag): tag is { Key: string; Value: string } =>
244 typeof tag.Key === "string" && typeof tag.Value === "string",
245 )
246 .map((tag) => [tag.Key, tag.Value]),
247 );
248
249const toAttrs = ({
250 cluster,
251 tags,
252}: {
253 cluster: docdb.DBCluster;
254 tags: Record<string, string>;
255}): DBCluster["Attributes"] => ({
256 dbClusterIdentifier: cluster.DBClusterIdentifier ?? "",
257 dbClusterArn: cluster.DBClusterArn ?? "",
258 dbSubnetGroupName: cluster.DBSubnetGroup,
259 endpoint: cluster.Endpoint,
260 readerEndpoint: cluster.ReaderEndpoint,
261 port: cluster.Port,
262 engine: cluster.Engine ?? "docdb",
263 engineVersion: cluster.EngineVersion,
264 status: cluster.Status,
265 masterUsername: cluster.MasterUsername,
266 masterUserSecretArn: cluster.MasterUserSecret?.SecretArn,
267 vpcSecurityGroupIds: (cluster.VpcSecurityGroups ?? []).flatMap((group) =>
268 group.VpcSecurityGroupId ? [group.VpcSecurityGroupId] : [],
269 ),
270 backupRetentionPeriod: cluster.BackupRetentionPeriod,
271 preferredBackupWindow: cluster.PreferredBackupWindow,
272 preferredMaintenanceWindow: cluster.PreferredMaintenanceWindow,
273 storageEncrypted: cluster.StorageEncrypted,
274 kmsKeyId: cluster.KmsKeyId,
275 deletionProtection: cluster.DeletionProtection,
276 dbClusterMembers: (cluster.DBClusterMembers ?? []).map((member) => ({
277 dbInstanceIdentifier: member.DBInstanceIdentifier,
278 isClusterWriter: member.IsClusterWriter,
279 promotionTier: member.PromotionTier,
280 })),
281 dbClusterResourceId: cluster.DbClusterResourceId,
282 hostedZoneId: cluster.HostedZoneId,
283 multiAZ: cluster.MultiAZ,
284 enabledCloudwatchLogsExports: cluster.EnabledCloudwatchLogsExports ?? [],
285 clusterCreateTime: cluster.ClusterCreateTime?.toISOString(),
286 storageType: cluster.StorageType,
287 networkType: cluster.NetworkType,
288 tags,
289});
290
291/**
292 * Compute the CloudWatch Logs export delta. The modify API is delta-shaped
293 * (`EnableLogTypes`/`DisableLogTypes`), so it must NOT carry the full set.
294 * Returns `undefined` when there is no change.
295 */
296const logExportDelta = (
297 observed: string[] | undefined,
298 desired: string[] | undefined,
299): docdb.CloudwatchLogsExportConfiguration | undefined => {
300 if (desired === undefined) return undefined;
301 const have = new Set(observed ?? []);
302 const want = new Set(desired);
303 const EnableLogTypes = [...want].filter((t) => !have.has(t));
304 const DisableLogTypes = [...have].filter((t) => !want.has(t));
305 if (EnableLogTypes.length === 0 && DisableLogTypes.length === 0) {
306 return undefined;
307 }
308 return {
309 ...(EnableLogTypes.length > 0 ? { EnableLogTypes } : {}),
310 ...(DisableLogTypes.length > 0 ? { DisableLogTypes } : {}),
311 };
312};
313
314export const DBClusterProvider = () =>
315 Provider.effect(
316 DBCluster,
317 Effect.gen(function* () {
318 const toIdentifier = (id: string, props: DBClusterProps) =>
319 props.dbClusterIdentifier
320 ? Effect.succeed(props.dbClusterIdentifier)
321 : createPhysicalName({ id, maxLength: 63 });
322
323 const readCluster = Effect.fn(function* (clusterId: string) {
324 const response = yield* docdb
325 .describeDBClusters({
326 DBClusterIdentifier: clusterId,
327 })
328 .pipe(
329 Effect.catchTag("DBClusterNotFoundFault", () =>
330 Effect.succeed(undefined),
331 ),
332 );
333 return response?.DBClusters?.[0];
334 });
335
336 const readTags = Effect.fn(function* (arn: string | undefined) {
337 if (!arn) return {} as Record<string, string>;
338 const response = yield* docdb
339 .listTagsForResource({ ResourceName: arn })
340 .pipe(
341 Effect.catchTag("DBClusterNotFoundFault", () =>
342 Effect.succeed(undefined),
343 ),
344 );
345 return toTagRecord(response?.TagList);
346 });
347
348 // Bounded readiness wait. Gate on cluster `Status === "available"` so a
349 // follow-on `modifyDBCluster` doesn't hit `InvalidDBClusterStateFault`.
350 // Budgets ~10 min (60 * 10s) for slow provisioning.
351 const waitForCluster = Effect.fn(function* (clusterId: string) {
352 const readinessPolicy = Schedule.max([
353 Schedule.fixed("10 seconds"),
354 Schedule.recurs(60),
355 ]);
356 return yield* readCluster(clusterId).pipe(
357 Effect.flatMap((cluster) => {
358 if (!cluster?.DBClusterArn) {
359 return Effect.fail(
360 new Error(`DB cluster '${clusterId}' not found`),
361 );
362 }
363 if (cluster.Status !== "available") {
364 return Effect.fail(
365 new Error(
366 `DB cluster '${clusterId}' not available (status: ${cluster.Status})`,
367 ),
368 );
369 }
370 return Effect.succeed(cluster);
371 }),
372 Effect.retry({ schedule: readinessPolicy }),
373 );
374 });
375
376 return {
377 stables: ["dbClusterArn", "dbClusterIdentifier"],
378 // AWS account/region collection: exhaustively paginate
379 // `describeDBClusters` and map each cluster to the exact `read`
380 // Attributes shape. DocumentDB does not surface tags inline on the
381 // cluster, so — mirroring `DBSubnetGroup.list` — we emit an empty tag
382 // map rather than issuing a per-item `listTagsForResource` fan-out.
383 list: () =>
384 docdb.describeDBClusters.pages({}).pipe(
385 Stream.runCollect,
386 Effect.map((chunk) =>
387 Array.from(chunk).flatMap((page) =>
388 (page.DBClusters ?? []).map((cluster) =>
389 toAttrs({ cluster, tags: {} }),
390 ),
391 ),
392 ),
393 ),
394 diff: Effect.fn(function* ({ id, olds, news }) {
395 if (!isResolved(news)) return;
396 if (
397 (yield* toIdentifier(id, olds ?? ({} as DBClusterProps))) !==
398 (yield* toIdentifier(id, news))
399 ) {
400 return { action: "replace" } as const;
401 }
402 // Immutable props — any change forces a fresh cluster.
403 if (
404 olds !== undefined &&
405 ((olds.engine ?? "docdb") !== (news.engine ?? "docdb") ||
406 olds.dbSubnetGroupName !== news.dbSubnetGroupName ||
407 olds.storageEncrypted !== news.storageEncrypted ||
408 olds.kmsKeyId !== news.kmsKeyId ||
409 olds.masterUsername !== news.masterUsername ||
410 olds.globalClusterIdentifier !== news.globalClusterIdentifier ||
411 JSON.stringify(olds.availabilityZones ?? []) !==
412 JSON.stringify(news.availabilityZones ?? []))
413 ) {
414 return { action: "replace" } as const;
415 }
416 }),
417 read: Effect.fn(function* ({ id, olds, output }) {
418 const identifier =
419 output?.dbClusterIdentifier ??
420 (yield* toIdentifier(id, olds ?? ({} as DBClusterProps)));
421 const cluster = yield* readCluster(identifier);
422 if (!cluster?.DBClusterArn) {
423 return undefined;
424 }
425 const tags = yield* readTags(cluster.DBClusterArn);
426 return toAttrs({ cluster, tags });
427 }),
428 reconcile: Effect.fn(function* ({ id, news, output, session }) {
429 const identifier =
430 output?.dbClusterIdentifier ?? (yield* toIdentifier(id, news));
431 const internalTags = yield* createInternalTags(id);
432 const desiredTags = { ...internalTags, ...news.tags };
433 // Redacted end-to-end: distilled's `MasterUserPassword` is
434 // SensitiveString, so the Redacted value is passed through without
435 // unwrapping and never appears in traces.
436 const masterUserPassword = news.masterUserPassword;
437
438 // Observe — fetch live cluster state.
439 let observed = yield* readCluster(identifier);
440
441 // Ensure — create the cluster if it's missing. Tolerate
442 // `DBClusterAlreadyExistsFault` as a race with a peer reconciler.
443 if (!observed?.DBClusterArn) {
444 yield* docdb
445 .createDBCluster({
446 DBClusterIdentifier: identifier,
447 Engine: news.engine ?? "docdb",
448 EngineVersion: news.engineVersion,
449 DBSubnetGroupName: news.dbSubnetGroupName,
450 DBClusterParameterGroupName: news.dbClusterParameterGroupName,
451 VpcSecurityGroupIds: news.vpcSecurityGroupIds,
452 Port: news.port,
453 AvailabilityZones: news.availabilityZones,
454 BackupRetentionPeriod: toWireDays(news.backupRetentionPeriod),
455 PreferredBackupWindow: news.preferredBackupWindow,
456 PreferredMaintenanceWindow: news.preferredMaintenanceWindow,
457 EnableCloudwatchLogsExports: news.enableCloudwatchLogsExports,
458 DeletionProtection: news.deletionProtection,
459 StorageEncrypted: news.storageEncrypted,
460 KmsKeyId: news.kmsKeyId,
461 StorageType: news.storageType,
462 NetworkType: news.networkType,
463 GlobalClusterIdentifier: news.globalClusterIdentifier,
464 MasterUsername: news.masterUsername,
465 MasterUserPassword: masterUserPassword,
466 ManageMasterUserPassword: news.manageMasterUserPassword,
467 MasterUserSecretKmsKeyId: news.masterUserSecretKmsKeyId,
468 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
469 Key,
470 Value,
471 })),
472 })
473 .pipe(
474 Effect.catchTag(
475 "DBClusterAlreadyExistsFault",
476 () => Effect.void,
477 ),
478 );
479
480 observed = yield* waitForCluster(identifier);
481 } else {
482 // Wait for the cluster to settle before any modify so the call
483 // doesn't hit `InvalidDBClusterStateFault`.
484 observed = yield* waitForCluster(identifier);
485
486 // syncCoreSettings — single `modifyDBCluster` carrying scalar
487 // in-place fields, only emitting a field when the desired value
488 // differs from the observed cloud state.
489 const core: docdb.ModifyDBClusterMessage = {
490 DBClusterIdentifier: identifier,
491 ApplyImmediately: true,
492 };
493 let coreDirty = false;
494 const setIf = <K extends keyof docdb.ModifyDBClusterMessage>(
495 key: K,
496 desired: docdb.ModifyDBClusterMessage[K] | undefined,
497 observedValue: unknown,
498 ) => {
499 if (desired !== undefined && desired !== observedValue) {
500 core[key] = desired;
501 coreDirty = true;
502 }
503 };
504 setIf("EngineVersion", news.engineVersion, observed.EngineVersion);
505 setIf("Port", news.port, observed.Port);
506 setIf("BackupRetentionPeriod", toWireDays(news.backupRetentionPeriod), observed.BackupRetentionPeriod); // prettier-ignore
507 setIf("PreferredBackupWindow", news.preferredBackupWindow, observed.PreferredBackupWindow); // prettier-ignore
508 setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore
509 setIf("DeletionProtection", news.deletionProtection, observed.DeletionProtection); // prettier-ignore
510 setIf("StorageType", news.storageType, observed.StorageType);
511 setIf("NetworkType", news.networkType, observed.NetworkType);
512 setIf("DBClusterParameterGroupName", news.dbClusterParameterGroupName, observed.DBClusterParameterGroup); // prettier-ignore
513 setIf("MasterUserSecretKmsKeyId", news.masterUserSecretKmsKeyId, undefined); // prettier-ignore
514 if (news.vpcSecurityGroupIds !== undefined) {
515 core.VpcSecurityGroupIds = news.vpcSecurityGroupIds;
516 coreDirty = true;
517 }
518 if (news.allowMajorVersionUpgrade) {
519 core.AllowMajorVersionUpgrade = true;
520 }
521 // syncMasterPassword — rotation or explicit password update.
522 if (
523 news.manageMasterUserPassword &&
524 news.rotateMasterUserPassword
525 ) {
526 core.RotateMasterUserPassword = true;
527 coreDirty = true;
528 } else if (masterUserPassword !== undefined) {
529 core.MasterUserPassword = masterUserPassword;
530 coreDirty = true;
531 }
532 if (coreDirty) {
533 yield* docdb.modifyDBCluster(core);
534 observed = yield* waitForCluster(identifier);
535 }
536
537 // syncCloudwatchLogsExports — delta-shaped; separate call.
538 const logDelta = logExportDelta(
539 observed.EnabledCloudwatchLogsExports,
540 news.enableCloudwatchLogsExports,
541 );
542 if (logDelta) {
543 yield* docdb.modifyDBCluster({
544 DBClusterIdentifier: identifier,
545 CloudwatchLogsExportConfiguration: logDelta,
546 ApplyImmediately: true,
547 });
548 observed = yield* waitForCluster(identifier);
549 }
550 }
551
552 const dbClusterArn = observed.DBClusterArn ?? "";
553
554 // Sync tags — diff observed cloud tags against desired.
555 const observedTags = yield* readTags(dbClusterArn);
556 const { removed, upsert } = diffTags(observedTags, desiredTags);
557 if (upsert.length > 0 && dbClusterArn) {
558 yield* docdb.addTagsToResource({
559 ResourceName: dbClusterArn,
560 Tags: upsert,
561 });
562 }
563 if (removed.length > 0 && dbClusterArn) {
564 yield* docdb.removeTagsFromResource({
565 ResourceName: dbClusterArn,
566 TagKeys: removed,
567 });
568 }
569
570 yield* session.note(dbClusterArn || identifier);
571 return toAttrs({ cluster: observed, tags: desiredTags });
572 }),
573 delete: Effect.fn(function* ({ output }) {
574 yield* docdb
575 .deleteDBCluster({
576 DBClusterIdentifier: output.dbClusterIdentifier,
577 SkipFinalSnapshot: true,
578 })
579 .pipe(Effect.catchTag("DBClusterNotFoundFault", () => Effect.void));
580 // Block until the cluster is fully gone. DocumentDB deletion is
581 // async; if we return while it is still `deleting`, a dependent
582 // (DBSubnetGroup or VPC) is torn down next and AWS rejects it.
583 yield* Effect.repeat(
584 docdb
585 .describeDBClusters({
586 DBClusterIdentifier: output.dbClusterIdentifier,
587 })
588 .pipe(
589 Effect.as(true),
590 Effect.catchTag("DBClusterNotFoundFault", () =>
591 Effect.succeed(false),
592 ),
593 ),
594 {
595 schedule: Schedule.max([
596 Schedule.fixed("15 seconds"),
597 Schedule.recurs(40),
598 ]),
599 until: (exists) => exists === false,
600 },
601 ).pipe(Effect.catch(() => Effect.void));
602 }),
603 };
604 }),
605 );