1import * as docdbelastic from "@distilled.cloud/aws/docdb-elastic";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import { toWireDays } from "../../Util/Duration.ts";
14import type { Providers } from "../Providers.ts";
15
16export interface ClusterProps {
17 /**
18 * Name of the elastic cluster. If omitted, a deterministic physical name
19 * is generated. Changing the name replaces the cluster.
20 */
21 clusterName?: string;
22 /**
23 * Name of the administrator account for the elastic cluster. Changing the
24 * admin user name replaces the cluster.
25 */
26 adminUserName: string;
27 /**
28 * Password for the administrator account. Must be 8-100 printable ASCII
29 * characters (no `/`, `"` or `@`). The cloud never reports the current
30 * password, so password rotation is detected by comparing against the
31 * previously deployed value.
32 */
33 adminUserPassword: Redacted.Redacted<string>;
34 /**
35 * Authentication type — `"PLAIN_TEXT"` (the password is the literal
36 * credential) or `"SECRET_ARN"` (the password is a Secrets Manager ARN).
37 * @default "PLAIN_TEXT"
38 */
39 authType?: string;
40 /**
41 * Capacity of each shard in vCPUs. Valid values: 2, 4, 8, 16, 32, 64.
42 * Updated in place.
43 * @default 2
44 */
45 shardCapacity?: number;
46 /**
47 * Number of shards in the cluster (1-32). Updated in place.
48 * @default 1
49 */
50 shardCount?: number;
51 /**
52 * Number of replica instances per shard (1-16).
53 * @default 1
54 */
55 shardInstanceCount?: number;
56 /**
57 * VPC security groups that control network access to the cluster
58 * endpoint.
59 * @default the VPC's default security group
60 */
61 vpcSecurityGroupIds?: string[];
62 /**
63 * VPC subnet IDs the cluster spans. Must cover at least two Availability
64 * Zones (three for production workloads).
65 * @default the default VPC's subnets
66 */
67 subnetIds?: string[];
68 /**
69 * Customer-managed KMS key for encryption at rest. Changing the key
70 * replaces the cluster.
71 * @default AWS-owned key
72 */
73 kmsKeyId?: string;
74 /**
75 * Weekly maintenance window, e.g. `"sun:23:00-mon:01:30"` (UTC).
76 */
77 preferredMaintenanceWindow?: string;
78 /**
79 * How long automatic snapshots are retained, e.g. `"7 days"` or
80 * `Duration.days(7)`. Rounded to whole days (1-35) on the wire.
81 */
82 backupRetentionPeriod?: Duration.Input;
83 /**
84 * Daily window (UTC, `HH:MM-HH:MM`) when automatic snapshots are taken.
85 */
86 preferredBackupWindow?: string;
87 /**
88 * User-defined tags for the cluster.
89 */
90 tags?: Record<string, string>;
91}
92
93export interface Cluster extends Resource<
94 "AWS.DocDBElastic.Cluster",
95 ClusterProps,
96 {
97 /** The name of the elastic cluster. */
98 clusterName: string;
99 /** The ARN of the elastic cluster. */
100 clusterArn: string;
101 /** The current status of the cluster, e.g. `ACTIVE`. */
102 status: string;
103 /** The MongoDB-compatible connection endpoint. */
104 clusterEndpoint: string | undefined;
105 /** The administrator username. */
106 adminUserName: string;
107 /** The authentication type (`PLAIN_TEXT` or `SECRET_ARN`). */
108 authType: string;
109 /** vCPU capacity of each shard (2, 4, 8, 16, 32, or 64). */
110 shardCapacity: number;
111 /** Number of shards in the cluster. */
112 shardCount: number;
113 /** Number of replica instances per shard. */
114 shardInstanceCount: number | undefined;
115 /** The VPC security groups attached to the cluster. */
116 vpcSecurityGroupIds: string[];
117 /** The subnets the cluster is deployed into. */
118 subnetIds: string[];
119 /** The KMS key used for encryption at rest. */
120 kmsKeyId: string;
121 /** The weekly window during which maintenance can occur. */
122 preferredMaintenanceWindow: string;
123 /** Number of days automated backups are retained. */
124 backupRetentionPeriod: number | undefined;
125 /** The daily window during which automated backups run. */
126 preferredBackupWindow: string | undefined;
127 /** The tags attached to the cluster. */
128 tags: Record<string, string>;
129 },
130 never,
131 Providers
132> {}
133
134/**
135 * An Amazon DocumentDB elastic cluster — a MongoDB-compatible, sharded
136 * document database that scales workloads to millions of reads/writes per
137 * second without managing instances.
138 *
139 * Elastic clusters take roughly 8-10 minutes to provision and bill per
140 * shard-vCPU-hour while they exist. They are reachable only from inside a
141 * VPC. Destroy clusters you are not using.
142 * ### Creating a Cluster
143 * **Example:** Minimal Elastic Cluster
144 * ```typescript
145 * const cluster = yield* Cluster("Documents", {
146 * adminUserName: "admin",
147 * adminUserPassword: Redacted.make("super-secret-password"),
148 * shardCapacity: 2,
149 * shardCount: 1,
150 * });
151 * ```
152 *
153 * **Example:** Cluster Pinned to Specific Subnets
154 * ```typescript
155 * const cluster = yield* Cluster("Documents", {
156 * adminUserName: "admin",
157 * adminUserPassword: Redacted.make("super-secret-password"),
158 * shardCapacity: 2,
159 * shardCount: 1,
160 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
161 * vpcSecurityGroupIds: [securityGroup.securityGroupId],
162 * backupRetentionPeriod: "1 day",
163 * });
164 * ```
165 *
166 * @resource
167 */
168export const Cluster = Resource<Cluster>("AWS.DocDBElastic.Cluster");
169
170const DEFAULT_AUTH_TYPE = "PLAIN_TEXT";
171const DEFAULT_SHARD_CAPACITY = 2;
172const DEFAULT_SHARD_COUNT = 1;
173
174/** True when two string sets are equal ignoring order and duplicates. */
175const sameStringSet = (
176 a: readonly string[] | undefined,
177 b: readonly string[] | undefined,
178): boolean => {
179 const left = [...new Set(a ?? [])].sort();
180 const right = [...new Set(b ?? [])].sort();
181 return left.length === right.length && left.every((v, i) => v === right[i]);
182};
183
184const toTagRecord = (
185 tags: { [key: string]: string | undefined } | undefined,
186): Record<string, string> =>
187 Object.fromEntries(
188 Object.entries(tags ?? {}).filter(
189 (entry): entry is [string, string] => entry[1] !== undefined,
190 ),
191 );
192
193export const ClusterProvider = () =>
194 Provider.effect(
195 Cluster,
196 Effect.gen(function* () {
197 const toName = (id: string, props: Partial<ClusterProps>) =>
198 props.clusterName
199 ? Effect.succeed(props.clusterName)
200 : createPhysicalName({ id, maxLength: 50, lowercase: true });
201
202 const getByArn = Effect.fn(function* (arn: string) {
203 const response = yield* docdbelastic
204 .getCluster({ clusterArn: arn })
205 .pipe(
206 Effect.catchTag("ResourceNotFoundException", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210 return response?.cluster;
211 });
212
213 // Elastic clusters are addressed by ARN (which embeds a server-side
214 // UUID), so a name-based lookup scans the paginated list.
215 const findByName = Effect.fn(function* (name: string) {
216 const matches = yield* docdbelastic.listClusters.items({}).pipe(
217 Stream.filter((cluster) => cluster.clusterName === name),
218 Stream.take(1),
219 Stream.runCollect,
220 );
221 const summary = Array.from(matches)[0];
222 if (summary === undefined) return undefined;
223 return yield* getByArn(summary.clusterArn);
224 });
225
226 const readCluster = Effect.fn(function* (
227 name: string,
228 arn: string | undefined,
229 ) {
230 if (arn !== undefined) {
231 const cluster = yield* getByArn(arn);
232 if (cluster !== undefined) return cluster;
233 }
234 return yield* findByName(name);
235 });
236
237 const readTags = Effect.fn(function* (arn: string) {
238 const response = yield* docdbelastic
239 .listTagsForResource({ resourceArn: arn })
240 .pipe(Effect.catch(() => Effect.succeed(undefined)));
241 return toTagRecord(response?.tags);
242 });
243
244 // Bounded readiness wait. Elastic cluster provisioning/modification
245 // typically completes in 8-10 minutes; budget ~20 min (80 * 15s).
246 const waitForActive = Effect.fn(function* (
247 name: string,
248 arn: string | undefined,
249 ) {
250 const policy = Schedule.max([
251 Schedule.fixed("15 seconds"),
252 Schedule.recurs(80),
253 ]);
254 return yield* readCluster(name, arn).pipe(
255 Effect.flatMap((cluster) => {
256 if (cluster === undefined) {
257 return Effect.fail(
258 new Error(`Elastic cluster '${name}' not found`),
259 );
260 }
261 if (cluster.status !== "ACTIVE") {
262 return Effect.fail(
263 new Error(
264 `Elastic cluster '${name}' not active (status: ${cluster.status})`,
265 ),
266 );
267 }
268 return Effect.succeed(cluster);
269 }),
270 Effect.retry({ schedule: policy }),
271 );
272 });
273
274 const toAttrs = Effect.fn(function* (cluster: docdbelastic.Cluster) {
275 return {
276 clusterName: cluster.clusterName,
277 clusterArn: cluster.clusterArn,
278 status: cluster.status,
279 clusterEndpoint: cluster.clusterEndpoint,
280 adminUserName: cluster.adminUserName,
281 authType: cluster.authType,
282 shardCapacity: cluster.shardCapacity,
283 shardCount: cluster.shardCount,
284 shardInstanceCount: cluster.shardInstanceCount,
285 vpcSecurityGroupIds: [...cluster.vpcSecurityGroupIds],
286 subnetIds: [...cluster.subnetIds],
287 kmsKeyId: cluster.kmsKeyId,
288 preferredMaintenanceWindow: cluster.preferredMaintenanceWindow,
289 backupRetentionPeriod: cluster.backupRetentionPeriod,
290 preferredBackupWindow: cluster.preferredBackupWindow,
291 tags: yield* readTags(cluster.clusterArn),
292 };
293 });
294
295 return {
296 stables: ["clusterName", "clusterArn"],
297
298 diff: Effect.fn(function* ({ id, olds, news }) {
299 if (!isResolved(news)) return undefined;
300 const n = news;
301 const o = olds;
302 if (n === undefined || o === undefined) return undefined;
303 if ((yield* toName(id, o)) !== (yield* toName(id, n))) {
304 return { action: "replace" } as const;
305 }
306 // Create-only properties force a replacement.
307 if (n.adminUserName !== o.adminUserName) {
308 return { action: "replace" } as const;
309 }
310 if ((n.kmsKeyId ?? undefined) !== (o.kmsKeyId ?? undefined)) {
311 return { action: "replace" } as const;
312 }
313 }),
314
315 read: Effect.fn(function* ({ id, olds, output }) {
316 const name =
317 output?.clusterName ??
318 (yield* toName(
319 id,
320 olds ?? {
321 adminUserName: "",
322 adminUserPassword: Redacted.make(""),
323 },
324 ));
325 const cluster = yield* readCluster(name, output?.clusterArn);
326 if (cluster === undefined) return undefined;
327 const attrs = yield* toAttrs(cluster);
328 return (yield* hasAlchemyTags(id, attrs.tags))
329 ? attrs
330 : Unowned(attrs);
331 }),
332
333 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
334 const props = news!;
335 const name = output?.clusterName ?? (yield* toName(id, props));
336 const internalTags = yield* createInternalTags(id);
337 const desiredTags = { ...internalTags, ...props.tags };
338
339 // 1. Observe — cloud state is authoritative; output is only an
340 // ARN cache.
341 let observed = yield* readCluster(name, output?.clusterArn);
342 let created = false;
343
344 // 2. Ensure — create if missing; tolerate a create race
345 // (ConflictException) by re-reading.
346 if (observed === undefined) {
347 const response = yield* docdbelastic
348 .createCluster({
349 clusterName: name,
350 adminUserName: props.adminUserName,
351 adminUserPassword: props.adminUserPassword,
352 authType: props.authType ?? DEFAULT_AUTH_TYPE,
353 shardCapacity: props.shardCapacity ?? DEFAULT_SHARD_CAPACITY,
354 shardCount: props.shardCount ?? DEFAULT_SHARD_COUNT,
355 shardInstanceCount: props.shardInstanceCount,
356 vpcSecurityGroupIds: props.vpcSecurityGroupIds,
357 subnetIds: props.subnetIds,
358 kmsKeyId: props.kmsKeyId,
359 preferredMaintenanceWindow: props.preferredMaintenanceWindow,
360 backupRetentionPeriod: toWireDays(props.backupRetentionPeriod),
361 preferredBackupWindow: props.preferredBackupWindow,
362 tags: desiredTags,
363 })
364 .pipe(
365 Effect.catchTag("ConflictException", () =>
366 Effect.succeed(undefined),
367 ),
368 );
369 observed = response?.cluster ?? (yield* findByName(name));
370 created = true;
371 }
372 if (observed === undefined) {
373 return yield* Effect.fail(
374 new Error(`Elastic cluster '${name}' not found after create`),
375 );
376 }
377 const arn = observed.clusterArn;
378
379 // Provisioning and in-flight modifications both surface as a
380 // non-ACTIVE status; wait (bounded) so update calls do not hit
381 // ConflictException.
382 observed = yield* waitForActive(name, arn);
383
384 // 3. Sync — compute the update delta from OBSERVED state.
385 const update: docdbelastic.UpdateClusterInput = { clusterArn: arn };
386 let mutated = false;
387 if (
388 props.shardCapacity !== undefined &&
389 props.shardCapacity !== observed.shardCapacity
390 ) {
391 update.shardCapacity = props.shardCapacity;
392 mutated = true;
393 }
394 if (
395 props.shardCount !== undefined &&
396 props.shardCount !== observed.shardCount
397 ) {
398 update.shardCount = props.shardCount;
399 mutated = true;
400 }
401 if (
402 props.shardInstanceCount !== undefined &&
403 props.shardInstanceCount !== observed.shardInstanceCount
404 ) {
405 update.shardInstanceCount = props.shardInstanceCount;
406 mutated = true;
407 }
408 if (
409 props.vpcSecurityGroupIds !== undefined &&
410 !sameStringSet(
411 props.vpcSecurityGroupIds,
412 observed.vpcSecurityGroupIds,
413 )
414 ) {
415 update.vpcSecurityGroupIds = props.vpcSecurityGroupIds;
416 mutated = true;
417 }
418 if (
419 props.subnetIds !== undefined &&
420 !sameStringSet(props.subnetIds, observed.subnetIds)
421 ) {
422 update.subnetIds = props.subnetIds;
423 mutated = true;
424 }
425 if (
426 props.preferredMaintenanceWindow !== undefined &&
427 props.preferredMaintenanceWindow !==
428 observed.preferredMaintenanceWindow
429 ) {
430 update.preferredMaintenanceWindow =
431 props.preferredMaintenanceWindow;
432 mutated = true;
433 }
434 const desiredRetentionDays = toWireDays(props.backupRetentionPeriod);
435 if (
436 desiredRetentionDays !== undefined &&
437 desiredRetentionDays !== observed.backupRetentionPeriod
438 ) {
439 update.backupRetentionPeriod = desiredRetentionDays;
440 mutated = true;
441 }
442 if (
443 props.preferredBackupWindow !== undefined &&
444 props.preferredBackupWindow !== observed.preferredBackupWindow
445 ) {
446 update.preferredBackupWindow = props.preferredBackupWindow;
447 mutated = true;
448 }
449 if ((props.authType ?? DEFAULT_AUTH_TYPE) !== observed.authType) {
450 update.authType = props.authType ?? DEFAULT_AUTH_TYPE;
451 update.adminUserPassword = props.adminUserPassword;
452 mutated = true;
453 } else if (
454 // The cloud never reports the password; the previously deployed
455 // value is the only diff baseline (skipped right after create —
456 // the password was just applied).
457 !created &&
458 olds !== undefined &&
459 Redacted.value(props.adminUserPassword) !==
460 Redacted.value(olds.adminUserPassword)
461 ) {
462 update.adminUserPassword = props.adminUserPassword;
463 mutated = true;
464 }
465 if (mutated) {
466 yield* docdbelastic.updateCluster(update);
467 observed = yield* waitForActive(name, arn);
468 }
469
470 // 3b. Sync tags — diff against OBSERVED cloud tags.
471 const observedTags = yield* readTags(arn);
472 const { removed, upsert } = diffTags(observedTags, desiredTags);
473 if (upsert.length > 0) {
474 yield* docdbelastic.tagResource({
475 resourceArn: arn,
476 tags: Object.fromEntries(
477 upsert.map((tag) => [tag.Key, tag.Value]),
478 ),
479 });
480 }
481 if (removed.length > 0) {
482 yield* docdbelastic.untagResource({
483 resourceArn: arn,
484 tagKeys: removed,
485 });
486 }
487
488 yield* session.note(name);
489 return yield* toAttrs(observed);
490 }),
491
492 delete: Effect.fn(function* ({ output }) {
493 // A cluster mid-create/modify rejects deletion with
494 // ConflictException — retry (bounded) while it settles. A cluster
495 // already deleting (or gone) is success.
496 const observed = yield* getByArn(output.clusterArn);
497 if (observed === undefined || observed.status === "DELETING") {
498 return;
499 }
500 yield* docdbelastic
501 .deleteCluster({ clusterArn: output.clusterArn })
502 .pipe(
503 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
504 Effect.retry({
505 while: (e) => e._tag === "ConflictException",
506 schedule: Schedule.max([
507 Schedule.fixed("15 seconds"),
508 Schedule.recurs(60),
509 ]),
510 }),
511 );
512 }),
513
514 list: () =>
515 docdbelastic.listClusters.items({}).pipe(
516 Stream.runCollect,
517 Effect.flatMap((summaries) =>
518 Effect.forEach(
519 Array.from(summaries),
520 (summary) =>
521 getByArn(summary.clusterArn).pipe(
522 Effect.flatMap((cluster) =>
523 cluster === undefined
524 ? Effect.succeed(undefined)
525 : toAttrs(cluster),
526 ),
527 ),
528 { concurrency: 4 },
529 ),
530 ),
531 Effect.map((attrs) =>
532 attrs.filter((a): a is NonNullable<typeof a> => a !== undefined),
533 ),
534 ),
535 };
536 }),
537 );