1import * as neptune from "@distilled.cloud/aws/neptune";
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 { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags } from "../../Tags.ts";
11import { toWireDays } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface ServerlessV2ScalingConfiguration {
15 /**
16 * Minimum Neptune Capacity Units (NCUs), e.g. `1`.
17 */
18 minCapacity?: number;
19 /**
20 * Maximum Neptune Capacity Units (NCUs), e.g. `2.5`.
21 */
22 maxCapacity?: number;
23}
24
25export interface DBClusterProps {
26 /**
27 * Cluster identifier. If omitted, Alchemy generates one.
28 */
29 dbClusterIdentifier?: string;
30 /**
31 * Database engine. Neptune only supports `neptune`.
32 * Changing it forces replacement.
33 * @default "neptune"
34 */
35 engine?: string;
36 /**
37 * Optional engine version, e.g. `1.4.5.0`. Changed in place via
38 * `modifyDBCluster` (may require `allowMajorVersionUpgrade`).
39 */
40 engineVersion?: string;
41 /**
42 * Subnet group the cluster is placed into. Neptune is VPC-only, so this
43 * is effectively required. Immutable — forces replacement.
44 */
45 dbSubnetGroupName?: string;
46 /**
47 * Cluster parameter group name. In-place modify.
48 */
49 dbClusterParameterGroupName?: string;
50 /**
51 * Security groups attached to the cluster. In-place modify.
52 */
53 vpcSecurityGroupIds?: string[];
54 /**
55 * Listener port.
56 * @default 8182
57 */
58 port?: number;
59 /**
60 * Availability zones for cluster placement. Immutable — forces replacement.
61 */
62 availabilityZones?: string[];
63 /**
64 * Backup retention period (1-35 days, e.g. `"7 days"` or
65 * `Duration.days(7)`). Sent to the API in whole days. In-place modify.
66 */
67 backupRetentionPeriod?: Duration.Input;
68 /**
69 * Daily backup window, e.g. `07:00-09:00`. In-place modify.
70 */
71 preferredBackupWindow?: string;
72 /**
73 * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify.
74 */
75 preferredMaintenanceWindow?: string;
76 /**
77 * Log types to export to CloudWatch Logs (`audit`, `slowquery`). Diffed
78 * against observed state and applied via the delta-shaped
79 * `CloudwatchLogsExportConfiguration` on modify.
80 */
81 enableCloudwatchLogsExports?: string[];
82 /**
83 * Enable IAM database authentication (SigV4-signed data-plane requests).
84 * In-place modify.
85 */
86 enableIAMDatabaseAuthentication?: boolean;
87 /**
88 * Serverless v2 (NCU-based) scaling configuration. Required when using
89 * `db.serverless` instances. In-place modify.
90 */
91 serverlessV2ScalingConfiguration?: ServerlessV2ScalingConfiguration;
92 /**
93 * Block accidental deletion. In-place modify.
94 */
95 deletionProtection?: boolean;
96 /**
97 * Whether the storage is encrypted. Immutable — forces replacement.
98 */
99 storageEncrypted?: boolean;
100 /**
101 * Optional KMS key used for storage encryption. Immutable — forces replace.
102 */
103 kmsKeyId?: string;
104 /**
105 * Storage type, `standard` | `iopt1`. In-place modify.
106 */
107 storageType?: string;
108 /**
109 * Copy tags to snapshots. In-place modify.
110 */
111 copyTagsToSnapshot?: boolean;
112 /**
113 * Join this cluster to a Neptune global database. Immutable on create.
114 */
115 globalClusterIdentifier?: string;
116 /**
117 * Allow a major engine-version upgrade during a modify. Modify-only flag.
118 */
119 allowMajorVersionUpgrade?: boolean;
120 /**
121 * IAM roles to associate with the cluster — e.g. an S3 read role for the
122 * Neptune bulk loader, or a SageMaker role for Neptune ML (set
123 * `featureName` where the feature requires it). Reconciled in place by
124 * diffing the observed cloud associations against this list via
125 * `addRoleToDBCluster`/`removeRoleFromDBCluster`.
126 */
127 associatedRoles?: Array<{
128 /** ARN of the IAM role to associate. */
129 roleArn: string;
130 /** Neptune feature the role is scoped to (omit for the default). */
131 featureName?: string;
132 }>;
133 /**
134 * User-defined tags.
135 */
136 tags?: Record<string, string>;
137}
138
139export interface DBCluster extends Resource<
140 "AWS.Neptune.DBCluster",
141 DBClusterProps,
142 {
143 /** Identifier of the cluster. */
144 dbClusterIdentifier: string;
145 /** ARN of the cluster. */
146 dbClusterArn: string;
147 /** Name of the subnet group the cluster is placed in. */
148 dbSubnetGroupName: string | undefined;
149 /** Name of the attached cluster parameter group. */
150 dbClusterParameterGroupName: string | undefined;
151 /** Writer (cluster) endpoint host name. */
152 endpoint: string | undefined;
153 /** Load-balanced reader endpoint host name. */
154 readerEndpoint: string | undefined;
155 /** Port the cluster listens on (default 8182). */
156 port: number | undefined;
157 /** Database engine (`neptune`). */
158 engine: string;
159 /** Running engine version. */
160 engineVersion: string | undefined;
161 /** Current lifecycle status (e.g. `creating`, `available`). */
162 status: string | undefined;
163 /** IDs of the VPC security groups attached to the cluster. */
164 vpcSecurityGroupIds: string[];
165 /** Automated backup retention period, in days. */
166 backupRetentionPeriod: number | undefined;
167 /** Daily window during which automated backups are taken. */
168 preferredBackupWindow: string | undefined;
169 /** Weekly window during which maintenance may occur. */
170 preferredMaintenanceWindow: string | undefined;
171 /** Whether storage is encrypted at rest. */
172 storageEncrypted: boolean | undefined;
173 /** KMS key encrypting the cluster's storage. */
174 kmsKeyId: string | undefined;
175 /** Whether deletion protection is enabled. */
176 deletionProtection: boolean | undefined;
177 /** Whether IAM database authentication is enabled. */
178 iamDatabaseAuthenticationEnabled: boolean | undefined;
179 /** IAM roles associated with the cluster (bulk loader, Neptune ML). */
180 associatedRoles: Array<{
181 /** ARN of the associated IAM role. */
182 roleArn: string | undefined;
183 /** Neptune feature the role is scoped to. */
184 featureName: string | undefined;
185 /** Association status (`ACTIVE`, `PENDING`, `INVALID`). */
186 status: string | undefined;
187 }>;
188 /** Serverless v2 (NCU) scaling range, if configured. */
189 serverlessV2ScalingConfiguration:
190 | { minCapacity: number | undefined; maxCapacity: number | undefined }
191 | undefined;
192 /** Instances that are members of the cluster. */
193 dbClusterMembers: Array<{
194 /** Identifier of the member instance. */
195 dbInstanceIdentifier: string | undefined;
196 /** Whether the member is the cluster's writer. */
197 isClusterWriter: boolean | undefined;
198 /** Failover promotion priority of the member. */
199 promotionTier: number | undefined;
200 }>;
201 /** Immutable, region-unique identifier of the cluster. */
202 dbClusterResourceId: string | undefined;
203 /** Route 53 hosted zone id of the cluster endpoints. */
204 hostedZoneId: string | undefined;
205 /** Whether the cluster has instances in multiple Availability Zones. */
206 multiAZ: boolean | undefined;
207 /** Log types exported to CloudWatch Logs (e.g. `audit`). */
208 enabledCloudwatchLogsExports: string[];
209 /** Creation time of the cluster (ISO 8601). */
210 clusterCreateTime: string | undefined;
211 /** Storage type (`standard` or `iopt1`). */
212 storageType: string | undefined;
213 /** Whether cluster tags are copied to snapshots. */
214 copyTagsToSnapshot: boolean | undefined;
215 /** Tags on the cluster (user + internal Alchemy tags). */
216 tags: Record<string, string>;
217 },
218 never,
219 Providers
220> {}
221
222/**
223 * An Amazon Neptune graph database cluster.
224 *
225 * `DBCluster` owns the writer and reader endpoints and cluster-wide
226 * networking; compute is added via {@link DBInstance}. Neptune serves
227 * Gremlin, openCypher, and SPARQL over the cluster endpoint (default port
228 * 8182) and is reachable only from inside the VPC. Provisioning a cluster
229 * plus its first instance takes ~10 minutes.
230 *
231 * Mutable fields are reconciled in place against the observed cloud state;
232 * immutable fields (`engine`, `dbSubnetGroupName`, `storageEncrypted`,
233 * `kmsKeyId`, `globalClusterIdentifier`, `availabilityZones`) force a
234 * replacement.
235 * ### Creating a Cluster
236 * **Example:** Neptune cluster with IAM auth
237 * ```typescript
238 * const cluster = yield* DBCluster("Graph", {
239 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
240 * vpcSecurityGroupIds: [sg.groupId],
241 * enableIAMDatabaseAuthentication: true,
242 * backupRetentionPeriod: "1 day",
243 * deletionProtection: false,
244 * });
245 * ```
246 *
247 * ### Serverless
248 * **Example:** Serverless v2 cluster (pair with a `db.serverless` instance)
249 * ```typescript
250 * const cluster = yield* DBCluster("Graph", {
251 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
252 * serverlessV2ScalingConfiguration: {
253 * minCapacity: 1,
254 * maxCapacity: 2.5,
255 * },
256 * });
257 * ```
258 *
259 * @resource
260 */
261export const DBCluster = Resource<DBCluster>("AWS.Neptune.DBCluster");
262
263const toTagRecord = (
264 tags: Array<{ Key?: string; Value?: string }> | undefined,
265): Record<string, string> =>
266 Object.fromEntries(
267 (tags ?? [])
268 .filter(
269 (tag): tag is { Key: string; Value: string } =>
270 typeof tag.Key === "string" && typeof tag.Value === "string",
271 )
272 .map((tag) => [tag.Key, tag.Value]),
273 );
274
275const toAttrs = ({
276 cluster,
277 tags,
278}: {
279 cluster: neptune.DBCluster;
280 tags: Record<string, string>;
281}): DBCluster["Attributes"] => ({
282 dbClusterIdentifier: cluster.DBClusterIdentifier ?? "",
283 dbClusterArn: cluster.DBClusterArn ?? "",
284 dbSubnetGroupName: cluster.DBSubnetGroup,
285 dbClusterParameterGroupName: cluster.DBClusterParameterGroup,
286 endpoint: cluster.Endpoint,
287 readerEndpoint: cluster.ReaderEndpoint,
288 port: cluster.Port,
289 engine: cluster.Engine ?? "neptune",
290 engineVersion: cluster.EngineVersion,
291 status: cluster.Status,
292 vpcSecurityGroupIds: (cluster.VpcSecurityGroups ?? []).flatMap((group) =>
293 group.VpcSecurityGroupId ? [group.VpcSecurityGroupId] : [],
294 ),
295 backupRetentionPeriod: cluster.BackupRetentionPeriod,
296 preferredBackupWindow: cluster.PreferredBackupWindow,
297 preferredMaintenanceWindow: cluster.PreferredMaintenanceWindow,
298 storageEncrypted: cluster.StorageEncrypted,
299 kmsKeyId: cluster.KmsKeyId,
300 deletionProtection: cluster.DeletionProtection,
301 iamDatabaseAuthenticationEnabled: cluster.IAMDatabaseAuthenticationEnabled,
302 associatedRoles: (cluster.AssociatedRoles ?? []).map((role) => ({
303 roleArn: role.RoleArn,
304 featureName: role.FeatureName,
305 status: role.Status,
306 })),
307 serverlessV2ScalingConfiguration: cluster.ServerlessV2ScalingConfiguration
308 ? {
309 minCapacity: cluster.ServerlessV2ScalingConfiguration.MinCapacity,
310 maxCapacity: cluster.ServerlessV2ScalingConfiguration.MaxCapacity,
311 }
312 : undefined,
313 dbClusterMembers: (cluster.DBClusterMembers ?? []).map((member) => ({
314 dbInstanceIdentifier: member.DBInstanceIdentifier,
315 isClusterWriter: member.IsClusterWriter,
316 promotionTier: member.PromotionTier,
317 })),
318 dbClusterResourceId: cluster.DbClusterResourceId,
319 hostedZoneId: cluster.HostedZoneId,
320 multiAZ: cluster.MultiAZ,
321 enabledCloudwatchLogsExports: cluster.EnabledCloudwatchLogsExports ?? [],
322 clusterCreateTime: cluster.ClusterCreateTime?.toISOString(),
323 storageType: cluster.StorageType,
324 copyTagsToSnapshot: cluster.CopyTagsToSnapshot,
325 tags,
326});
327
328/**
329 * Compute the CloudWatch Logs export delta. The modify API is delta-shaped
330 * (`EnableLogTypes`/`DisableLogTypes`), so it must NOT carry the full set.
331 * Returns `undefined` when there is no change.
332 */
333const logExportDelta = (
334 observed: string[] | undefined,
335 desired: string[] | undefined,
336): neptune.CloudwatchLogsExportConfiguration | undefined => {
337 if (desired === undefined) return undefined;
338 const have = new Set(observed ?? []);
339 const want = new Set(desired);
340 const EnableLogTypes = [...want].filter((t) => !have.has(t));
341 const DisableLogTypes = [...have].filter((t) => !want.has(t));
342 if (EnableLogTypes.length === 0 && DisableLogTypes.length === 0) {
343 return undefined;
344 }
345 return {
346 ...(EnableLogTypes.length > 0 ? { EnableLogTypes } : {}),
347 ...(DisableLogTypes.length > 0 ? { DisableLogTypes } : {}),
348 };
349};
350
351export const DBClusterProvider = () =>
352 Provider.effect(
353 DBCluster,
354 Effect.gen(function* () {
355 const toIdentifier = (id: string, props: DBClusterProps) =>
356 props.dbClusterIdentifier
357 ? Effect.succeed(props.dbClusterIdentifier)
358 : createPhysicalName({ id, maxLength: 63 });
359
360 const readCluster = Effect.fn(function* (clusterId: string) {
361 const response = yield* neptune
362 .describeDBClusters({
363 DBClusterIdentifier: clusterId,
364 })
365 .pipe(
366 Effect.catchTag("DBClusterNotFoundFault", () =>
367 Effect.succeed(undefined),
368 ),
369 );
370 return response?.DBClusters?.[0];
371 });
372
373 const readTags = Effect.fn(function* (arn: string | undefined) {
374 if (!arn) return {} as Record<string, string>;
375 const response = yield* neptune
376 .listTagsForResource({ ResourceName: arn })
377 .pipe(
378 Effect.catchTag("DBClusterNotFoundFault", () =>
379 Effect.succeed(undefined),
380 ),
381 );
382 return toTagRecord(response?.TagList);
383 });
384
385 // Bounded readiness wait. Gate on cluster `Status === "available"` so a
386 // follow-on `modifyDBCluster` doesn't hit `InvalidDBClusterStateFault`.
387 // Budgets ~10 min (60 * 10s) for slow provisioning.
388 const waitForCluster = Effect.fn(function* (clusterId: string) {
389 const readinessPolicy = Schedule.max([
390 Schedule.fixed("10 seconds"),
391 Schedule.recurs(60),
392 ]);
393 return yield* readCluster(clusterId).pipe(
394 Effect.flatMap((cluster) => {
395 if (!cluster?.DBClusterArn) {
396 return Effect.fail(
397 new Error(`DB cluster '${clusterId}' not found`),
398 );
399 }
400 if (cluster.Status !== "available") {
401 return Effect.fail(
402 new Error(
403 `DB cluster '${clusterId}' not available (status: ${cluster.Status})`,
404 ),
405 );
406 }
407 return Effect.succeed(cluster);
408 }),
409 Effect.retry({ schedule: readinessPolicy }),
410 );
411 });
412
413 return {
414 stables: ["dbClusterArn", "dbClusterIdentifier"],
415 // AWS account/region collection: the RDS-family control plane serves
416 // clusters for every engine (RDS, DocumentDB, Neptune), so filter to
417 // `engine = neptune`. Tags are not surfaced inline — mirror
418 // `DBSubnetGroup.list` and emit an empty tag map rather than a
419 // per-item `listTagsForResource` fan-out.
420 list: () =>
421 neptune.describeDBClusters
422 .pages({ Filters: [{ Name: "engine", Values: ["neptune"] }] })
423 .pipe(
424 Stream.runCollect,
425 Effect.map((chunk) =>
426 Array.from(chunk).flatMap((page) =>
427 (page.DBClusters ?? []).map((cluster) =>
428 toAttrs({ cluster, tags: {} }),
429 ),
430 ),
431 ),
432 ),
433 diff: Effect.fn(function* ({ id, olds, news }) {
434 if (!isResolved(news)) return;
435 if (
436 (yield* toIdentifier(id, olds ?? ({} as DBClusterProps))) !==
437 (yield* toIdentifier(id, news))
438 ) {
439 return { action: "replace" } as const;
440 }
441 // Immutable props — any change forces a fresh cluster.
442 if (
443 olds !== undefined &&
444 ((olds.engine ?? "neptune") !== (news.engine ?? "neptune") ||
445 olds.dbSubnetGroupName !== news.dbSubnetGroupName ||
446 olds.storageEncrypted !== news.storageEncrypted ||
447 olds.kmsKeyId !== news.kmsKeyId ||
448 olds.globalClusterIdentifier !== news.globalClusterIdentifier ||
449 JSON.stringify(olds.availabilityZones ?? []) !==
450 JSON.stringify(news.availabilityZones ?? []))
451 ) {
452 return { action: "replace" } as const;
453 }
454 }),
455 read: Effect.fn(function* ({ id, olds, output }) {
456 const identifier =
457 output?.dbClusterIdentifier ??
458 (yield* toIdentifier(id, olds ?? ({} as DBClusterProps)));
459 const cluster = yield* readCluster(identifier);
460 if (!cluster?.DBClusterArn) {
461 return undefined;
462 }
463 const tags = yield* readTags(cluster.DBClusterArn);
464 return toAttrs({ cluster, tags });
465 }),
466 reconcile: Effect.fn(function* ({ id, news, output, session }) {
467 const identifier =
468 output?.dbClusterIdentifier ?? (yield* toIdentifier(id, news));
469 const internalTags = yield* createInternalTags(id);
470 const desiredTags = { ...internalTags, ...news.tags };
471 // The wire field is whole days.
472 const backupRetentionDays = toWireDays(news.backupRetentionPeriod);
473
474 // Observe — fetch live cluster state.
475 let observed = yield* readCluster(identifier);
476
477 // Ensure — create the cluster if it's missing. Tolerate
478 // `DBClusterAlreadyExistsFault` as a race with a peer reconciler.
479 if (!observed?.DBClusterArn) {
480 yield* neptune
481 .createDBCluster({
482 DBClusterIdentifier: identifier,
483 Engine: news.engine ?? "neptune",
484 EngineVersion: news.engineVersion,
485 DBSubnetGroupName: news.dbSubnetGroupName,
486 DBClusterParameterGroupName: news.dbClusterParameterGroupName,
487 VpcSecurityGroupIds: news.vpcSecurityGroupIds,
488 Port: news.port,
489 AvailabilityZones: news.availabilityZones,
490 BackupRetentionPeriod: backupRetentionDays,
491 PreferredBackupWindow: news.preferredBackupWindow,
492 PreferredMaintenanceWindow: news.preferredMaintenanceWindow,
493 EnableCloudwatchLogsExports: news.enableCloudwatchLogsExports,
494 EnableIAMDatabaseAuthentication:
495 news.enableIAMDatabaseAuthentication,
496 ServerlessV2ScalingConfiguration:
497 news.serverlessV2ScalingConfiguration
498 ? {
499 MinCapacity:
500 news.serverlessV2ScalingConfiguration.minCapacity,
501 MaxCapacity:
502 news.serverlessV2ScalingConfiguration.maxCapacity,
503 }
504 : undefined,
505 DeletionProtection: news.deletionProtection,
506 StorageEncrypted: news.storageEncrypted,
507 KmsKeyId: news.kmsKeyId,
508 StorageType: news.storageType,
509 CopyTagsToSnapshot: news.copyTagsToSnapshot,
510 GlobalClusterIdentifier: news.globalClusterIdentifier,
511 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
512 Key,
513 Value,
514 })),
515 })
516 .pipe(
517 Effect.catchTag(
518 "DBClusterAlreadyExistsFault",
519 () => Effect.void,
520 ),
521 );
522
523 observed = yield* waitForCluster(identifier);
524 } else {
525 // Wait for the cluster to settle before any modify so the call
526 // doesn't hit `InvalidDBClusterStateFault`.
527 observed = yield* waitForCluster(identifier);
528
529 // syncCoreSettings — single `modifyDBCluster` carrying scalar
530 // in-place fields, only emitting a field when the desired value
531 // differs from the observed cloud state.
532 const core: neptune.ModifyDBClusterMessage = {
533 DBClusterIdentifier: identifier,
534 ApplyImmediately: true,
535 };
536 let coreDirty = false;
537 const setIf = <K extends keyof neptune.ModifyDBClusterMessage>(
538 key: K,
539 desired: neptune.ModifyDBClusterMessage[K] | undefined,
540 observedValue: unknown,
541 ) => {
542 if (desired !== undefined && desired !== observedValue) {
543 core[key] = desired;
544 coreDirty = true;
545 }
546 };
547 setIf("EngineVersion", news.engineVersion, observed.EngineVersion);
548 setIf("Port", news.port, observed.Port);
549 setIf("BackupRetentionPeriod", backupRetentionDays, observed.BackupRetentionPeriod); // prettier-ignore
550 setIf("PreferredBackupWindow", news.preferredBackupWindow, observed.PreferredBackupWindow); // prettier-ignore
551 setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore
552 setIf("DeletionProtection", news.deletionProtection, observed.DeletionProtection); // prettier-ignore
553 setIf("StorageType", news.storageType, observed.StorageType);
554 setIf("CopyTagsToSnapshot", news.copyTagsToSnapshot, observed.CopyTagsToSnapshot); // prettier-ignore
555 setIf("EnableIAMDatabaseAuthentication", news.enableIAMDatabaseAuthentication, observed.IAMDatabaseAuthenticationEnabled); // prettier-ignore
556 setIf("DBClusterParameterGroupName", news.dbClusterParameterGroupName, observed.DBClusterParameterGroup); // prettier-ignore
557 if (news.vpcSecurityGroupIds !== undefined) {
558 const observedGroups = (observed.VpcSecurityGroups ?? [])
559 .flatMap((g) =>
560 g.VpcSecurityGroupId ? [g.VpcSecurityGroupId] : [],
561 )
562 .sort();
563 if (
564 JSON.stringify(observedGroups) !==
565 JSON.stringify([...news.vpcSecurityGroupIds].sort())
566 ) {
567 core.VpcSecurityGroupIds = news.vpcSecurityGroupIds;
568 coreDirty = true;
569 }
570 }
571 if (news.serverlessV2ScalingConfiguration !== undefined) {
572 const observedScaling = observed.ServerlessV2ScalingConfiguration;
573 if (
574 observedScaling?.MinCapacity !==
575 news.serverlessV2ScalingConfiguration.minCapacity ||
576 observedScaling?.MaxCapacity !==
577 news.serverlessV2ScalingConfiguration.maxCapacity
578 ) {
579 core.ServerlessV2ScalingConfiguration = {
580 MinCapacity:
581 news.serverlessV2ScalingConfiguration.minCapacity,
582 MaxCapacity:
583 news.serverlessV2ScalingConfiguration.maxCapacity,
584 };
585 coreDirty = true;
586 }
587 }
588 if (news.allowMajorVersionUpgrade) {
589 core.AllowMajorVersionUpgrade = true;
590 }
591 if (coreDirty) {
592 yield* neptune.modifyDBCluster(core);
593 observed = yield* waitForCluster(identifier);
594 }
595
596 // syncCloudwatchLogsExports — delta-shaped; separate call.
597 const logDelta = logExportDelta(
598 observed.EnabledCloudwatchLogsExports,
599 news.enableCloudwatchLogsExports,
600 );
601 if (logDelta) {
602 yield* neptune.modifyDBCluster({
603 DBClusterIdentifier: identifier,
604 CloudwatchLogsExportConfiguration: logDelta,
605 ApplyImmediately: true,
606 });
607 observed = yield* waitForCluster(identifier);
608 }
609 }
610
611 // Sync associated IAM roles — diff observed cloud associations
612 // against desired. Only runs when the prop is set, so clusters
613 // that don't manage roles through Alchemy are left untouched.
614 if (news.associatedRoles !== undefined) {
615 const roleKey = (
616 roleArn: string | undefined,
617 featureName: string | undefined,
618 ) => `${roleArn ?? ""}|${featureName ?? ""}`;
619 const observedRoles = observed.AssociatedRoles ?? [];
620 const observedKeys = new Set(
621 observedRoles.map((role) =>
622 roleKey(role.RoleArn, role.FeatureName),
623 ),
624 );
625 const desiredKeys = new Set(
626 news.associatedRoles.map((role) =>
627 roleKey(role.roleArn, role.featureName),
628 ),
629 );
630 for (const role of news.associatedRoles) {
631 if (!observedKeys.has(roleKey(role.roleArn, role.featureName))) {
632 yield* neptune
633 .addRoleToDBCluster({
634 DBClusterIdentifier: identifier,
635 RoleArn: role.roleArn,
636 FeatureName: role.featureName,
637 })
638 .pipe(
639 Effect.catchTag(
640 "DBClusterRoleAlreadyExistsFault",
641 () => Effect.void,
642 ),
643 );
644 }
645 }
646 for (const role of observedRoles) {
647 if (
648 role.RoleArn !== undefined &&
649 !desiredKeys.has(roleKey(role.RoleArn, role.FeatureName))
650 ) {
651 yield* neptune
652 .removeRoleFromDBCluster({
653 DBClusterIdentifier: identifier,
654 RoleArn: role.RoleArn,
655 FeatureName: role.FeatureName,
656 })
657 .pipe(
658 Effect.catchTag(
659 "DBClusterRoleNotFoundFault",
660 () => Effect.void,
661 ),
662 );
663 }
664 }
665 // Re-observe so the returned attributes carry the fresh
666 // associations.
667 observed = (yield* readCluster(identifier)) ?? observed;
668 }
669
670 const dbClusterArn = observed.DBClusterArn ?? "";
671
672 // Sync tags — diff observed cloud tags against desired.
673 const observedTags = yield* readTags(dbClusterArn);
674 const { removed, upsert } = diffTags(observedTags, desiredTags);
675 if (upsert.length > 0 && dbClusterArn) {
676 yield* neptune.addTagsToResource({
677 ResourceName: dbClusterArn,
678 Tags: upsert,
679 });
680 }
681 if (removed.length > 0 && dbClusterArn) {
682 yield* neptune.removeTagsFromResource({
683 ResourceName: dbClusterArn,
684 TagKeys: removed,
685 });
686 }
687
688 yield* session.note(dbClusterArn || identifier);
689 return toAttrs({ cluster: observed, tags: desiredTags });
690 }),
691 delete: Effect.fn(function* ({ output }) {
692 yield* neptune
693 .deleteDBCluster({
694 DBClusterIdentifier: output.dbClusterIdentifier,
695 SkipFinalSnapshot: true,
696 })
697 .pipe(Effect.catchTag("DBClusterNotFoundFault", () => Effect.void));
698 // Block until the cluster is fully gone. Neptune deletion is async;
699 // if we return while it is still `deleting`, a dependent
700 // (DBSubnetGroup or VPC) is torn down next and AWS rejects it.
701 yield* Effect.repeat(
702 neptune
703 .describeDBClusters({
704 DBClusterIdentifier: output.dbClusterIdentifier,
705 })
706 .pipe(
707 Effect.as(true),
708 Effect.catchTag("DBClusterNotFoundFault", () =>
709 Effect.succeed(false),
710 ),
711 ),
712 {
713 schedule: Schedule.max([
714 Schedule.fixed("15 seconds"),
715 Schedule.recurs(40),
716 ]),
717 until: (exists) => exists === false,
718 },
719 ).pipe(Effect.catch(() => Effect.void));
720 }),
721 };
722 }),
723 );