From 2ab67a7277750ad6af51d40d20647c58b651110f Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Fri, 7 Aug 2026 00:56:52 +0700 Subject: [PATCH] =?UTF-8?q?feat(aws):=20storage.docdb=20=E2=80=94=20comple?= =?UTF-8?q?te=20aws-docdb=20port=20at=20v2.263.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First non-RDS database module: all 8 upstream files into storage.docdb. CaCertificate reused from ../rds (the Phase-0 decoupling); rotation rides the live-proven SecretRotation SAR mechanism (mongo apps; attach() injects engine/ssl fields the rotation Lambda requires); DocDB Serverless v2 maps as a native typed block (serverless-ness known synchronously — no addOverride needed, unlike rds). Instance naming: derived grid-scoped cluster identifier as base (instanceN) — upstream's base-defaults-to-cluster-name semantic + repo invariant both preserved (regression-tested; run 1 exposed the provider tf-* auto-name). 103 tests. Live integ docdb.cluster: DocumentDB 5.0 + db.t3.medium, mongo secret merge, drift oracle clean, PASS 1012s (run 2 re-proves with grid-named instances). --- integ/aws/storage/Makefile | 4 + integ/aws/storage/apps/docdb.cluster.ts | 76 + integ/aws/storage/docdb_cluster_test.go | 81 + src/aws/storage/docdb/cluster-ref.ts | 102 + src/aws/storage/docdb/cluster.ts | 1125 ++++++++++ src/aws/storage/docdb/database-secret.ts | 98 + src/aws/storage/docdb/endpoint.ts | 92 + src/aws/storage/docdb/index.ts | 18 + src/aws/storage/docdb/instance.ts | 355 ++++ src/aws/storage/docdb/parameter-group.ts | 143 ++ src/aws/storage/docdb/props.ts | 111 + src/aws/storage/index.ts | 3 + .../docdb/__snapshots__/instance.test.ts.snap | 309 +++ .../parameter-group.test.ts.snap | 58 + test/aws/storage/docdb/cluster.test.ts | 1852 +++++++++++++++++ test/aws/storage/docdb/endpoint.test.ts | 98 + test/aws/storage/docdb/instance.test.ts | 284 +++ .../aws/storage/docdb/parameter-group.test.ts | 122 ++ 18 files changed, 4931 insertions(+) create mode 100644 integ/aws/storage/apps/docdb.cluster.ts create mode 100644 integ/aws/storage/docdb_cluster_test.go create mode 100644 src/aws/storage/docdb/cluster-ref.ts create mode 100644 src/aws/storage/docdb/cluster.ts create mode 100644 src/aws/storage/docdb/database-secret.ts create mode 100644 src/aws/storage/docdb/endpoint.ts create mode 100644 src/aws/storage/docdb/index.ts create mode 100644 src/aws/storage/docdb/instance.ts create mode 100644 src/aws/storage/docdb/parameter-group.ts create mode 100644 src/aws/storage/docdb/props.ts create mode 100644 test/aws/storage/docdb/__snapshots__/instance.test.ts.snap create mode 100644 test/aws/storage/docdb/__snapshots__/parameter-group.test.ts.snap create mode 100644 test/aws/storage/docdb/cluster.test.ts create mode 100644 test/aws/storage/docdb/endpoint.test.ts create mode 100644 test/aws/storage/docdb/instance.test.ts create mode 100644 test/aws/storage/docdb/parameter-group.test.ts diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 85b94781..91b86caa 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -40,6 +40,10 @@ rds.proxy: ## Test DatabaseProxy L2 (live RDS Proxy fronting MySQL db.t3.micro) go test -v -count 1 -timeout 45m ./... -run ^TestRdsProxy$ .PHONY: rds.proxy +docdb.cluster: ## Test DocDB DatabaseCluster L2 (live DocumentDB cluster + instance) + go test -v -count 1 -timeout 45m ./... -run ^TestDocdbCluster$ +.PHONY: docdb.cluster + bucket-notifications: ## Test S3 Bucket with EventBridge Notifications go test -v -count 1 -timeout 15m ./... -run ^TestBucketNotifications$ .PHONY: bucket-notifications diff --git a/integ/aws/storage/apps/docdb.cluster.ts b/integ/aws/storage/apps/docdb.cluster.ts new file mode 100644 index 00000000..5990e900 --- /dev/null +++ b/integ/aws/storage/apps/docdb.cluster.ts @@ -0,0 +1,76 @@ +// Live test for the storage.docdb DatabaseCluster L2: a real DocumentDB +// cluster + one db.t3.medium instance deployed through the ported construct, +// with a generated master password attached via the attach() protocol +// (dbClusterIdentifier/engine "mongo"/ssl "true"/host/number-port merged into +// the DatabaseSecret -- the fields the MongoDB rotation Lambda requires). +// +// NOTE: the auto-generated DatabaseSecret has a deterministic name and no +// recovery-window override -- a re-run within 30 days of destroy needs +// `aws secretsmanager delete-secret --force-delete-without-recovery` first. +import { App, LocalBackend, TerraformOutput } from "cdktn"; +import { aws, Duration } from "../../../../src"; + +const environmentName = process.env.ENVIRONMENT_NAME ?? "test"; +const region = process.env.AWS_REGION ?? "us-east-1"; +const outdir = process.env.OUT_DIR ?? "cdktf.out"; +const stackName = process.env.STACK_NAME ?? "docdb.cluster"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "g11111111-1111", + environmentName, + providerConfig: { + region, + }, +}); +new LocalBackend(stack, { + path: `${stackName}.tfstate`, +}); + +const vpc = new aws.compute.Vpc(stack, "Vpc", { + maxAzs: 2, + natGateways: 0, + subnetConfiguration: [ + { + name: "isolated", + subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED, + cidrMask: 24, + }, + ], +}); + +const cluster = new aws.storage.docdb.DatabaseCluster(stack, "Cluster", { + masterUser: { + username: "docadmin", + }, + vpc, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, + instanceType: aws.compute.InstanceType.of( + aws.compute.InstanceClass.T3, + aws.compute.InstanceSize.MEDIUM, + ), + instances: 1, + backup: { + retention: Duration.days(1), + }, + // Terraform-native replacement for upstream removalPolicy: allow clean destroy. + skipFinalSnapshot: true, +}); + +new TerraformOutput(stack, "cluster_identifier", { + value: cluster.clusterIdentifier, + staticId: true, +}); +new TerraformOutput(stack, "cluster_endpoint_address", { + value: cluster.clusterEndpoint.hostname, + staticId: true, +}); +new TerraformOutput(stack, "secret_arn", { + value: cluster.secret!.secretArn, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/docdb_cluster_test.go b/integ/aws/storage/docdb_cluster_test.go new file mode 100644 index 00000000..983913c3 --- /dev/null +++ b/integ/aws/storage/docdb_cluster_test.go @@ -0,0 +1,81 @@ +package test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/gruntwork-io/terratest/modules/aws" + "github.com/gruntwork-io/terratest/modules/terraform" + test_structure "github.com/gruntwork-io/terratest/modules/test-structure" + "github.com/stretchr/testify/require" +) + +// Run the apps/docdb.cluster.ts integration test: a real DocumentDB cluster + +// db.t3.medium instance deployed through the storage.docdb DatabaseCluster L2. +// Validates cluster/instance read-back (DocDB shares the RDS API), the +// attach() protocol's merged secret (incl. the mongo/ssl fields the rotation +// Lambda requires), and the post-apply drift oracle. +func TestDocdbCluster(t *testing.T) { + runStorageIntegrationTest(t, "docdb.cluster", "us-east-1", validateDocdbCluster) +} + +func validateDocdbCluster(t *testing.T, tfWorkingDir string, awsRegion string) { + terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir) + outputs := terraform.OutputAll(t, terraformOptions) + + clusterID := outputs["cluster_identifier"].(string) + endpointAddress := outputs["cluster_endpoint_address"].(string) + secretArn := outputs["secret_arn"].(string) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion)) + require.NoError(t, err) + client := rds.NewFromConfig(cfg) + + // --- 1. Cluster read-back (DocDB answers on the RDS API). --- + dc, err := client.DescribeDBClusters(ctx, &rds.DescribeDBClustersInput{ + DBClusterIdentifier: &clusterID, + }) + require.NoError(t, err) + require.Len(t, dc.DBClusters, 1) + c := dc.DBClusters[0] + require.Equal(t, "available", *c.Status) + require.Equal(t, "docdb", *c.Engine) + require.Equal(t, endpointAddress, *c.Endpoint) + require.True(t, *c.StorageEncrypted, "storage encryption defaults to true") + t.Logf("docdb-cluster: %s available (engine docdb %s, encrypted)", clusterID, *c.EngineVersion) + + // --- 2. The single instance is db.t3.medium. --- + require.Len(t, c.DBClusterMembers, 1) + instanceID := *c.DBClusterMembers[0].DBInstanceIdentifier + di, err := client.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ + DBInstanceIdentifier: &instanceID, + }) + require.NoError(t, err) + require.Len(t, di.DBInstances, 1) + require.Equal(t, "db.t3.medium", *di.DBInstances[0].DBInstanceClass) + t.Logf("docdb-cluster: instance %s is db.t3.medium", instanceID) + + // --- 3. Attached secret carries the merged connection fields incl. the + // mongo/ssl fields the MongoDB rotation Lambda requires (port is a JSON + // NUMBER -- CFN SecretTargetAttachment parity). --- + secretValue := aws.GetSecretValue(t, awsRegion, secretArn) + var connection map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(secretValue), &connection)) + require.Equal(t, "docadmin", connection["username"]) + require.NotEmpty(t, connection["password"]) + require.Equal(t, "mongo", connection["engine"]) + require.Equal(t, "true", connection["ssl"]) + require.Equal(t, endpointAddress, connection["host"]) + require.Equal(t, float64(*c.Port), connection["port"]) + require.Equal(t, clusterID, connection["dbClusterIdentifier"]) + t.Logf("docdb-cluster: attached secret carries mongo connection details incl. dbClusterIdentifier=%s", clusterID) + + // --- Drift oracle: re-planning the already-applied stack must show zero changes. --- + planExitCode := terraform.PlanExitCode(t, terraformOptions) + require.Equal(t, terraform.DefaultSuccessExitCode, planExitCode, + "expected `tofu plan -detailed-exitcode` to report no drift after apply (got exit code %d)", planExitCode) +} diff --git a/src/aws/storage/docdb/cluster-ref.ts b/src/aws/storage/docdb/cluster-ref.ts new file mode 100644 index 00000000..970b60c5 --- /dev/null +++ b/src/aws/storage/docdb/cluster-ref.ts @@ -0,0 +1,102 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/cluster-ref.ts + +import type { Endpoint } from "./endpoint"; +import { IAwsConstruct } from "../../aws-construct"; +import * as ec2 from "../../compute"; +import * as secretsmanager from "../../encryption"; + +/** + * Create a clustered database with a given number of instances. + * + * TODO: omitted — upstream also extends `aws_docdb.IDBClusterRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (identical omission to `dbClusterRef` on `IDatabaseCluster` + * in `../rds/cluster-ref.ts` — see the TODO there), so `dbClusterRef` is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/cluster-ref.ts#L10 + */ +export interface IDatabaseCluster + extends IAwsConstruct, + ec2.IConnectable, + secretsmanager.ISecretAttachmentTarget { + /** + * Identifier of the cluster + */ + readonly clusterIdentifier: string; + + /** + * Identifiers of the replicas + */ + readonly instanceIdentifiers: string[]; + + /** + * The endpoint to use for read/write operations + */ + readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + readonly clusterReadEndpoint: Endpoint; + + /** + * Endpoints which address each individual replica. + */ + readonly instanceEndpoints: Endpoint[]; + + /** + * The security group for this database cluster + */ + readonly securityGroupId: string; +} + +/** + * Properties that describe an existing cluster instance + */ +export interface DatabaseClusterAttributes { + /** + * The database port + * + * @default - none + */ + readonly port?: number; + + /** + * The security group of the database cluster + * + * @default - no security groups + */ + readonly securityGroup?: ec2.ISecurityGroup; + + /** + * Identifier for the cluster + */ + readonly clusterIdentifier: string; + + /** + * Identifier for the instances + * + * @default - no instance identifiers + */ + readonly instanceIdentifiers?: string[]; + + /** + * Cluster endpoint address + * + * @default - no cluster endpoint address + */ + readonly clusterEndpointAddress?: string; + + /** + * Reader endpoint address + * + * @default - no reader endpoint address + */ + readonly readerEndpointAddress?: string; + + /** + * Endpoint addresses of individual instances + * + * @default - no instance endpoint addresses + */ + readonly instanceEndpointAddresses?: string[]; +} diff --git a/src/aws/storage/docdb/cluster.ts b/src/aws/storage/docdb/cluster.ts new file mode 100644 index 00000000..2a233326 --- /dev/null +++ b/src/aws/storage/docdb/cluster.ts @@ -0,0 +1,1125 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/cluster.ts + +import { + docdbCluster, + docdbClusterInstance, + docdbSubnetGroup, +} from "@cdktn/provider-aws"; +import { Annotations, Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import type { + DatabaseClusterAttributes, + IDatabaseCluster, +} from "./cluster-ref"; +import { DatabaseSecret } from "./database-secret"; +import { Endpoint } from "./endpoint"; +import type { IClusterParameterGroup } from "./parameter-group"; +import type { BackupProps, Login, RotationMultiUserOptions } from "./props"; +import type { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { AwsConstructBase, AwsConstructProps } from "../../aws-construct"; +import * as ec2 from "../../compute"; +import * as secretsmanager from "../../encryption"; +import type * as encryption from "../../encryption"; +import { CaCertificate } from "../rds"; + +const MIN_ENGINE_VERSION_FOR_IO_OPTIMIZED_STORAGE = 5; +const MIN_ENGINE_VERSION_FOR_SERVERLESS = 5; + +/** + * Matches the DocumentDB maintenance window format `ddd:hh24:mi-ddd:hh24:mi` + * Days: mon | tue | wed | thu | fri | sat | sun (case-insensitive). + * Time: 00-23 hour, 00-59 minute. + * + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/db-instance-maintain.html#maintenance-window + */ +const MAINTENANCE_WINDOW_REGEX = + /^(mon|tue|wed|thu|fri|sat|sun):(2[0-3]|[01]\d):[0-5]\d-(mon|tue|wed|thu|fri|sat|sun):(2[0-3]|[01]\d):[0-5]\d$/i; + +/** + * Matches a `x.y.z` DocumentDB engine version. + */ +const VALID_ENGINE_VERSION_REGEX = + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + +/** + * ServerlessV2 scaling configuration for DocumentDB clusters + */ +export interface ServerlessV2ScalingConfiguration { + /** + * The minimum number of DocumentDB capacity units (DCUs) for a DocumentDB instance in a DocumentDB Serverless cluster. + */ + readonly minCapacity: number; + + /** + * The maximum number of DocumentDB capacity units (DCUs) for a DocumentDB instance in a DocumentDB Serverless cluster. + */ + readonly maxCapacity: number; +} + +/** + * The storage type of the DocDB cluster + */ +export enum StorageType { + /** + * Standard storage + */ + STANDARD = "standard", + + /** + * I/O-optimized storage + */ + IOPT1 = "iopt1", +} + +/** + * Properties for a new database cluster + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `DatabaseClusterProps` does not — matching the base-idiom used throughout this + * repo (e.g. `DatabaseClusterProps` in `../rds/cluster.ts`) for cross-account/-region construct + * placement. + */ +export interface DatabaseClusterProps extends AwsConstructProps { + /** + * What version of the database to start + * + * @default - the latest major version + */ + readonly engineVersion?: string; + + /** + * The port the DocumentDB cluster will listen on + * + * @default DatabaseCluster.DEFAULT_PORT + */ + readonly port?: number; + + /** + * Username and password for the administrative user + */ + readonly masterUser: Login; + + // NOTE: `aws_docdb_cluster` also exposes `manage_master_user_password` / + // `master_user_secret_kms_key_id` (AWS-managed master passwords). The rds sibling + // (`../rds/cluster.ts`) surfaces these as a TERRACONSTRUCTS-native extension; that extension + // is deliberately NOT carried over to docdb in this slice (upstream aws-docdb has no such + // prop, and the Login-based path is the upstream-faithful surface). Follow-up candidate if + // DocDB users ask for RDS-managed rotation. + + /** + * Backup settings + * + * @default - Backup retention period for automated backups is 1 day. + * Backup preferred window is set to a 30-minute window selected at random from an + * 8-hour block of time for each AWS Region, occurring on a random day of the week. + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/backup-restore.db-cluster-snapshots.html#backup-restore.backup-window + */ + readonly backup?: BackupProps; + + /** + * The KMS key for storage encryption. + * + * @default - default master key. + */ + readonly kmsKey?: encryption.IKey; + + /** + * Whether to enable storage encryption + * + * @default true + */ + readonly storageEncrypted?: boolean; + + /** + * An optional identifier for the cluster + * + * If you specify a name, it is lowercased at synth: DocumentDB stores DB + * identifiers lowercase server-side, and emitting the original casing would + * report a perpetual Terraform diff. + * + * @default - a gridUUID-scoped generated name + */ + readonly dbClusterName?: string; + + /** + * Base identifier for instances + * + * Every replica is named by appending the replica number to this string, 1-based. + * Only applicable for provisioned clusters. + * + * If you specify a base, it is lowercased at synth (DocumentDB stores DB + * identifiers lowercase server-side; see `dbClusterName`). + * + * @default - `dbClusterName` is used with the word "Instance" appended. If `dbClusterName` is not provided, the + * identifier is automatically generated. + */ + readonly instanceIdentifierBase?: string; + + /** + * What type of instance to start for the replicas. + * Required for provisioned clusters, not applicable for serverless clusters. + * + * @default None + */ + readonly instanceType?: ec2.InstanceType; + + /** + * Number of DocDB compute instances + * @default 1 + */ + readonly instances?: number; + + /** + * ServerlessV2 scaling configuration. + * When specified, the cluster will be created as a serverless cluster. + * + * @default None + */ + readonly serverlessV2ScalingConfiguration?: ServerlessV2ScalingConfiguration; + + /** + * The identifier of the CA certificate used for the instances. + * + * Specifying or updating this property triggers a reboot. + * + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/ca_cert_rotation.html + * + * @default - DocumentDB will choose a certificate authority + */ + readonly caCertificate?: CaCertificate; + + /** + * What subnets to run the DocumentDB instances in. + * + * Must be at least 2 subnets in two different AZs. + */ + readonly vpc: ec2.IVpc; + + /** + * Where to place the instances within the VPC + * + * @default private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * Security group. + * + * @default a new security group is created. + */ + readonly securityGroup?: ec2.ISecurityGroup; + + /** + * The DB parameter group to associate with the instance. + * + * TERRACONSTRUCTS DEVIATION: `IClusterParameterGroup` instead of upstream's + * `aws_docdb.IDBClusterParameterGroupRef` — see the identical omission on + * `IClusterParameterGroup.dbClusterParameterGroupRef` in `./parameter-group.ts`. + * + * @default no parameter group + */ + readonly parameterGroup?: IClusterParameterGroup; + + /** + * A weekly time range in which maintenance should preferably execute. + * + * Must be at least 30 minutes long. + * + * Example: 'tue:04:17-tue:04:47' + * + * @default - 30-minute window selected at random from an 8-hour block of time for + * each AWS Region, occurring on a random day of the week. + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/db-instance-maintain.html#maintenance-window + */ + readonly preferredMaintenanceWindow?: string; + + /** + * The weekly time range during which system maintenance can occur on the cluster's instances. + * + * The cluster-level `preferredMaintenanceWindow` applies to cluster-wide maintenance events; this prop + * applies to each auto-created instance independently. To use the same window for both, set this prop + * to the same value as `preferredMaintenanceWindow`. + * + * Format: `ddd:hh24:mi-ddd:hh24:mi`. Must be at least 30 minutes long. + * Example: 'sat:09:00-sat:09:30' + * + * Only applicable to provisioned clusters; has no effect on serverless clusters because they do not + * create instances. + * + * @default - a 30-minute window selected at random from an 8-hour block of time for each AWS Region, + * occurring on a random day of the week. + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/db-instance-maintain.html#maintenance-window + */ + readonly instanceMaintenanceWindow?: string; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`), + // `instanceRemovalPolicy?: RemovalPolicy` and `securityGroupRemovalPolicy?: RemovalPolicy` are + // CloudFormation's DeletionPolicy concept applied independently to the cluster, its + // auto-created instances, and its auto-created security group. `core.RemovalPolicy` is not + // ported anywhere in this repo (see the identical omission on `DatabaseClusterBaseProps` in + // `../rds/cluster.ts`). Terraform's `aws_docdb_cluster` exposes the cluster-level equivalent + // natively via `skipFinalSnapshot`/`finalSnapshotIdentifier` below -- the TERRACONSTRUCTS-native + // replacement, mirroring `../rds/cluster.ts` exactly. Neither `aws_docdb_cluster_instance` nor + // the auto-created `aws_security_group` exposes an equivalent argument at all (verified against + // the full config shapes in `node_modules/@cdktn/provider-aws/lib/docdb-cluster-instance/index.d.ts` + // and `.../security-group/index.d.ts`), so `instanceRemovalPolicy`/`securityGroupRemovalPolicy` + // have no native replacement and are dropped entirely -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/cluster.ts#L220-L233 + // readonly removalPolicy?: RemovalPolicy; + // readonly instanceRemovalPolicy?: RemovalPolicy; + // readonly securityGroupRemovalPolicy?: RemovalPolicy; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — native Terraform replacement for upstream's + * `removalPolicy` (see the TODO above). Whether Terraform should take a final DB snapshot before + * destroying this cluster. When `false` (the default) and `finalSnapshotIdentifier` is not set, + * `terraform destroy`/replace will FAIL at apply-time with an AWS API error (native + * `aws_docdb_cluster` behavior, not enforced here at synth time). Mirrors + * `DatabaseClusterProps.skipFinalSnapshot` in `../rds/cluster.ts`. + * + * @default false (a final snapshot is taken on delete/replace, so `finalSnapshotIdentifier` should + * also be set) + */ + readonly skipFinalSnapshot?: boolean; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — see `skipFinalSnapshot` above. The identifier + * for the final DB cluster snapshot Terraform takes before destroying this cluster. Unlike + * CloudFormation (which auto-generates a snapshot name), Terraform requires this to be supplied + * explicitly. Mirrors `DatabaseClusterProps.finalSnapshotIdentifier` in `../rds/cluster.ts`. + * + * @default - no final snapshot identifier; required unless `skipFinalSnapshot` is `true` + */ + readonly finalSnapshotIdentifier?: string; + + /** + * Specifies whether this cluster can be deleted. If deletionProtection is + * enabled, the cluster cannot be deleted unless it is modified and + * deletionProtection is disabled. deletionProtection protects clusters from + * being accidentally deleted. + * + * @default - false + */ + readonly deletionProtection?: boolean; + + /** + * Whether the profiler logs should be exported to CloudWatch. + * Note that you also have to configure the profiler log export in the Cluster's Parameter Group. + * + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/profiling.html#profiling.enable-profiling + * @default false + */ + readonly exportProfilerLogsToCloudWatch?: boolean; + + /** + * Whether the audit logs should be exported to CloudWatch. + * Note that you also have to configure the audit log export in the Cluster's Parameter Group. + * + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/event-auditing.html#event-auditing-enabling-auditing + * @default false + */ + readonly exportAuditLogsToCloudWatch?: boolean; + + // TODO: omitted — `cloudWatchLogsRetention`/`cloudWatchLogsRetentionRole` (and the LogRetention + // custom-resource machinery that consumes them) are dropped for the identical reason given on + // `DatabaseClusterBaseProps.cloudwatchLogsRetention` in `../rds/cluster.ts` — there is no + // Terraform-native equivalent of upstream's Lambda-backed `logs.LogRetention` custom resource; + // the `aws_docdb_cluster` resource only controls WHICH logs are exported + // (`enabled_cloudwatch_logs_exports`, ported below via `exportProfilerLogsToCloudWatch`/ + // `exportAuditLogsToCloudWatch`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/cluster.ts#L263-L278 + // readonly cloudWatchLogsRetention?: logs.RetentionDays; + // readonly cloudWatchLogsRetentionRole?: iam.IRole; + + /** + * A value that indicates whether to enable Performance Insights for the instances in the DB Cluster. + * + * @default - false + */ + readonly enablePerformanceInsights?: boolean; + + // TODO: omitted — upstream's `copyTagsToSnapshot?: boolean` maps to `CfnDBCluster.copyTagsToSnapshot`. + // The Terraform `aws_docdb_cluster` resource has NO `copy_tags_to_snapshot` argument at all + // (verified against the full config shape in + // `node_modules/@cdktn/provider-aws/lib/docdb-cluster/index.d.ts`). Note: + // `aws_docdb_cluster_instance.copy_tags_to_snapshot` DOES exist, but is deliberately not + // repurposed here — upstream's prop is a cluster-level CfnDBCluster property, and mapping it + // onto the auto-created instances would silently change its semantics — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/cluster.ts#L309-L314 + // readonly copyTagsToSnapshot?: boolean; + + /** + * The storage type of the DocDB cluster. + * + * I/O-optimized storage is supported starting with engine version 5.0.0. + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/db-cluster-storage-configs.html + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/release-notes.html#release-notes.11-21-2023 + * + * @default StorageType.STANDARD + */ + readonly storageType?: StorageType; +} + +/** + * A new or imported clustered database. + */ +abstract class DatabaseClusterBase + extends AwsConstructBase + implements IDatabaseCluster +{ + /** + * Identifier of the cluster + */ + public abstract readonly clusterIdentifier: string; + /** + * Identifiers of the replicas + */ + public abstract readonly instanceIdentifiers: string[]; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + public abstract readonly clusterReadEndpoint: Endpoint; + + /** + * Endpoints which address each individual replica. + */ + public abstract readonly instanceEndpoints: Endpoint[]; + + /** + * Access to the network connections + */ + public abstract readonly connections: ec2.Connections; + + /** + * Security group identifier of this database + */ + public abstract readonly securityGroupId: string; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. `clusterEndpoint`/`clusterReadEndpoint` are + * abstract getters that THROW on an `ImportedDatabaseCluster` built via + * `fromDatabaseClusterAttributes()` without the corresponding endpoint address (see + * `ImportedDatabaseCluster` below). `asSecretAttachmentTarget()` and `outputs` (both below) need + * to tolerate that -- host/port and the endpoint outputs are simply omitted for a minimally + * imported cluster rather than throwing. Concrete (non-imported) subclasses never throw here, so + * the default implementation is a plain passthrough; `ImportedDatabaseCluster` overrides both to + * return `undefined` instead of throwing when the corresponding attribute wasn't supplied. Mirrors + * `DatabaseClusterBase.tryGetClusterEndpoint()` in `../rds/cluster.ts`. + */ + protected tryGetClusterEndpoint(): Endpoint | undefined { + return this.clusterEndpoint; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — see `tryGetClusterEndpoint()` above. + */ + protected tryGetClusterReadEndpoint(): Endpoint | undefined { + return this.clusterReadEndpoint; + } + + /** + * Renders the secret attachment target specifications. + * + * TERRACONSTRUCTS DEVIATION: mirrors the identical deviation note on + * `DatabaseClusterBase.asSecretAttachmentTarget()` in `../rds/cluster.ts` — upstream returns + * only `{ targetId, targetType }` because CloudFormation's + * `AWS::SecretsManager::SecretTargetAttachment` resolves engine/host/port server-side from those + * fields. The Terraform AWS provider has no such server-side merge, so `connectionFields` is + * supplied here too, using `dbClusterIdentifier` (not `dbInstanceIdentifier`) as CFN's + * `SecretTargetAttachment` does for DocDB cluster targets. + * + * TERRACONSTRUCTS DEVIATION: `engine: "mongo"` and `ssl: "true"` are also injected, neither of + * which CFN's `SecretTargetAttachment` needs to set explicitly (it resolves `engine` from the + * target's resource type, and DocumentDB's Secrets Manager rotation templates default `ssl` to + * `false` if absent). The Secrets Manager MongoDB rotation Lambda requires `engine` to be + * literally `"mongo"` in the secret JSON (see the docblock on `RotationMultiUserOptions.secret` + * in `./props.ts`), and DocumentDB clusters have TLS enabled by default (see + * https://docs.aws.amazon.com/documentdb/latest/developerguide/security.encryption.ssl.html), so + * the rotation Lambda must connect over TLS -- `ssl: "true"` mirrors that default. + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + const endpoint = this.tryGetClusterEndpoint(); + return { + targetId: this.clusterIdentifier, + targetType: secretsmanager.AttachmentTargetType.DOCDB_DB_CLUSTER, + connectionFields: { + dbClusterIdentifier: this.clusterIdentifier, + engine: "mongo", + ssl: "true", + ...(endpoint + ? { + host: endpoint.hostname, + // NUMBER-typed port token, stringified for embedding in the connection fields map. + port: Tokenization.stringifyNumber(endpoint.port), + } + : {}), + }, + }; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `../rds/cluster.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + const endpoint = this.tryGetClusterEndpoint(); + const readEndpoint = this.tryGetClusterReadEndpoint(); + return { + identifier: this.clusterIdentifier, + ...(endpoint && { + endpointAddress: endpoint.hostname, + endpointPort: Tokenization.stringifyNumber(endpoint.port), + }), + ...(readEndpoint && { readEndpointAddress: readEndpoint.hostname }), + }; + } +} + +/** + * Create a clustered database with a given number of instances. + * + * @resource aws_docdb_cluster + */ +export class DatabaseCluster extends DatabaseClusterBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.docdb.DatabaseCluster"; + + /** + * The default number of instances in the DocDB cluster if none are + * specified + */ + public static readonly DEFAULT_NUM_INSTANCES = 1; + + /** + * The default port Document DB listens on + */ + public static readonly DEFAULT_PORT = 27017; + + /** + * Import an existing DatabaseCluster from properties + */ + public static fromDatabaseClusterAttributes( + scope: Construct, + id: string, + attrs: DatabaseClusterAttributes, + ): IDatabaseCluster { + return new ImportedDatabaseCluster(scope, id, attrs); + } + + /** + * The single user secret rotation application. + * + * CROSS-LANE FIX (Lane B, unblocking `docdb` barrel import): was a `private static readonly` + * field eagerly evaluating `secretsmanager.SecretRotationApplication.MONGODB_ROTATION_SINGLE_USER` + * at class-definition (module-load) time. Once `storage/index.ts` re-exports `docdb` (which + * re-exports this file), that eager read lands mid-way through `encryption/secret-rotation.ts`'s + * own top-level evaluation (via a `encryption -> compute -> storage -> docdb -> encryption` + * circular require), so `SecretRotationApplication` is still `undefined` at that point -- + * `TypeError: Cannot read properties of undefined (reading 'MONGODB_ROTATION_SINGLE_USER')` at + * import time. Converted to a lazily-evaluated static getter (same access syntax at both call + * sites below, `DatabaseCluster.SINGLE_USER_ROTATION_APPLICATION`) so the read only happens once + * `addRotationSingleUser()` actually runs, by which point the module graph has fully loaded -- + * matches the (already-lazy, instance-level) precedent in `../rds/cluster.ts`'s + * `singleUserRotationApplication`/`multiUserRotationApplication`. + */ + private static get SINGLE_USER_ROTATION_APPLICATION() { + return secretsmanager.SecretRotationApplication + .MONGODB_ROTATION_SINGLE_USER; + } + + /** + * The multi user secret rotation application. + * + * CROSS-LANE FIX: see `SINGLE_USER_ROTATION_APPLICATION` above. + */ + private static get MULTI_USER_ROTATION_APPLICATION() { + return secretsmanager.SecretRotationApplication.MONGODB_ROTATION_MULTI_USER; + } + + /** + * Identifier of the cluster + */ + public readonly clusterIdentifier: string; + + /** + * The endpoint to use for read/write operations + */ + public readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + public readonly clusterReadEndpoint: Endpoint; + + /** + * The resource id for the cluster; for example: cluster-ABCD1234EFGH5678IJKL90MNOP. The cluster ID uniquely + * identifies the cluster and is used in things like IAM authentication policies. + */ + public readonly clusterResourceIdentifier: string; + + /** + * The connections object to implement IConnectable + */ + public readonly connections: ec2.Connections; + + /** + * Identifiers of the replicas + */ + public readonly instanceIdentifiers: string[] = []; + + /** + * Endpoints which address each individual replica. + */ + public readonly instanceEndpoints: Endpoint[] = []; + + /** + * Security group identifier of this database + */ + public readonly securityGroupId: string; + + /** + * The secret attached to this cluster + */ + public readonly secret?: secretsmanager.ISecret; + + /** + * The underlying `aws_docdb_cluster` L1. NOTE: this construct owns `lifecycle.ignore_changes` on + * it (see the `ignore_changes`/password-drift note in the constructor) -- code calling + * `resource.addOverride("lifecycle.ignore_changes", ...)` directly will REPLACE that list rather + * than merge with it. + */ + public readonly resource: docdbCluster.DocdbCluster; + + /** + * The VPC where the DB subnet group is created. + */ + private readonly vpc: ec2.IVpc; + + /** + * The subnets used by the DB subnet group. + */ + private readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — see `addSecurityGroups()` below. Tracks the + * security group ids owned by this construct so `addSecurityGroups()` can extend the underlying + * `aws_docdb_cluster.vpc_security_group_ids` INPUT list. The Terraform L1's + * `vpcSecurityGroupIds` GETTER returns a synth-time-resolved attribute reference + * (`Fn.tolist(getListAttribute("vpc_security_group_ids"))`), not the raw input array -- unlike + * upstream's `CfnDBCluster.vpcSecurityGroupIds` (a plain mutable CFN L1 property), pushing onto + * that resolved reference would silently no-op rather than mutating the resource's actual + * argument, so the ids are tracked locally and re-assigned via the setter instead. + */ + private readonly securityGroupIds: string[]; + + constructor(scope: Construct, id: string, props: DatabaseClusterProps) { + super(scope, id, props); + + // Validate exactly one of instanceType or serverlessV2ScalingConfiguration is provided + if (!props.instanceType && !props.serverlessV2ScalingConfiguration) { + throw new ValidationError( + "Either instanceType (for provisioned clusters) or serverlessV2ScalingConfiguration (for serverless clusters) must be specified", + this, + ); + } + + if ( + props.preferredMaintenanceWindow !== undefined && + !Token.isUnresolved(props.preferredMaintenanceWindow) && + !MAINTENANCE_WINDOW_REGEX.test(props.preferredMaintenanceWindow) + ) { + throw new ValidationError( + "preferredMaintenanceWindow must be in the format ddd:hh24:mi-ddd:hh24:mi, e.g. sat:09:00-sat:09:30", + this, + ); + } + + if ( + props.instanceMaintenanceWindow !== undefined && + !Token.isUnresolved(props.instanceMaintenanceWindow) && + !MAINTENANCE_WINDOW_REGEX.test(props.instanceMaintenanceWindow) + ) { + throw new ValidationError( + "instanceMaintenanceWindow must be in the format ddd:hh24:mi-ddd:hh24:mi, e.g. sat:09:00-sat:09:30", + this, + ); + } + + const isServerless = !!props.serverlessV2ScalingConfiguration; + if (isServerless && props.instanceType) { + throw new ValidationError( + "Cannot specify both instanceType and serverlessV2ScalingConfiguration", + this, + ); + } + + this.vpc = props.vpc; + this.vpcSubnets = props.vpcSubnets; + + // Determine the subnet(s) to deploy the DocDB cluster to + const { subnetIds, internetConnectivityEstablished } = + this.vpc.selectSubnets(this.vpcSubnets); + + // DocDB clusters require a subnet group with subnets from at least two AZs. + // We cannot test whether the subnets are in different AZs, but at least we can test the amount. + // See https://docs.aws.amazon.com/documentdb/latest/developerguide/replication.html#replication.high-availability + if (subnetIds.length < 2) { + throw new ValidationError( + `Cluster requires at least 2 subnets, got ${subnetIds.length}`, + this, + ); + } + + const subnetGroup = new docdbSubnetGroup.DocdbSubnetGroup(this, "Subnets", { + description: `Subnets for ${id} database`, + subnetIds, + }); + // TERRACONSTRUCTS DEVIATION: when unnamed, upstream lets CloudFormation generate a name + // from the logical id; the repo invariant is a gridUUID-scoped `uniqueResourceName` default + // instead (mirroring `SubnetGroup` in `../rds/subnet-group.ts`), lowercased to match the + // DocumentDB/RDS-family server-side storage convention. Scoped to the subnet group's OWN + // construct path (`subnetGroup`, not `this`/the `DatabaseCluster`) so it does not collide with + // the cluster's own `uniqueResourceName(this, ...)`-derived `clusterIdentifier` below -- both + // would otherwise resolve to the identical string, since `uniqueResourceName` hashes the + // construct's node path and `this` is the same construct in both calls. + subnetGroup.name = this.stack.uniqueResourceName(subnetGroup).toLowerCase(); + + // Create the security group for the DB cluster + let securityGroup: ec2.ISecurityGroup; + if (props.securityGroup) { + securityGroup = props.securityGroup; + } else { + securityGroup = new ec2.SecurityGroup(this, "SecurityGroup", { + description: "DocumentDB security group", + vpc: this.vpc, + }); + // TERRACONSTRUCTS DEVIATION: upstream applies a consistent removal policy to the + // auto-created security group via an escape-hatch (`securityGroupRemovalPolicy`). `core.RemovalPolicy` + // is not ported in this repo -- see the TODO on `DatabaseClusterProps.securityGroupRemovalPolicy` above. + } + this.securityGroupId = securityGroup.securityGroupId; + this.securityGroupIds = [this.securityGroupId]; + + // Create the CloudwatchLogsConfiguration + const enableCloudwatchLogsExports: string[] = []; + if (props.exportAuditLogsToCloudWatch) { + enableCloudwatchLogsExports.push("audit"); + } + if (props.exportProfilerLogsToCloudWatch) { + enableCloudwatchLogsExports.push("profiler"); + } + + // Create the secret manager secret if no password is specified + let secret: DatabaseSecret | undefined; + if (!props.masterUser.password) { + secret = new DatabaseSecret(this, "Secret", { + username: props.masterUser.username, + encryptionKey: props.masterUser.kmsKey, + excludeCharacters: props.masterUser.excludeCharacters, + secretName: props.masterUser.secretName, + }); + } + + // Default to encrypted storage + const storageEncrypted = props.storageEncrypted ?? true; + + if (props.kmsKey && !storageEncrypted) { + throw new ValidationError( + "KMS key supplied but storageEncrypted is false", + this, + ); + } + + if ( + props.engineVersion !== undefined && + !VALID_ENGINE_VERSION_REGEX.test(props.engineVersion) + ) { + throw new ValidationError( + `Invalid engine version: '${props.engineVersion}'. Engine version must be in the format x.y.z`, + this, + ); + } + + if ( + props.storageType === StorageType.IOPT1 && + props.engineVersion !== undefined && + Number(props.engineVersion.split(".")[0]) < + MIN_ENGINE_VERSION_FOR_IO_OPTIMIZED_STORAGE + ) { + throw new ValidationError( + `I/O-optimized storage is supported starting with engine version 5.0.0, got '${props.engineVersion}'`, + this, + ); + } + + // Validate engine version for serverless clusters: https://docs.aws.amazon.com/documentdb/latest/developerguide/docdb-serverless-limitations.html + if ( + isServerless && + props.engineVersion !== undefined && + Number(props.engineVersion.split(".")[0]) < + MIN_ENGINE_VERSION_FOR_SERVERLESS + ) { + throw new ValidationError( + `DocumentDB serverless requires engine version 5.0.0 or higher, got '${props.engineVersion}'`, + this, + ); + } + + // Create the DocDB cluster + // TERRACONSTRUCTS DEVIATION: hoisted so the auto-created instances below can reuse the + // derived (gridUUID-scoped, lowercased) cluster identifier as their name base -- see the + // instance-identifier note in the creation loop. + const derivedClusterIdentifier = Token.isUnresolved(props.dbClusterName) + ? props.dbClusterName + : ( + props.dbClusterName ?? + this.stack.uniqueResourceName(this, { maxLength: 55 }) + ).toLowerCase(); + + const cluster = new docdbCluster.DocdbCluster(this, "Resource", { + // Basic + engineVersion: props.engineVersion, + // TERRACONSTRUCTS DEVIATION: when unnamed, upstream lets CloudFormation generate a name + // from the logical id; the repo invariant is a gridUUID-scoped `uniqueResourceName` default + // instead (mirroring `DatabaseCluster` in `../rds/cluster.ts`), lowercased to match the + // DocumentDB/RDS-family server-side storage convention. `ClusterIdentifier` is capped at 63 + // characters, so `maxLength` is passed explicitly here. + clusterIdentifier: derivedClusterIdentifier, + dbSubnetGroupName: subnetGroup.name, + port: props.port, + vpcSecurityGroupIds: this.securityGroupIds, + dbClusterParameterGroupName: props.parameterGroup?.parameterGroupName, + deletionProtection: props.deletionProtection, + // Admin + masterUsername: props.masterUser.username, + masterPassword: secret + ? secret._generatedPassword + : props.masterUser.password, + // Backup + backupRetentionPeriod: props.backup?.retention?.toDays(), + preferredBackupWindow: props.backup?.preferredWindow, + preferredMaintenanceWindow: props.preferredMaintenanceWindow, + // EnableCloudwatchLogsExports + enabledCloudwatchLogsExports: + enableCloudwatchLogsExports.length > 0 + ? enableCloudwatchLogsExports + : undefined, + // Encryption + kmsKeyId: props.kmsKey?.keyArn, + storageEncrypted, + storageType: props.storageType, + // Serverless configuration + serverlessV2ScalingConfiguration: props.serverlessV2ScalingConfiguration, + skipFinalSnapshot: props.skipFinalSnapshot, + finalSnapshotIdentifier: props.finalSnapshotIdentifier, + } as docdbCluster.DocdbClusterConfig); + + this.resource = cluster; + this.clusterIdentifier = cluster.clusterIdentifier; + this.clusterResourceIdentifier = cluster.clusterResourceId; + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `ignore_changes` note on `DatabaseCluster` + // in `../rds/cluster.ts` -- `secret` is only set here when a new `DatabaseSecret` was just + // generated for us, in which case `masterPassword` above is the SAME regenerating-on-every-plan + // `aws_secretsmanager_random_password` token stored in that secret. Without `ignore_changes`, + // every apply after the first would drift and REPLACE the live master password. + const ignoreChanges: string[] = []; + if (secret) { + ignoreChanges.push("master_password"); + } + if (ignoreChanges.length > 0) { + cluster.addOverride("lifecycle.ignore_changes", ignoreChanges); + } + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `skipFinalSnapshot`/`finalSnapshotIdentifier` + // synth-time warning on `DatabaseCluster` in `../rds/cluster.ts` — see that note for the full + // rationale. + if (props.skipFinalSnapshot !== true && !props.finalSnapshotIdentifier) { + Annotations.of(this).addWarning( + "Neither `skipFinalSnapshot` nor `finalSnapshotIdentifier` is set: `terraform destroy` (or any change that replaces this cluster) will FAIL at apply time because the AWS provider requires `finalSnapshotIdentifier` when `skipFinalSnapshot` is not `true`. Set `skipFinalSnapshot: true` to skip the final snapshot, or set `finalSnapshotIdentifier` to a snapshot name.", + ); + } + + // NOTE: must be set before `secret.attach(this)` below -- `attach()` calls + // `asSecretAttachmentTarget()` synchronously, which reads `this.clusterEndpoint`. + this.clusterEndpoint = new Endpoint(cluster.endpoint, cluster.port); + this.clusterReadEndpoint = new Endpoint( + cluster.readerEndpoint, + cluster.port, + ); + + this.connections = new ec2.Connections({ + securityGroups: [securityGroup], + defaultPort: ec2.Port.tcp(this.clusterEndpoint.port), + }); + + if (secret) { + this.secret = secret.attach(this); + } + + // Create instances only for provisioned clusters + if (!isServerless) { + const instanceCount = + props.instances ?? DatabaseCluster.DEFAULT_NUM_INSTANCES; + if (instanceCount < 1) { + throw new ValidationError( + "At least one instance is required for provisioned clusters", + this, + ); + } + + const caCertificateIdentifier = props.caCertificate + ? props.caCertificate.toString() + : undefined; + + for (let i = 0; i < instanceCount; i++) { + const instanceIndex = i + 1; + + // TERRACONSTRUCTS DEVIATION: upstream lets CloudFormation auto-generate a name from each + // instance's per-index logical id (`Instance1`, `Instance2`, ...) when neither + // `instanceIdentifierBase` nor `dbClusterName` is provided. A single + // `uniqueResourceName(this, ...)` call would collide across loop iterations (shared + // scope), so the repo naming invariant is preserved by reusing the DERIVED cluster + // identifier (already gridUUID-scoped + lowercased above, capped at 55 chars to leave + // room for the `instanceN` suffix) as the per-instance base -- upstream's own fallback + // semantic (`dbClusterName` + "instance" + N), just with the derived default instead of + // the raw prop. + const instanceIdentifierBase = + props.instanceIdentifierBase != null + ? `${props.instanceIdentifierBase}${instanceIndex}` + : `${derivedClusterIdentifier}instance${instanceIndex}`; + const instanceIdentifier = + instanceIdentifierBase === undefined + ? undefined + : Token.isUnresolved(instanceIdentifierBase) + ? instanceIdentifierBase + : instanceIdentifierBase.toLowerCase(); + + const instance = new docdbClusterInstance.DocdbClusterInstance( + this, + `Instance${instanceIndex}`, + { + // Link to cluster + clusterIdentifier: this.clusterIdentifier, + identifier: instanceIdentifier, + // Instance properties + instanceClass: databaseInstanceType(props.instanceType!), + enablePerformanceInsights: props.enablePerformanceInsights, + caCertIdentifier: caCertificateIdentifier, + preferredMaintenanceWindow: props.instanceMaintenanceWindow, + } as docdbClusterInstance.DocdbClusterInstanceConfig, + ); + + // We must have a dependency on the NAT gateway provider here to create + // things in the right order. + instance.node.addDependency(internetConnectivityEstablished); + + this.instanceIdentifiers.push(instance.identifier); + this.instanceEndpoints.push( + new Endpoint(instance.endpoint, this.clusterEndpoint.port), + ); + } + } + } + + public get outputs(): Record { + return { + ...super.outputs, + arn: this.resource.arn, + ...(this.secret && { secretArn: this.secret.secretArn }), + }; + } + + /** + * Adds the single user rotation of the master password to this cluster. + * + * @param [automaticallyAfter=Duration.days(30)] Specifies the number of days after the previous rotation + * before Secrets Manager triggers the next automatic rotation. + */ + public addRotationSingleUser( + automaticallyAfter?: Duration, + ): secretsmanager.SecretRotation { + if (!this.secret) { + throw new ValidationError( + "Cannot add single user rotation for a cluster without secret.", + this, + ); + } + + const id = "RotationSingleUser"; + const existing = this.node.tryFindChild(id); + if (existing) { + throw new ValidationError( + "A single user rotation was already added to this cluster.", + this, + ); + } + + return new secretsmanager.SecretRotation(this, id, { + secret: this.secret, + automaticallyAfter, + application: DatabaseCluster.SINGLE_USER_ROTATION_APPLICATION, + excludeCharacters: (this.node.tryFindChild("Secret") as DatabaseSecret) + ._excludedCharacters, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + target: this, + }); + } + + /** + * Adds the multi user rotation to this cluster. + */ + public addRotationMultiUser( + id: string, + options: RotationMultiUserOptions, + ): secretsmanager.SecretRotation { + if (!this.secret) { + throw new ValidationError( + "Cannot add multi user rotation for a cluster without secret.", + this, + ); + } + return new secretsmanager.SecretRotation(this, id, { + secret: options.secret, + masterSecret: this.secret, + automaticallyAfter: options.automaticallyAfter, + excludeCharacters: (this.node.tryFindChild("Secret") as DatabaseSecret) + ._excludedCharacters, + application: DatabaseCluster.MULTI_USER_ROTATION_APPLICATION, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + target: this, + }); + } + + /** + * Adds security groups to this cluster. + * @param securityGroups The security groups to add. + */ + public addSecurityGroups(...securityGroups: ec2.ISecurityGroup[]): void { + this.securityGroupIds.push( + ...securityGroups.map((sg) => sg.securityGroupId), + ); + this.resource.vpcSecurityGroupIds = [...this.securityGroupIds]; + } +} + +/** + * Turn a regular instance type into a database instance type + */ +function databaseInstanceType(instanceType: ec2.InstanceType) { + return "db." + instanceType.toString(); +} + +/** + * Represents an imported database cluster. + */ +class ImportedDatabaseCluster + extends DatabaseClusterBase + implements IDatabaseCluster +{ + public readonly clusterIdentifier: string; + public readonly connections: ec2.Connections; + + private readonly _instanceIdentifiers?: string[]; + private readonly _clusterEndpoint?: Endpoint; + private readonly _clusterReadEndpoint?: Endpoint; + private readonly _instanceEndpoints?: Endpoint[]; + private readonly _securityGroupId?: string; + + constructor(scope: Construct, id: string, attrs: DatabaseClusterAttributes) { + super(scope, id); + + const defaultPort = + typeof attrs.port !== "undefined" ? ec2.Port.tcp(attrs.port) : undefined; + this.connections = new ec2.Connections({ + securityGroups: attrs.securityGroup ? [attrs.securityGroup] : undefined, + defaultPort, + }); + this.clusterIdentifier = attrs.clusterIdentifier; + this._instanceIdentifiers = attrs.instanceIdentifiers; + this._clusterEndpoint = + attrs.clusterEndpointAddress && typeof attrs.port !== "undefined" + ? new Endpoint(attrs.clusterEndpointAddress, attrs.port) + : undefined; + this._clusterReadEndpoint = + attrs.readerEndpointAddress && typeof attrs.port !== "undefined" + ? new Endpoint(attrs.readerEndpointAddress, attrs.port) + : undefined; + this._instanceEndpoints = + attrs.instanceEndpointAddresses && typeof attrs.port !== "undefined" + ? attrs.instanceEndpointAddresses.map( + (addr) => new Endpoint(addr, attrs.port!), + ) + : undefined; + this._securityGroupId = attrs.securityGroup?.securityGroupId; + } + + public get clusterEndpoint(): Endpoint { + if (!this._clusterEndpoint) { + throw new ValidationError( + "Cannot access `clusterEndpoint` of an imported cluster without an endpoint address and port", + this, + ); + } + return this._clusterEndpoint; + } + + protected tryGetClusterEndpoint(): Endpoint | undefined { + return this._clusterEndpoint; + } + + public get clusterReadEndpoint(): Endpoint { + if (!this._clusterReadEndpoint) { + throw new ValidationError( + "Cannot access `clusterReadEndpoint` of an imported cluster without a readerEndpointAddress and port", + this, + ); + } + return this._clusterReadEndpoint; + } + + protected tryGetClusterReadEndpoint(): Endpoint | undefined { + return this._clusterReadEndpoint; + } + + public get instanceIdentifiers(): string[] { + if (!this._instanceIdentifiers) { + throw new ValidationError( + "Cannot access `instanceIdentifiers` of an imported cluster without provided instanceIdentifiers", + this, + ); + } + return this._instanceIdentifiers; + } + + public get instanceEndpoints(): Endpoint[] { + if (!this._instanceEndpoints) { + throw new ValidationError( + "Cannot access `instanceEndpoints` of an imported cluster without instanceEndpointAddresses and port", + this, + ); + } + return this._instanceEndpoints; + } + + public get securityGroupId(): string { + if (!this._securityGroupId) { + throw new ValidationError( + "Cannot access `securityGroupId` of an imported cluster without securityGroupId", + this, + ); + } + return this._securityGroupId; + } +} diff --git a/src/aws/storage/docdb/database-secret.ts b/src/aws/storage/docdb/database-secret.ts new file mode 100644 index 00000000..e1c1af31 --- /dev/null +++ b/src/aws/storage/docdb/database-secret.ts @@ -0,0 +1,98 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/database-secret.ts + +import type { Construct } from "constructs"; +import { AwsStack } from "../../aws-stack"; +import * as secretsmanager from "../../encryption"; +import type { IKey } from "../../encryption"; + +/** + * Construction properties for a DatabaseSecret. + */ +export interface DatabaseSecretProps { + /** + * The username. + */ + readonly username: string; + + /** + * The KMS key to use to encrypt the secret. + * + * @default default master key + */ + readonly encryptionKey?: IKey; + + /** + * The physical name of the secret + * + * @default Secretsmanager will generate a physical name for the secret + */ + readonly secretName?: string; + + /** + * The master secret which will be used to rotate this secret. + * + * @default - no master secret information will be included + */ + readonly masterSecret?: secretsmanager.ISecret; + + /** + * Characters to not include in the generated password. + * + * @default "\"@/" + */ + readonly excludeCharacters?: string; +} + +/** + * + * A database secret. + * + * @resource aws_secretsmanager_secret + */ +export class DatabaseSecret extends secretsmanager.Secret { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.docdb.DatabaseSecret"; + /** + * the excluded characters for this Secret + * @internal + */ + public readonly _excludedCharacters: string; + + constructor(scope: Construct, id: string, props: DatabaseSecretProps) { + const excludedCharacters = props.excludeCharacters ?? '"@/'; + + // TERRACONSTRUCTS DEVIATION: upstream interpolates `Aws.STACK_NAME` (a CloudFormation + // pseudo-parameter, `${AWS::StackName}`) into the description via `Fn::Join`. `core.Aws` is + // not ported in this repo, and `TerraformStack` (cdktn) has no equivalent runtime-resolved + // stack-name token -- `AwsStack.ofAwsConstruct(scope).node.id` (the construct ID the stack was + // instantiated with, synth-time-known) is used as a plain string instead. Mirrors the identical + // deviation on `../rds/database-secret.ts`. + const stack = AwsStack.ofAwsConstruct(scope); + + super(scope, id, { + secretName: props.secretName, + description: `Generated by the CDK for stack: ${stack.node.id}`, + encryptionKey: props.encryptionKey, + // The CloudFormation resource provider for AWS::DocDB::DBCluster currently limits the DocDB master password to + // 41 characters when pulling the password from secrets manager using a CloudFormation reference. This does not + // line up with the CloudFormation resource specification which states a maximum of 100 characters: + // + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-docdb-dbcluster.html#cfn-docdb-dbcluster-masteruserpassword + // + // When attempting to exceed 41 characters, a deployment fails with the message: + // Length of value for property {/MasterUserPassword} is greater than maximum allowed length {41} + generateSecretString: { + passwordLength: 41, + secretStringTemplate: JSON.stringify({ + username: props.username, + masterarn: props.masterSecret?.secretArn, + }), + generateStringKey: "password", + excludeCharacters: excludedCharacters, + }, + }); + + this._excludedCharacters = excludedCharacters; + } +} diff --git a/src/aws/storage/docdb/endpoint.ts b/src/aws/storage/docdb/endpoint.ts new file mode 100644 index 00000000..7efe1af6 --- /dev/null +++ b/src/aws/storage/docdb/endpoint.ts @@ -0,0 +1,92 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/endpoint.ts + +import { Token } from "cdktn"; +import { UnscopedValidationError } from "../../../errors"; + +/** + * Connection endpoint of a database cluster or instance + * + * Consists of a combination of hostname and port. + */ +export class Endpoint { + /** + * The minimum port value + */ + private static readonly MIN_PORT = 1; + + /** + * The maximum port value + */ + private static readonly MAX_PORT = 65535; + + /** + * Determines if a port is valid + * + * @param port: The port number + * @returns boolean whether the port is valid + */ + private static isValidPort(port: number): boolean { + return ( + Number.isInteger(port) && + port >= Endpoint.MIN_PORT && + port <= Endpoint.MAX_PORT + ); + } + + /** + * The hostname of the endpoint + */ + public readonly hostname: string; + + /** + * The port number of the endpoint. + * + * This can potentially be a CDK token. If you need to embed the port in a string (e.g. instance user data script), + * use `Endpoint.portAsString`. + */ + public readonly port: number; + + /** + * Constructs an Endpoint instance. + * + * @param address - The hostname or address of the endpoint + * @param port - The port number of the endpoint + */ + constructor(address: string, port: number) { + if (!Token.isUnresolved(port) && !Endpoint.isValidPort(port)) { + throw new UnscopedValidationError( + `Port must be an integer between [${Endpoint.MIN_PORT}, ${Endpoint.MAX_PORT}] but got: ${port}`, + ); + } + + this.hostname = address; + this.port = port; + } + + /** + * The combination of ``HOSTNAME:PORT`` for this endpoint. + */ + public get socketAddress(): string { + const portDesc = Token.isUnresolved(this.port) + ? Token.asString(this.port) + : this.port; + return `${this.hostname}:${portDesc}`; + } + + /** + * Returns the port number as a string representation that can be used for embedding within other strings. + * + * This is intended to deal with CDK's token system. Numeric CDK tokens are not expanded when their string + * representation is embedded in a string. This function returns the port either as an unresolved string token or + * as a resolved string representation of the port value. + * + * @returns {string} An (un)resolved string representation of the endpoint's port number + */ + public portAsString(): string { + if (Token.isUnresolved(this.port)) { + return Token.asString(this.port); + } else { + return this.port.toString(); + } + } +} diff --git a/src/aws/storage/docdb/index.ts b/src/aws/storage/docdb/index.ts new file mode 100644 index 00000000..b1e16229 --- /dev/null +++ b/src/aws/storage/docdb/index.ts @@ -0,0 +1,18 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/index.ts + +export * from "./cluster"; +export * from "./cluster-ref"; +export * from "./database-secret"; +export * from "./endpoint"; +export * from "./instance"; +export * from "./parameter-group"; +export * from "./props"; + +export { CaCertificate } from "../rds"; + +// TODO: omitted — upstream also re-exports the generated CFN L1 (`./docdb.generated`, i.e. +// `CfnDBCluster`/`CfnDBInstance`/`CfnDBClusterParameterGroup`/`CfnDBSubnetGroup`). This repo has no +// CloudFormation-generated L1 layer to re-export (Terraform L1s come from `@cdktn/provider-aws` +// instead, already consumed directly by `./instance.ts`/`./parameter-group.ts`) — identical +// omission to every other ported module in this repo (e.g. `../rds/index.ts`) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/index.ts#L12 diff --git a/src/aws/storage/docdb/instance.ts b/src/aws/storage/docdb/instance.ts new file mode 100644 index 00000000..0721de1a --- /dev/null +++ b/src/aws/storage/docdb/instance.ts @@ -0,0 +1,355 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/instance.ts + +import { docdbClusterInstance } from "@cdktn/provider-aws"; +import { Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import type { IDatabaseCluster } from "./cluster-ref"; +import { Endpoint } from "./endpoint"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; +import type { CaCertificate } from "../rds"; + +/** + * A database instance + * + * TODO: omitted — upstream also extends `aws_docdb.IDBInstanceRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (identical omission on `IDatabaseInstance` in + * `../rds/instance.ts`), so `dbInstanceRef` is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/instance.ts#L12 + */ +export interface IDatabaseInstance extends IAwsConstruct { + /** + * The instance identifier. + */ + readonly instanceIdentifier: string; + + /** + * The instance arn. + */ + readonly instanceArn: string; + + /** + * The instance endpoint address. + * + * @attribute Endpoint + */ + readonly dbInstanceEndpointAddress: string; + + /** + * The instance endpoint port. + * + * @attribute Port + */ + readonly dbInstanceEndpointPort: string; + + /** + * The instance endpoint. + */ + readonly instanceEndpoint: Endpoint; +} + +/** + * Properties that describe an existing instance + */ +export interface DatabaseInstanceAttributes { + /** + * The instance identifier. + */ + readonly instanceIdentifier: string; + + /** + * The endpoint address. + */ + readonly instanceEndpointAddress: string; + + /** + * The database port. + */ + readonly port: number; +} + +/** + * A new or imported database instance. + */ +abstract class DatabaseInstanceBase + extends AwsConstructBase + implements IDatabaseInstance +{ + /** + * Import an existing database instance. + */ + public static fromDatabaseInstanceAttributes( + scope: Construct, + id: string, + attrs: DatabaseInstanceAttributes, + ): IDatabaseInstance { + class Import extends DatabaseInstanceBase implements IDatabaseInstance { + public readonly instanceIdentifier = attrs.instanceIdentifier; + public readonly dbInstanceEndpointAddress = attrs.instanceEndpointAddress; + public readonly dbInstanceEndpointPort = Tokenization.stringifyNumber( + attrs.port, + ); + public readonly instanceEndpoint = new Endpoint( + attrs.instanceEndpointAddress, + attrs.port, + ); + } + + return new Import(scope, id, {}); + } + + /** + * @inheritdoc + */ + public abstract readonly instanceIdentifier: string; + /** + * @inheritdoc + */ + public abstract readonly dbInstanceEndpointAddress: string; + /** + * @inheritdoc + */ + public abstract readonly dbInstanceEndpointPort: string; + /** + * @inheritdoc + */ + public abstract readonly instanceEndpoint: Endpoint; + + /** + * The instance arn. + * + * TERRACONSTRUCTS DEVIATION: upstream resolves this via `Stack.formatArn`, which works for both + * owned and imported instances because `this.instanceIdentifier` is already the final physical + * name by the time this getter runs (not a two-phase CFN Ref/attribute). That is exactly this + * repo's `this.instanceIdentifier` semantics too (always the real, final value -- either a + * caller-supplied string or the underlying `aws_docdb_cluster_instance.identifier` attribute), so + * the same single `formatArn` call carries over unchanged. DocumentDB instance ARNs live in the + * `rds`/`db` ARN namespace (DocumentDB is RDS-family infrastructure), matching upstream exactly. + */ + public get instanceArn(): string { + return this.stack.formatArn({ + service: "rds", + resource: "db", + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: this.instanceIdentifier, + }); + } + + // TODO: omitted — see the TODO on `IDatabaseInstance` above (`dbInstanceRef`/`IDBInstanceRef`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/instance.ts#L116-L123 + // /** + // * A reference to this instance. + // */ + // public get dbInstanceRef(): DBInstanceReference { + // return { + // dbInstanceId: this.instanceIdentifier, + // }; + // } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — repo-wide construct-output convention (see + * `ClusterParameterGroup` in `./parameter-group.ts` and `SubnetGroup`/`OptionGroup` in `../rds`) + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + identifier: this.instanceIdentifier, + arn: this.instanceArn, + endpointAddress: this.dbInstanceEndpointAddress, + endpointPort: this.dbInstanceEndpointPort, + }; + } +} + +/** + * Construction properties for a DatabaseInstanceNew + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `DatabaseInstanceProps` does not — matching the base-idiom used throughout this + * repo (e.g. `ClusterParameterGroupProps` in `./parameter-group.ts`, `SubnetGroupProps`/ + * `OptionGroupProps` in `../rds`) for cross-account/-region construct placement. + */ +export interface DatabaseInstanceProps extends AwsConstructProps { + /** + * The DocumentDB database cluster the instance should launch into. + * + * TERRACONSTRUCTS DEVIATION: typed as `IDatabaseCluster` (this module's own construct interface, + * `./cluster-ref.ts`) rather than upstream's `aws_docdb.IDBClusterRef` — see the TODO on + * `IDatabaseCluster` there. Because there is no looser generated cross-stack reference type to + * accept here, the runtime `toIDatabaseCluster()` duck-typing cast upstream performs on this prop + * (to recover an `IDatabaseCluster` for the public `cluster` getter) is unnecessary and dropped. + */ + readonly cluster: IDatabaseCluster; + + /** + * The name of the compute and memory capacity classes. + */ + readonly instanceType: ec2.InstanceType; + + /** + * The name of the Availability Zone where the DB instance will be located. + * + * @default - no preference + */ + readonly availabilityZone?: string; + + /** + * A name for the DB instance. If you specify a name, it is lowercased (DocumentDB always + * lowercases DB instance identifiers server-side). + * + * @default - a gridUUID-scoped generated name + */ + readonly dbInstanceName?: string; + + /** + * Indicates that minor engine upgrades are applied automatically to the + * DB instance during the maintenance window. + * + * @default true + */ + readonly autoMinorVersionUpgrade?: boolean; + + /** + * The weekly time range (in UTC) during which system maintenance can occur. + * + * Format: `ddd:hh24:mi-ddd:hh24:mi` + * Constraint: Minimum 30-minute window + * + * @default - a 30-minute window selected at random from an 8-hour block of + * time for each AWS Region, occurring on a random day of the week. To see + * the time blocks available, see https://docs.aws.amazon.com/documentdb/latest/developerguide/db-instance-maintain.html#maintenance-window + */ + readonly preferredMaintenanceWindow?: string; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`) maps + // onto `CfnDBInstance`'s CloudFormation `DeletionPolicy`/`UpdateReplacePolicy`. `core.RemovalPolicy` + // is not ported in this repo (see the identical omission throughout `../rds`, e.g. + // `SubnetGroupProps`). Unlike `aws_db_instance` (RDS, see `../rds/instance.ts`'s + // `skipFinalSnapshot`/`finalSnapshotIdentifier`), the Terraform `aws_docdb_cluster_instance` + // resource has NO `skip_final_snapshot`/`final_snapshot_identifier`/`deletion_protection` + // arguments at all to honestly map any part of this onto -- individual DocumentDB cluster + // instances are stateless compute nodes (storage lives on the cluster, the same compute/storage + // separation as Aurora), so there is no Terraform-native replacement to offer here. It is dropped + // entirely rather than partially wired up — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/instance.ts#L176-L182 + // readonly removalPolicy?: RemovalPolicy; + + /** + * A value that indicates whether to enable Performance Insights for the DB Instance. + * + * @default - false + */ + readonly enablePerformanceInsights?: boolean; + + /** + * The identifier of the CA certificate for this DB instance. + * + * Specifying or updating this property triggers a reboot. + * + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/ca_cert_rotation.html + * + * @default - DocumentDB will choose a certificate authority + */ + readonly caCertificate?: CaCertificate; +} + +/** + * A database instance + * + * @resource aws_docdb_cluster_instance + */ +export class DatabaseInstance + extends DatabaseInstanceBase + implements IDatabaseInstance +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.docdb.DatabaseInstance"; + + /** + * The instance's database cluster + */ + public readonly cluster: IDatabaseCluster; + + /** + * @inheritdoc + */ + public readonly instanceIdentifier: string; + + /** + * @inheritdoc + */ + public readonly dbInstanceEndpointAddress: string; + + /** + * @inheritdoc + */ + public readonly dbInstanceEndpointPort: string; + + /** + * @inheritdoc + */ + public readonly instanceEndpoint: Endpoint; + + /** + * The underlying `aws_docdb_cluster_instance` L1. + */ + public readonly resource: docdbClusterInstance.DocdbClusterInstance; + + constructor(scope: Construct, id: string, props: DatabaseInstanceProps) { + super(scope, id, props); + + // TERRACONSTRUCTS DEVIATION: repo invariant -- unnamed resources get a gridUUID-scoped + // `uniqueResourceName` default (lowercased; DocumentDB always lowercases DB instance + // identifiers server-side) instead of relying on CloudFormation's Ref-based logical-id naming + // (which this repo has no equivalent of -- see `ClusterParameterGroup`/`../rds/SubnetGroup` for + // the same idiom) or the provider's own generated `terraform-` fallback. + // `DBInstanceIdentifier` is capped at 63 characters (same limit as RDS DB instance + // identifiers), so `maxLength` is passed explicitly here. + const instanceIdentifier = Token.isUnresolved(props.dbInstanceName) + ? props.dbInstanceName + : ( + props.dbInstanceName ?? + this.stack.uniqueResourceName(this, { maxLength: 63 }) + ).toLowerCase(); + + this.resource = new docdbClusterInstance.DocdbClusterInstance( + this, + "Resource", + { + clusterIdentifier: props.cluster.clusterIdentifier, + instanceClass: `db.${props.instanceType}`, + autoMinorVersionUpgrade: props.autoMinorVersionUpgrade ?? true, + availabilityZone: props.availabilityZone, + caCertIdentifier: props.caCertificate + ? props.caCertificate.toString() + : undefined, + identifier: instanceIdentifier, + preferredMaintenanceWindow: props.preferredMaintenanceWindow, + enablePerformanceInsights: props.enablePerformanceInsights, + }, + ); + + this.cluster = props.cluster; + this.instanceIdentifier = this.resource.identifier; + this.dbInstanceEndpointAddress = this.resource.endpoint; + this.dbInstanceEndpointPort = Tokenization.stringifyNumber( + this.resource.port, + ); + + // TERRACONSTRUCTS DEVIATION: upstream converts `instance.attrPort` (a CFN `Fn::GetAtt` string + // attribute) into a number token via `cdk.Token.asNumber(...)`. The CDKTF L1 `port` getter + // already returns a native `number`-typed (Token-backed) attribute, so no string-to-number + // conversion is needed here. + this.instanceEndpoint = new Endpoint( + this.resource.endpoint, + this.resource.port, + ); + } +} diff --git a/src/aws/storage/docdb/parameter-group.ts b/src/aws/storage/docdb/parameter-group.ts new file mode 100644 index 00000000..a8b038bd --- /dev/null +++ b/src/aws/storage/docdb/parameter-group.ts @@ -0,0 +1,143 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/parameter-group.ts + +import { docdbClusterParameterGroup } from "@cdktn/provider-aws"; +import { Construct } from "constructs"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; + +/** + * A cluster parameter group + * + * TODO: omitted — upstream also extends `aws_docdb.IDBClusterParameterGroupRef`, a CloudFormation + * cross-stack "Reference" marker interface generated from the CFN resource spec. TerraConstructs + * has no equivalent generated-reference layer (identical omission to `IParameterGroup` in + * `../rds/parameter-group.ts`), so `dbClusterParameterGroupRef` is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/parameter-group.ts#L12 + */ +export interface IClusterParameterGroup extends IAwsConstruct { + /** + * The name of this parameter group + */ + readonly parameterGroupName: string; +} + +/** + * A new cluster or instance parameter group + */ +abstract class ClusterParameterGroupBase + extends AwsConstructBase + implements IClusterParameterGroup +{ + /** + * Imports a parameter group + */ + public static fromParameterGroupName( + scope: Construct, + id: string, + parameterGroupName: string, + ): IClusterParameterGroup { + class Import extends AwsConstructBase implements IClusterParameterGroup { + public readonly parameterGroupName = parameterGroupName; + public get outputs(): Record { + return { name: this.parameterGroupName }; + } + } + return new Import(scope, id); + } + + /** + * The name of the parameter group + */ + public abstract readonly parameterGroupName: string; +} + +/** + * Properties for a cluster parameter group + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `ClusterParameterGroupProps` does not — matching the base-idiom used + * throughout this repo (e.g. `ParameterGroupProps` in `../rds/parameter-group.ts`) for + * cross-account/-region construct placement. + */ +export interface ClusterParameterGroupProps extends AwsConstructProps { + /** + * Description for this parameter group + * + * @default a CDK generated description + */ + readonly description?: string; + + /** + * Database family of this parameter group + */ + readonly family: string; + + /** + * The name of the cluster parameter group + * + * @default - a gridUUID-scoped generated name + */ + readonly dbClusterParameterGroupName?: string; + + /** + * The parameters in this parameter group + */ + readonly parameters: { [key: string]: string }; +} + +/** + * A cluster parameter group + * + * @resource aws_docdb_cluster_parameter_group + */ +export class ClusterParameterGroup + extends ClusterParameterGroupBase + implements IClusterParameterGroup +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.docdb.ClusterParameterGroup"; + /** + * The name of the parameter group + */ + public readonly parameterGroupName: string; + + private readonly resource: docdbClusterParameterGroup.DocdbClusterParameterGroup; + + constructor(scope: Construct, id: string, props: ClusterParameterGroupProps) { + super(scope, id, props); + + this.resource = new docdbClusterParameterGroup.DocdbClusterParameterGroup( + this, + "Resource", + { + // TERRACONSTRUCTS DEVIATION: when unnamed, upstream lets CloudFormation generate a name + // from the logical id; the repo invariant is a gridUUID-scoped `uniqueResourceName` + // default instead (mirroring `ParameterGroup` in `../rds/parameter-group.ts`), lowercased + // to match the DocumentDB/RDS-family server-side storage convention. + name: + props.dbClusterParameterGroupName ?? + this.stack.uniqueResourceName(this).toLowerCase(), + description: + props.description || `Cluster parameter group for ${props.family}`, + family: props.family, + parameter: Object.entries(props.parameters).map(([name, value]) => ({ + name, + value, + })), + }, + ); + + this.parameterGroupName = this.resource.name; + } + + public get outputs(): Record { + return { + name: this.parameterGroupName, + arn: this.resource.arn, + }; + } +} diff --git a/src/aws/storage/docdb/props.ts b/src/aws/storage/docdb/props.ts new file mode 100644 index 00000000..43dde951 --- /dev/null +++ b/src/aws/storage/docdb/props.ts @@ -0,0 +1,111 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/lib/props.ts + +import { Duration } from "../../../duration"; +import type * as encryption from "../../encryption"; + +// TERRACONSTRUCTS DEVIATION: upstream types `Login.password` as `core.SecretValue` (a +// CloudFormation dynamic-reference wrapper), which is not ported in this repo (see the identical +// deviation on `../rds/props.ts`'s `Credentials.password`). It is a plain `string` instead (which +// may itself be an unresolved Token). + +/** + * Backup configuration for DocumentDB databases + * + * @default - The retention period for automated backups is 1 day. + * The preferred backup window will be a 30-minute window selected at random + * from an 8-hour block of time for each AWS Region. + * @see https://docs.aws.amazon.com/documentdb/latest/developerguide/backup-restore.db-cluster-snapshots.html#backup-restore.backup-window + */ +export interface BackupProps { + /** + * How many days to retain the backup + */ + readonly retention: Duration; + + /** + * A daily time range in 24-hours UTC format in which backups preferably execute. + * + * Must be at least 30 minutes long. + * + * Example: '01:00-02:00' + * + * @default - a 30-minute window selected at random from an 8-hour block of + * time for each AWS Region. To see the time blocks available, see + * https://docs.aws.amazon.com/documentdb/latest/developerguide/backup-restore.db-cluster-snapshots.html#backup-restore.backup-window + */ + readonly preferredWindow?: string; +} + +/** + * Login credentials for a database cluster + */ +export interface Login { + /** + * Username + */ + readonly username: string; + + /** + * Password + * + * Do not put passwords in your CDK code directly. + * + * TERRACONSTRUCTS DEVIATION: `string` instead of upstream's `core.SecretValue` — see the file + * header note above. + * + * @default a Secrets Manager generated password + */ + readonly password?: string; + + /** + * KMS encryption key to encrypt the generated secret. + * + * @default default master key + */ + readonly kmsKey?: encryption.IKey; + + /** + * Specifies characters to not include in generated passwords. + * + * @default "\"@/" + */ + readonly excludeCharacters?: string; + + /** + * The physical name of the secret, that will be generated. + * + * @default Secretsmanager will generate a physical name for the secret + */ + readonly secretName?: string; +} + +/** + * Options to add the multi user rotation + */ +export interface RotationMultiUserOptions { + /** + * The secret to rotate. It must be a JSON string with the following format: + * ``` + * { + * "engine": , + * "host": , + * "username": , + * "password": , + * "dbname": , + * "port": , + * "masterarn": + * "ssl": + * } + * ``` + */ + readonly secret: encryption.ISecret; + + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default Duration.days(30) + */ + readonly automaticallyAfter?: Duration; +} diff --git a/src/aws/storage/index.ts b/src/aws/storage/index.ts index 2108580b..09f9d664 100644 --- a/src/aws/storage/index.ts +++ b/src/aws/storage/index.ts @@ -39,3 +39,6 @@ export * as assets from "./assets"; // aws-rds export * as rds from "./rds"; + +// aws-docdb +export * as docdb from "./docdb"; diff --git a/test/aws/storage/docdb/__snapshots__/instance.test.ts.snap b/test/aws/storage/docdb/__snapshots__/instance.test.ts.snap new file mode 100644 index 00000000..83307710 --- /dev/null +++ b/test/aws/storage/docdb/__snapshots__/instance.test.ts.snap @@ -0,0 +1,309 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatabaseInstance check that instantiation works 1`] = ` +"{ + "data": { + "aws_availability_zones": { + "AvailabilityZones": { + "provider": "aws" + } + }, + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_docdb_cluster": { + "Database_B269D8BB": { + "cluster_identifier": "test-cluster", + "master_password": "toolongtoolongtoolong", + "master_username": "admin", + "skip_final_snapshot": true, + "tags": { + "Name": "Test-Database", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_security_group_ids": [ + "\${aws_security_group.Database_SecurityGroup_5C91FDCB.id}" + ] + } + }, + "aws_docdb_cluster_instance": { + "Instance_C1063A87": { + "auto_minor_version_upgrade": true, + "cluster_identifier": "\${aws_docdb_cluster.Database_B269D8BB.cluster_identifier}", + "identifier": "mystackinstancec8b5f353", + "instance_class": "db.r5.xlarge", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_eip": { + "VPC_PublicSubnet1_EIP_6AD938E8": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_EIP_4947BC00": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway": { + "VPC_IGW_B7E252D3": { + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway_attachment": { + "VPC_VPCGW_99B986DC": { + "internet_gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_nat_gateway": { + "VPC_PublicSubnet1_NATGateway_E0556630": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet1_EIP_6AD938E8.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet1_RouteTableAssociation_0B0896DC", + "aws_route.VPC_PublicSubnet1_DefaultRoute_91CEF279" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_NATGateway_3C070193": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet2_EIP_4947BC00.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet2_RouteTableAssociation_5A808732", + "aws_route.VPC_PublicSubnet2_DefaultRoute_B7481BBA" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_route": { + "VPC_PrivateSubnet1_DefaultRoute_AE1D6490": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet1_NATGateway_E0556630.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet1_RouteTable_BE8A6027.id}" + }, + "VPC_PrivateSubnet2_DefaultRoute_F4F5CFD2": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet2_NATGateway_3C070193.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet2_RouteTable_0A19E10E.id}" + }, + "VPC_PublicSubnet1_DefaultRoute_91CEF279": { + "depends_on": [ + "aws_internet_gateway_attachment.VPC_VPCGW_99B986DC" + ], + "destination_cidr_block": "0.0.0.0/0", + "gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "route_table_id": "\${aws_route_table.VPC_PublicSubnet1_RouteTable_FEE4B781.id}" + }, + "VPC_PublicSubnet2_DefaultRoute_B7481BBA": { + "depends_on": [ + "aws_internet_gateway_attachment.VPC_VPCGW_99B986DC" + ], + "destination_cidr_block": "0.0.0.0/0", + "gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "route_table_id": "\${aws_route_table.VPC_PublicSubnet2_RouteTable_6F1A15F1.id}" + } + }, + "aws_route_table": { + "VPC_PrivateSubnet1_RouteTable_BE8A6027": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PrivateSubnet2_RouteTable_0A19E10E": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet1_RouteTable_FEE4B781": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet2_RouteTable_6F1A15F1": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_route_table_association": { + "VPC_PrivateSubnet1_RouteTableAssociation_347902D1": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet1_RouteTable_BE8A6027.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}" + }, + "VPC_PrivateSubnet2_RouteTableAssociation_0C73D413": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet2_RouteTable_0A19E10E.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + }, + "VPC_PublicSubnet1_RouteTableAssociation_0B0896DC": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet1_RouteTable_FEE4B781.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}" + }, + "VPC_PublicSubnet2_RouteTableAssociation_5A808732": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet2_RouteTable_6F1A15F1.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}" + } + }, + "aws_security_group": { + "Database_SecurityGroup_5C91FDCB": { + "description": "MyStack/Database/SecurityGroup", + "egress": [ + { + "cidr_blocks": [ + "0.0.0.0/0" + ], + "description": "Allow all outbound traffic by default", + "from_port": 0, + "ipv6_cidr_blocks": null, + "prefix_list_ids": null, + "protocol": "-1", + "security_groups": null, + "self": null, + "to_port": 0 + } + ], + "name": "a123e4567-e89b-12d3MyStackDatabaseSecurityGroup228B52A5", + "tags": { + "Name": "Test-Database", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_subnet": { + "VPC_PrivateSubnet1_05F5A6DA": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.128.0/18", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet1", + "aws-cdk:subnet-name": "Private", + "aws-cdk:subnet-type": "Private", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PrivateSubnet2_8C0AEF3A": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 1)}", + "cidr_block": "10.0.192.0/18", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet2", + "aws-cdk:subnet-name": "Private", + "aws-cdk:subnet-type": "Private", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet1_0D1B5E48": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.0.0/18", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "aws-cdk:subnet-name": "Public", + "aws-cdk:subnet-type": "Public", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet2_E52FD57B": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 1)}", + "cidr_block": "10.0.64.0/18", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "aws-cdk:subnet-name": "Public", + "aws-cdk:subnet-type": "Public", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_vpc": { + "VPC_B9E5F0B4": { + "cidr_block": "10.0.0.0/16", + "enable_dns_hostnames": true, + "enable_dns_support": true, + "instance_tenancy": "default", + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/docdb/__snapshots__/parameter-group.test.ts.snap b/test/aws/storage/docdb/__snapshots__/parameter-group.test.ts.snap new file mode 100644 index 00000000..8572b9bc --- /dev/null +++ b/test/aws/storage/docdb/__snapshots__/parameter-group.test.ts.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClusterParameterGroup check that instantiation works 1`] = ` +"{ + "data": { + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_docdb_cluster_parameter_group": { + "Params_A8366201": { + "description": "desc", + "family": "hello", + "name": "mystackparams9fd42da1", + "parameter": [ + { + "name": "key", + "value": "value" + } + ], + "tags": { + "Name": "Test-Params", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/docdb/cluster.test.ts b/test/aws/storage/docdb/cluster.test.ts new file mode 100644 index 00000000..39ac8f2b --- /dev/null +++ b/test/aws/storage/docdb/cluster.test.ts @@ -0,0 +1,1852 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/test/cluster.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { + docdbCluster, + docdbClusterInstance, + docdbClusterParameterGroup, + docdbSubnetGroup, + secretsmanagerSecret, + secretsmanagerSecretRotation, + secretsmanagerSecretVersion, + dataAwsSecretsmanagerRandomPassword, + vpcSecurityGroupEgressRule, + serverlessapplicationrepositoryCloudformationStack, +} from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing, Tokenization } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as docdb from "../../../../src/aws/storage/docdb"; +import { Duration } from "../../../../src/duration"; +import { Annotations, Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +// TERRACONSTRUCTS DEVIATION: filter out the unrelated `skipFinalSnapshot`/`finalSnapshotIdentifier` +// synth-time warning (emitted whenever neither is set, which every test in this file triggers +// incidentally) rather than asserting zero warnings overall -- mirrors the identical adaptation in +// `../rds/cluster.test.ts`. +function nonSkipFinalSnapshotWarnings(stack: AwsStack) { + return Annotations.fromStack(stack).warnings.filter( + (w) => !w.message.toString().includes("skipFinalSnapshot"), + ); +} + +describe("DatabaseCluster", () => { + test("check that instantiation works", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + master_username: "admin", + master_password: "tooshort", + storage_encrypted: true, + }); + t.resourceCountIs(docdbClusterInstance.DocdbClusterInstance, 1); + t.expect.toHaveResourceWithProperties(docdbSubnetGroup.DocdbSubnetGroup, { + subnet_ids: expect.arrayContaining([expect.anything()]), + }); + const [subnetGroupResource] = t.resourceTypeArray( + docdbSubnetGroup.DocdbSubnetGroup, + ) as any[]; + expect(subnetGroupResource.subnet_ids).toHaveLength(3); + }); + + test("can create a cluster with a single instance", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + master_username: "admin", + master_password: "tooshort", + }); + t.resourceCountIs(docdbClusterInstance.DocdbClusterInstance, 1); + }); + + test("can specify instance CA certificate", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const cluster = new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + caCertificate: docdb.CaCertificate.RDS_CA_RSA4096_G1, + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + cluster_identifier: stack.resolve(cluster.clusterIdentifier), + ca_cert_identifier: "rds-ca-rsa4096-g1", + }, + ); + }); + + test("errors when less than one instance is specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + instances: 0, + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.LARGE, + ), + }); + }).toThrow("At least one instance is required"); + }); + + test("errors when only one subnet is specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC", { + maxAzs: 1, + }); + + // WHEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.LARGE, + ), + vpcSubnets: { + subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS, + }, + }); + }).toThrow("Cluster requires at least 2 subnets, got 1"); + }); + + test("secret attachment target type is correct", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const cluster = new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: mirrors `DatabaseSecret.attach()`'s house pattern (see + // `../rds/cluster.test.ts`'s equivalent test) -- there is no + // `AWS::SecretsManager::SecretTargetAttachment` Terraform resource; `Secret.attach()` instead + // stores the resolved `SecretAttachmentTargetProps` inline on the secret's own + // `aws_secretsmanager_secret_version` (via `secretObjectValue`/`secretStringTemplate`), so the + // assertion below checks `cluster.secret`'s attachment target directly rather than a separate + // synthesized resource. + expect(cluster.secret).toBeDefined(); + const target = cluster.asSecretAttachmentTarget(); + expect(stack.resolve(target.targetType)).toEqual("AWS::DocDB::DBCluster"); + // `engine`/`ssl` are required by the Secrets Manager MongoDB rotation templates and are not + // resolvable server-side the way CFN's `SecretTargetAttachment` would (see the deviation note + // on `DatabaseClusterBase.asSecretAttachmentTarget()`); `host`/`port` come from the cluster's + // resolved endpoint. + expect(stack.resolve(target.connectionFields)).toEqual({ + dbClusterIdentifier: stack.resolve(cluster.clusterIdentifier), + engine: "mongo", + ssl: "true", + host: stack.resolve(cluster.clusterEndpoint.hostname), + port: stack.resolve( + Tokenization.stringifyNumber(cluster.clusterEndpoint.port), + ), + }); + }); + + test("generated secret is attached with mongo engine, ssl and host/port connection fields", () => { + // Regression test: `asSecretAttachmentTarget()` must inject `engine: "mongo"` and `ssl: "true"` + // into the generated secret's JSON -- without them, the Secrets Manager MongoDB rotation + // Lambda templates fail (see the docblock on `RotationMultiUserOptions.secret` in + // `../../../../src/aws/storage/docdb/props.ts`). Mirrors the analogous + // `../rds/cluster.test.ts` "generated secret is attached with host and port connection fields" + // regression tests. + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + { + secret_string: expect.stringContaining('"engine" = "mongo"'), + }, + ); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + { + secret_string: expect.stringContaining('"ssl" = "true"'), + }, + ); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + { + secret_string: expect.stringContaining("host"), + }, + ); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + { + secret_string: expect.stringContaining("port"), + }, + ); + }); + + test("can create a cluster with imported vpc and security group", () => { + // GIVEN + const stack = testStack(); + // TODO: omitted — upstream's `ec2.Vpc.fromLookup()` depends on the CDK CLI's synth-time + // context-provider lookup/cache mechanism, which has no CDKTF equivalent (same omission as + // `DatabaseClusterBase.fromLookup` in `./cluster.ts`). Use `compute.Vpc.fromVpcAttributes()` + // with explicitly known attributes instead. + const vpc = compute.Vpc.fromVpcAttributes(stack, "VPC", { + vpcId: "VPC12345", + availabilityZones: ["us-east-1a", "us-east-1b"], + privateSubnetIds: ["priv-1", "priv-2"], + }); + const sg = compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "SecurityGroupId12345", + ); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + securityGroup: sg, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + master_username: "admin", + master_password: "tooshort", + vpc_security_group_ids: ["SecurityGroupId12345"], + }); + }); + + test("can configure cluster deletion protection", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + deletionProtection: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + deletion_protection: true, + }); + }); + + test("cluster with parameter group", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const group = new docdb.ClusterParameterGroup(stack, "Params", { + family: "hello", + description: "bye", + parameters: { + param: "value", + }, + }); + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + parameterGroup: group, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + db_cluster_parameter_group_name: stack.resolve(group.parameterGroupName), + }); + t.expect.toHaveResourceWithProperties( + docdbClusterParameterGroup.DocdbClusterParameterGroup, + { + family: "hello", + description: "bye", + parameter: [{ name: "param", value: "value" }], + }, + ); + }); + + test("cluster with imported parameter group", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const group = docdb.ClusterParameterGroup.fromParameterGroupName( + stack, + "Params", + "ParamGroupName", + ); + + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + parameterGroup: group, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + db_cluster_parameter_group_name: "ParamGroupName", + }); + }); + + test("creates a secret when master credentials are not specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: mirrors `DatabaseCluster`'s generated-password house pattern (see + // `../rds/cluster.test.ts`'s equivalent test) rather than upstream's CFN dynamic-reference + // (`{{resolve:secretsmanager:...}}`) syntax -- the generated password is an + // `aws_secretsmanager_random_password` data-source token, stored verbatim (and `ignore_changes`'d) + // on the `aws_docdb_cluster.master_password` argument. + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: '"@/', + password_length: 41, + }, + ); + const [clusterResource] = t.resourceTypeArray( + docdbCluster.DocdbCluster, + ) as any[]; + expect(clusterResource.master_username).toEqual("admin"); + expect(clusterResource.master_password).toBeDefined(); + // TERRACONSTRUCTS DEVIATION: generated-password `ignore_changes` house pattern (see the + // `ignore_changes`/password-drift note in `DatabaseCluster`'s constructor) -- without it, every + // apply after the first would drift and REPLACE the live master password. + expect(clusterResource.lifecycle).toEqual({ + ignore_changes: ["master_password"], + }); + }); + + test("creates a secret with excludeCharacters", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + excludeCharacters: '"@/()[]', + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: '"@/()[]', + }, + ); + }); + + test("creates a secret with secretName set", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + secretName: "/myapp/mydocdb/masteruser", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + name: "/myapp/mydocdb/masteruser", + }, + ); + }); + + test("create an encrypted cluster with custom KMS key", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const key = new encryption.Key(stack, "Key"); + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + kmsKey: key, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + kms_key_id: stack.resolve(key.keyArn), + storage_encrypted: true, + }); + }); + + test("creating a cluster defaults to using encryption", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + storage_encrypted: true, + }); + }); + + test("supplying a KMS key with storageEncryption false throws an error", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + function action() { + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + kmsKey: new encryption.Key(stack, "Key"), + storageEncrypted: false, + }); + } + + // THEN + expect(action).toThrow(); + }); + + test("cluster exposes different read and write endpoints", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // THEN + expect(stack.resolve(cluster.clusterEndpoint)).not.toEqual( + stack.resolve(cluster.clusterReadEndpoint), + ); + }); + + test("instance identifier used when present", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const instanceIdentifierBase = "instanceidentifierbase-"; + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + instanceIdentifierBase, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + identifier: `${instanceIdentifierBase}1`, + }, + ); + }); + + test("cluster identifier used", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const clusterIdentifier = "clusteridentifier-"; + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + dbClusterName: clusterIdentifier, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + identifier: `${clusterIdentifier}instance1`, + }, + ); + }); + + test("cluster identifier defaults to a lowercased gridUUID-scoped generated name", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + docdbCluster.DocdbCluster, + ) as any[]; + expect(clusterResource.cluster_identifier).toEqual( + (clusterResource.cluster_identifier as string).toLowerCase(), + ); + }); + + test("generated subnet group name is distinct from the cluster identifier", () => { + // Regression test: the auto-created `aws_docdb_subnet_group.name` and `aws_docdb_cluster + // .cluster_identifier` are both gridUUID-scoped `uniqueResourceName` defaults, but MUST be + // derived from each resource's OWN construct path -- reusing the same path for both would + // silently collapse them to the identical string (see the deviation note on the `subnetGroup` + // creation in `../../../../src/aws/storage/docdb/cluster.ts`). + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + docdbCluster.DocdbCluster, + ) as any[]; + const [subnetGroupResource] = t.resourceTypeArray( + docdbSubnetGroup.DocdbSubnetGroup, + ) as any[]; + expect(subnetGroupResource.name).not.toEqual( + clusterResource.cluster_identifier, + ); + }); + + test("imported cluster has supplied attributes", () => { + // GIVEN + const stack = testStack(); + + // WHEN + const cluster = docdb.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Database", + { + clusterEndpointAddress: "addr", + clusterIdentifier: "identifier", + instanceEndpointAddresses: ["addr"], + instanceIdentifiers: ["identifier"], + port: 3306, + readerEndpointAddress: "reader-address", + securityGroup: compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { + allowAllOutbound: false, + }, + ), + }, + ); + + // THEN + expect(cluster.clusterEndpoint.hostname).toEqual("addr"); + expect(cluster.clusterEndpoint.port).toEqual(3306); + expect(cluster.clusterIdentifier).toEqual("identifier"); + expect(cluster.instanceIdentifiers).toEqual(["identifier"]); + expect(cluster.clusterReadEndpoint.hostname).toEqual("reader-address"); + expect(cluster.securityGroupId).toEqual("sg-123456789"); + }); + + test("imported cluster with imported security group honors allowAllOutbound", () => { + // GIVEN + const stack = testStack(); + + const cluster = docdb.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Database", + { + clusterEndpointAddress: "addr", + clusterIdentifier: "identifier", + instanceEndpointAddresses: ["addr"], + instanceIdentifiers: ["identifier"], + port: 3306, + readerEndpointAddress: "reader-address", + securityGroup: compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { + allowAllOutbound: false, + }, + ), + }, + ); + + // WHEN + cluster.connections.allowToAnyIpv4(compute.Port.tcp(443)); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupEgressRule.VpcSecurityGroupEgressRule, + { + security_group_id: "sg-123456789", + }, + ); + }); + + test("minimal imported cluster throws on accessing attributes for unprovided parameters", () => { + const stack = testStack(); + + const cluster = docdb.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Database", + { + clusterIdentifier: "identifier", + }, + ); + + expect(cluster.clusterIdentifier).toEqual("identifier"); + expect(() => cluster.clusterEndpoint).toThrow( + /Cannot access `clusterEndpoint` of an imported cluster/, + ); + expect(() => cluster.clusterReadEndpoint).toThrow( + /Cannot access `clusterReadEndpoint` of an imported cluster/, + ); + expect(() => cluster.instanceIdentifiers).toThrow( + /Cannot access `instanceIdentifiers` of an imported cluster/, + ); + expect(() => cluster.instanceEndpoints).toThrow( + /Cannot access `instanceEndpoints` of an imported cluster/, + ); + expect(() => cluster.securityGroupId).toThrow( + /Cannot access `securityGroupId` of an imported cluster/, + ); + }); + + test("backup retention period respected", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + backup: { + retention: Duration.days(20), + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + backup_retention_period: 20, + }); + }); + + test("backup maintenance window respected", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + backup: { + retention: Duration.days(20), + preferredWindow: "07:34-08:04", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + backup_retention_period: 20, + preferred_backup_window: "07:34-08:04", + }); + }); + + test("regular maintenance window respected", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + preferredMaintenanceWindow: "tue:07:34-tue:08:04", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + preferred_maintenance_window: "tue:07:34-tue:08:04", + }); + }); + + test("instanceMaintenanceWindow propagates to all auto-created instances", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + preferredMaintenanceWindow: "tue:04:17-tue:04:47", + instances: 2, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + instanceMaintenanceWindow: "sat:09:00-sat:09:30", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + preferred_maintenance_window: "tue:04:17-tue:04:47", + }); + t.resourceCountIs(docdbClusterInstance.DocdbClusterInstance, 2); + const instances = t.resourceTypeArray( + docdbClusterInstance.DocdbClusterInstance, + ) as any[]; + for (const instance of instances) { + expect(instance.preferred_maintenance_window).toEqual( + "sat:09:00-sat:09:30", + ); + } + }); + + test("maintenance window is omitted on instances when instanceMaintenanceWindow is not set", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // THEN + const t = new Template(stack); + const [instanceResource] = t.resourceTypeArray( + docdbClusterInstance.DocdbClusterInstance, + ) as any[]; + expect(instanceResource.preferred_maintenance_window).toBeUndefined(); + }); + + test.each([ + "mon:00:00-mon:00:30", + "sun:23:45-mon:00:15", + "wed:12:00-thu:11:59", + "SAT:09:00-SAT:09:30", // case-insensitive + ])("accepts valid maintenance window %s", (window) => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN / THEN + expect( + () => + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + preferredMaintenanceWindow: window, + instanceMaintenanceWindow: window, + }), + ).not.toThrow(); + }); + + test("skips maintenance window validation for tokenized values", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + // TERRACONSTRUCTS DEVIATION: upstream uses `cdk.CfnParameter`, which has no CDKTF equivalent; + // `TerraformVariable` (used the same way -- an unresolved Token at synth time) is the + // TerraConstructs-native stand-in (mirrors `../rds/cluster.test.ts`'s equivalent adaptation). + const clusterWindow = new TerraformVariable(stack, "ClusterWindow", { + type: "string", + }).stringValue; + const instanceWindow = new TerraformVariable(stack, "InstanceWindow", { + type: "string", + }).stringValue; + + // WHEN / THEN + expect( + () => + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + preferredMaintenanceWindow: clusterWindow, + instanceMaintenanceWindow: instanceWindow, + }), + ).not.toThrow(); + }); + + test.each([ + ["07:34-08:04", "missing day prefix"], + ["tue:04:17", "missing end range"], + ["funday:04:17-tue:04:47", "invalid day"], + ["tue:24:00-tue:24:30", "invalid hour"], + ["tue:04:60-tue:05:30", "invalid minute"], + ["tue:04:17-tue:04:47 ", "trailing whitespace"], + ["", "empty string"], + ])( + "fails for invalid preferredMaintenanceWindow %s (%s)", + (window, _reason) => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN / THEN + expect( + () => + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + preferredMaintenanceWindow: window, + }), + ).toThrow( + /preferredMaintenanceWindow must be in the format ddd:hh24:mi-ddd:hh24:mi/, + ); + }, + ); + + test.each([ + ["07:34-08:04", "missing day prefix"], + ["funday:04:17-tue:04:47", "invalid day"], + ["tue:24:00-tue:24:30", "invalid hour"], + ["tue:04:60-tue:05:30", "invalid minute"], + ["", "empty string"], + ])( + "fails for invalid instanceMaintenanceWindow %s (%s)", + (window, _reason) => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN / THEN + expect( + () => + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + instanceMaintenanceWindow: window, + }), + ).toThrow( + /instanceMaintenanceWindow must be in the format ddd:hh24:mi-ddd:hh24:mi/, + ); + }, + ); + + test("can configure CloudWatchLogs for audit", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + exportAuditLogsToCloudWatch: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + enabled_cloudwatch_logs_exports: ["audit"], + }); + }); + + test("can configure CloudWatchLogs for profiler", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + exportProfilerLogsToCloudWatch: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + enabled_cloudwatch_logs_exports: ["profiler"], + }); + }); + + test("can configure CloudWatchLogs for all logs", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + exportAuditLogsToCloudWatch: true, + exportProfilerLogsToCloudWatch: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + enabled_cloudwatch_logs_exports: ["audit", "profiler"], + }); + }); + + // TODO: omitted — upstream's `test('can set CloudWatch log retention', ...)` exercises + // `cloudWatchLogsRetention`/`cloudWatchLogsRetentionRole` (Lambda-backed `Custom::LogRetention` + // custom resource). There is no Terraform-native equivalent, and the props are dropped entirely + // (see the TODO on `DatabaseClusterProps.cloudWatchLogsRetention` in `../../../../src/aws/storage/docdb/cluster.ts`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/test/cluster.test.ts#L822-L860 + + test("can enable Performance Insights on instances", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + enablePerformanceInsights: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + enable_performance_insights: true, + }, + ); + }); + + test("single user rotation", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // WHEN + cluster.addRotationSingleUser(Duration.days(5)); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + { + rotation_rules: { schedule_expression: "rate(5 days)" }, + }, + ); + t.expect.toHaveResourceWithProperties( + serverlessapplicationrepositoryCloudformationStack.ServerlessapplicationrepositoryCloudformationStack, + { + application_id: expect.stringContaining( + "SecretsManagerMongoDBRotationSingleUser", + ), + }, + ); + }); + + test("single user rotation requires secret", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + password: "secretpassword", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // WHEN + function addSingleUserRotation() { + cluster.addRotationSingleUser(Duration.days(10)); + } + + // THEN + expect(addSingleUserRotation).toThrow(); + }); + + test("no multiple single user rotations", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + // WHEN + cluster.addRotationSingleUser(Duration.days(5)); + function addSecondRotation() { + cluster.addRotationSingleUser(Duration.days(10)); + } + + // THEN + expect(addSecondRotation).toThrow(); + }); + + test("multi user rotation", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + const userSecret = new docdb.DatabaseSecret(stack, "UserSecret", { + username: "seconduser", + masterSecret: cluster.secret, + }); + const attachedUserSecret = userSecret.attach(cluster); + + // WHEN + cluster.addRotationMultiUser("Rotation", { + secret: attachedUserSecret, + automaticallyAfter: Duration.days(5), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + serverlessapplicationrepositoryCloudformationStack.ServerlessapplicationrepositoryCloudformationStack, + { + application_id: expect.stringContaining( + "SecretsManagerMongoDBRotationMultiUser", + ), + parameters: expect.objectContaining({ + masterSecretArn: stack.resolve(cluster.secret!.secretArn), + }), + }, + ); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + { + rotation_rules: { schedule_expression: "rate(5 days)" }, + }, + ); + }); + + test("multi user rotation requires secret", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + password: "secretpassword", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + const userSecret = new docdb.DatabaseSecret(stack, "UserSecret", { + username: "seconduser", + masterSecret: cluster.secret, + }); + const attachedUserSecret = userSecret.attach(cluster); + + // WHEN + function addMultiUserRotation() { + cluster.addRotationMultiUser("Rotation", { + secret: attachedUserSecret, + }); + } + + // THEN + expect(addMultiUserRotation).toThrow(); + }); + + test("adds security groups", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new docdb.DatabaseCluster(stack, "Database", { + vpc, + masterUser: { + username: "admin", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + const securityGroup = new compute.SecurityGroup(stack, "SecurityGroup", { + vpc, + }); + + // WHEN + cluster.addSecurityGroups(securityGroup); + + // THEN + // Asserts the exact (order-sensitive) two-element list, not just that the added security + // group is present -- this is the behavior the bespoke `securityGroupIds` tracking deviation + // in `../../../../src/aws/storage/docdb/cluster.ts` exists to guarantee (the originally + // auto-created security group must be preserved, not clobbered, when appending). + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + vpc_security_group_ids: [ + stack.resolve(cluster.securityGroupId), + stack.resolve(securityGroup.securityGroupId), + ], + }); + }); + + // TODO: omitted globally throughout this block — upstream repeatedly asserts CFN + // `DeletionPolicy`/`UpdateReplacePolicy` via `removalPolicy`/`instanceRemovalPolicy`/ + // `securityGroupRemovalPolicy` (`RemovalPolicy.SNAPSHOT`/`RETAIN`). Terraform has no per-resource + // DeletionPolicy concept -- the equivalent semantics (`skipFinalSnapshot`/`finalSnapshotIdentifier`/ + // `deletionProtection`, plus the synth-time warning when neither is set) are covered by the + // dedicated tests below instead (mirroring `../rds/cluster.ts`'s house pattern). Neither + // `aws_docdb_cluster_instance` nor the auto-created security group has a native replacement at all + // (see the TODO on `DatabaseClusterProps.instanceRemovalPolicy`/`securityGroupRemovalPolicy` in + // `../../../../src/aws/storage/docdb/cluster.ts`), so those upstream tests have no TerraConstructs + // equivalent — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/test/cluster.test.ts#L1113-L1269 + describe("removal policy replacement props", () => { + test("skipFinalSnapshot, finalSnapshotIdentifier and deletionProtection are rendered when set", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + skipFinalSnapshot: false, + finalSnapshotIdentifier: "my-final-snapshot", + deletionProtection: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + skip_final_snapshot: false, + final_snapshot_identifier: "my-final-snapshot", + deletion_protection: true, + }); + }); + + test("skipFinalSnapshot true omits finalSnapshotIdentifier", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + skipFinalSnapshot: true, + }); + + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + docdbCluster.DocdbCluster, + ) as any[]; + expect(clusterResource.skip_final_snapshot).toEqual(true); + expect(clusterResource.final_snapshot_identifier).toBeUndefined(); + }); + + test("skipFinalSnapshot, finalSnapshotIdentifier and deletionProtection are absent when unset", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + docdbCluster.DocdbCluster, + ) as any[]; + expect(clusterResource.skip_final_snapshot).toBeUndefined(); + expect(clusterResource.final_snapshot_identifier).toBeUndefined(); + expect(clusterResource.deletion_protection).toBeUndefined(); + }); + + test("warns when neither skipFinalSnapshot nor finalSnapshotIdentifier is set", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + }); + + const warnings = Annotations.fromStack(stack).warnings; + expect( + warnings.some((w) => + w.message.toString().includes("skipFinalSnapshot"), + ), + ).toEqual(true); + }); + + test("does not warn when skipFinalSnapshot is true", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + skipFinalSnapshot: true, + }); + + expect(nonSkipFinalSnapshotWarnings(stack)).toHaveLength(0); + }); + }); + + test.each([ + "1.0.0.1", + "01.1.0", + "1.0", + "-1", + "-0.1", + "abc", + "1.0.a", + "a.b.c", + ])("throw error for invalid engine version %s", (engineVersion: string) => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // THEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + vpc, + engineVersion, + }); + }).toThrow( + `Invalid engine version: '${engineVersion}'. Engine version must be in the format x.y.z`, + ); + }); + + describe("storage type", () => { + test("specify storage type", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + vpc, + storageType: docdb.StorageType.IOPT1, + engineVersion: "5.0.0", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + storage_type: "iopt1", + }); + }); + + test("throw error for invalid engine version with I/O optimized storage type", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // THEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + instances: 1, + masterUser: { + username: "admin", + password: "tooshort", + }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.SMALL, + ), + vpc, + storageType: docdb.StorageType.IOPT1, + engineVersion: "3.6.0", + }); + }).toThrow( + "I/O-optimized storage is supported starting with engine version 5.0.0, got '3.6.0'", + ); + }); + }); + + describe("serverless clusters", () => { + test("can create a serverless cluster", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + password: "tooshort", + }, + vpc, + serverlessV2ScalingConfiguration: { + minCapacity: 0.5, + maxCapacity: 1, + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + serverless_v2_scaling_configuration: { + min_capacity: 0.5, + max_capacity: 1, + }, + }); + // Should not create any instances + t.resourceCountIs(docdbClusterInstance.DocdbClusterInstance, 0); + }); + + test("serverless cluster has empty instance arrays", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const cluster = new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + serverlessV2ScalingConfiguration: { + minCapacity: 0.5, + maxCapacity: 2, + }, + }); + + // THEN + expect(cluster.instanceIdentifiers).toEqual([]); + expect(cluster.instanceEndpoints).toEqual([]); + }); + + test("cannot specify instanceType with serverless configuration", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN/THEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.LARGE, + ), + serverlessV2ScalingConfiguration: { + minCapacity: 0.5, + maxCapacity: 1, + }, + }); + }).toThrow( + "Cannot specify both instanceType and serverlessV2ScalingConfiguration", + ); + }); + + test("provisioned cluster requires instanceType", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN/THEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + }); + }).toThrow( + "Either instanceType (for provisioned clusters) or serverlessV2ScalingConfiguration (for serverless clusters) must be specified", + ); + }); + + test("serverless cluster with all configuration options", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + serverlessV2ScalingConfiguration: { + minCapacity: 1, + maxCapacity: 4, + }, + engineVersion: "5.0.0", + deletionProtection: true, + exportAuditLogsToCloudWatch: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + serverless_v2_scaling_configuration: { + min_capacity: 1, + max_capacity: 4, + }, + engine_version: "5.0.0", + deletion_protection: true, + enabled_cloudwatch_logs_exports: ["audit"], + }); + }); + + test("serverless cluster requires engine version 5.0.0 or higher", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN/THEN + expect(() => { + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + serverlessV2ScalingConfiguration: { + minCapacity: 0.5, + maxCapacity: 1, + }, + engineVersion: "4.0.0", + }); + }).toThrow( + "DocumentDB serverless requires engine version 5.0.0 or higher, got '4.0.0'", + ); + }); + + test("serverless cluster allows engine version 5.0.0", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { + username: "admin", + }, + vpc, + serverlessV2ScalingConfiguration: { + minCapacity: 0.5, + maxCapacity: 1, + }, + engineVersion: "5.0.0", + }); + + // THEN - should not throw + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(docdbCluster.DocdbCluster, { + engine_version: "5.0.0", + serverless_v2_scaling_configuration: { + min_capacity: 0.5, + max_capacity: 1, + }, + }); + }); + }); +}); + +describe("instance identifiers (TERRACONSTRUCTS DEVIATION: gridUUID naming invariant)", () => { + test("unnamed cluster derives lowercase gridUUID-scoped instance identifiers from the derived cluster identifier", () => { + // GIVEN -- local stack (the shared beforeEach fixtures are scoped to the + // describe blocks above) + const app = Testing.app(); + const stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + const vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); + + // WHEN -- no dbClusterName, no instanceIdentifierBase + new docdb.DatabaseCluster(stack, "Database", { + masterUser: { username: "admin" }, + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.LARGE, + ), + instances: 2, + vpc, + }); + + // THEN -- instances reuse the derived (grid-scoped, lowercased) cluster + // identifier as their base instead of falling back to provider auto-naming + const t = new Template(stack); + const instances: any[] = t.resourceTypeArray( + docdbClusterInstance.DocdbClusterInstance, + ); + expect(instances).toHaveLength(2); + const clusterResource: any[] = t.resourceTypeArray( + docdbCluster.DocdbCluster, + ); + const clusterId = clusterResource[0].cluster_identifier; + expect(instances[0].identifier).toEqual(`${clusterId}instance1`); + expect(instances[1].identifier).toEqual(`${clusterId}instance2`); + expect(instances[0].identifier).toMatch(/^[a-z0-9-]+$/); + }); +}); diff --git a/test/aws/storage/docdb/endpoint.test.ts b/test/aws/storage/docdb/endpoint.test.ts new file mode 100644 index 00000000..6c071c89 --- /dev/null +++ b/test/aws/storage/docdb/endpoint.test.ts @@ -0,0 +1,98 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/test/endpoint.test.ts + +import { Token } from "cdktn"; +import { Endpoint } from "../../../../src/aws/storage/docdb"; + +const CDK_NUMERIC_TOKEN = Token.asNumber({ Ref: "abc" }); + +describe("Endpoint", () => { + test("accepts tokens for the port value", () => { + // GIVEN + const token = CDK_NUMERIC_TOKEN; + + // WHEN + const endpoint = new Endpoint("127.0.0.1", token); + + // THEN + expect(endpoint.port).toBe(token); + }); + + test("accepts valid port string numbers", () => { + // GIVEN + for (const port of [1, 50, 65535]) { + // WHEN + const endpoint = new Endpoint("127.0.0.1", port); + + // THEN + expect(endpoint.port).toBe(port); + } + }); + + test("throws an exception for port numbers below the minimum", () => { + // GIVEN + const port = 0; + + // WHEN + function createInvalidEnpoint() { + new Endpoint("127.0.0.1", port); + } + + // THEN + expect(createInvalidEnpoint).toThrow(); + }); + + test("throws an exception for port numbers above the maximum", () => { + // GIVEN + const port = 65536; + + // WHEN + function createInvalidEnpoint() { + new Endpoint("127.0.0.1", port); + } + + // THEN + expect(createInvalidEnpoint).toThrow(); + }); + + test("throws an exception for floating-point port numbers", () => { + // GIVEN + const port = 1.5; + + // WHEN + function createInvalidEnpoint() { + new Endpoint("127.0.0.1", port); + } + + // THEN + expect(createInvalidEnpoint).toThrow(); + }); + + describe(".portAsString()", () => { + test("converts port tokens to string tokens", () => { + // GIVEN + const port = CDK_NUMERIC_TOKEN; + const endpoint = new Endpoint("127.0.0.1", port); + + // WHEN + const result = endpoint.portAsString(); + + // THEN + // Should return a string token + expect(Token.isUnresolved(result)).toBeTruthy(); + // It should not just be the string representation of the numeric token + expect(result).not.toBe(port.toString()); + }); + + test("converts resolved port numbers to string representation", () => { + // GIVEN + const port = 1500; + const endpoint = new Endpoint("127.0.0.1", port); + + // WHEN + const result = endpoint.portAsString(); + + // THEN + expect(result).toBe(port.toString()); + }); + }); +}); diff --git a/test/aws/storage/docdb/instance.test.ts b/test/aws/storage/docdb/instance.test.ts new file mode 100644 index 00000000..8d9d97f4 --- /dev/null +++ b/test/aws/storage/docdb/instance.test.ts @@ -0,0 +1,284 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/test/instance.test.ts + +import { docdbCluster, docdbClusterInstance } from "@cdktn/provider-aws"; +import { App, Testing, Tokenization } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { Construct } from "constructs"; +import { ArnFormat, AwsConstructBase, AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as docdb from "../../../../src/aws/storage/docdb"; +import { CaCertificate } from "../../../../src/aws/storage/rds"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +const SINGLE_INSTANCE_TYPE = compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.XLARGE, +); +const EXPECTED_SYNTH_INSTANCE_TYPE = `db.${SINGLE_INSTANCE_TYPE}`; + +/** + * TEST-ONLY adapter implementing `docdb.IDatabaseCluster` directly on top of the + * `aws_docdb_cluster` L1. This fixture stands in for the real `DatabaseCluster` construct + * (`cluster.ts`) so `DatabaseInstance` (which requires a `docdb.IDatabaseCluster`) can be exercised + * in isolation from `DatabaseCluster`'s own subnet-group/security-group/secret machinery. Mirrors + * the `RdsDbInstanceAttachmentTarget` TEST-ONLY adapter pattern in + * `integ/aws/encryption/apps/secret-attach.ts`. + */ +class TestDatabaseCluster + extends AwsConstructBase + implements docdb.IDatabaseCluster +{ + public readonly clusterIdentifier: string; + public readonly instanceIdentifiers: string[] = []; + public readonly clusterEndpoint: docdb.Endpoint; + public readonly clusterReadEndpoint: docdb.Endpoint; + public readonly instanceEndpoints: docdb.Endpoint[] = []; + public readonly securityGroupId: string; + public readonly connections: compute.Connections; + + constructor(scope: Construct, id: string, vpc: compute.IVpc) { + super(scope, id, {}); + + const securityGroup = new compute.SecurityGroup(this, "SecurityGroup", { + vpc, + }); + const resource = new docdbCluster.DocdbCluster(this, "Resource", { + clusterIdentifier: "test-cluster", + masterUsername: "admin", + masterPassword: "toolongtoolongtoolong", + vpcSecurityGroupIds: [securityGroup.securityGroupId], + skipFinalSnapshot: true, + }); + + this.clusterIdentifier = resource.clusterIdentifier; + this.securityGroupId = securityGroup.securityGroupId; + this.connections = new compute.Connections({ + securityGroups: [securityGroup], + }); + this.clusterEndpoint = new docdb.Endpoint(resource.endpoint, 27017); + this.clusterReadEndpoint = new docdb.Endpoint( + resource.readerEndpoint, + 27017, + ); + } + + public asSecretAttachmentTarget(): encryption.SecretAttachmentTargetProps { + return { + targetId: this.clusterIdentifier, + targetType: encryption.AttachmentTargetType.DOCDB_DB_CLUSTER, + }; + } + + public get outputs(): Record { + return { identifier: this.clusterIdentifier }; + } +} + +let app: App; +let stack: AwsStack; +let vpc: compute.IVpc; +let cluster: docdb.IDatabaseCluster; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); + cluster = new TestDatabaseCluster(stack, "Database", vpc); +}); + +describe("DatabaseInstance", () => { + test("check that instantiation works", () => { + // WHEN + new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + cluster_identifier: stack.resolve(cluster.clusterIdentifier), + instance_class: EXPECTED_SYNTH_INSTANCE_TYPE, + auto_minor_version_upgrade: true, + }, + ); + }); + + test.each([ + [undefined, true], + [true, true], + [false, false], + ])( + "check that autoMinorVersionUpdate works: %p", + (given: boolean | undefined, expected: boolean) => { + // WHEN + new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + autoMinorVersionUpgrade: given, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + instance_class: EXPECTED_SYNTH_INSTANCE_TYPE, + auto_minor_version_upgrade: expected, + }, + ); + }, + ); + + test("check that CA certificate identifier works", () => { + // WHEN + new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + caCertificate: CaCertificate.RDS_CA_RSA4096_G1, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + instance_class: EXPECTED_SYNTH_INSTANCE_TYPE, + ca_cert_identifier: "rds-ca-rsa4096-g1", + }, + ); + }); + + test("check that the endpoint works", () => { + // WHEN + const instance = new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + }); + + // THEN + expect(stack.resolve(instance.instanceEndpoint.port)).toEqual( + stack.resolve(instance.resource.port), + ); + // Built from the L1 attribute references directly (NOT from + // instanceEndpoint's own fields) so this fails if socketAddress ever + // stops interpolating hostname:port. + expect(stack.resolve(instance.instanceEndpoint.socketAddress)).toEqual( + stack.resolve( + `${instance.resource.endpoint}:${Tokenization.stringifyNumber(instance.resource.port)}`, + ), + ); + }); + + test("check that instanceArn property works", () => { + // WHEN + const instance = new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + }); + + // THEN + expect(stack.resolve(instance.instanceArn)).toEqual( + stack.resolve( + stack.formatArn({ + service: "rds", + resource: "db", + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: instance.instanceIdentifier, + }), + ), + ); + }); + + test("check importing works as expected", () => { + // GIVEN + const instanceEndpointAddress = "127.0.0.1"; + const instanceIdentifier = "InstanceID"; + const port = 8888; + + // WHEN + const instance = docdb.DatabaseInstance.fromDatabaseInstanceAttributes( + stack, + "ImportedInstance", + { + instanceEndpointAddress, + instanceIdentifier, + port, + }, + ); + + // THEN + expect(instance.instanceIdentifier).toEqual(instanceIdentifier); + expect(instance.instanceEndpoint.socketAddress).toEqual( + `${instanceEndpointAddress}:${port}`, + ); + }); + + test("can enable performance insights on instances", () => { + // WHEN + new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + enablePerformanceInsights: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + enable_performance_insights: true, + }, + ); + }); + + test("instance identifier defaults to a gridUUID-scoped, lowercased generated name", () => { + // WHEN + new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + docdbClusterInstance.DocdbClusterInstance, + ) as any[]; + expect(resource.identifier).toEqual(expect.any(String)); + expect(resource.identifier).toEqual(resource.identifier.toLowerCase()); + }); + + test("an explicit dbInstanceName is lowercased", () => { + // WHEN + new docdb.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: SINGLE_INSTANCE_TYPE, + dbInstanceName: "MyInstanceName", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterInstance.DocdbClusterInstance, + { + identifier: "myinstancename", + }, + ); + }); +}); diff --git a/test/aws/storage/docdb/parameter-group.test.ts b/test/aws/storage/docdb/parameter-group.test.ts new file mode 100644 index 00000000..0e48df39 --- /dev/null +++ b/test/aws/storage/docdb/parameter-group.test.ts @@ -0,0 +1,122 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-docdb/test/parameter-group.test.ts + +import { docdbClusterParameterGroup } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as docdb from "../../../../src/aws/storage/docdb"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +}); + +describe("ClusterParameterGroup", () => { + test("check that instantiation works", () => { + // WHEN + new docdb.ClusterParameterGroup(stack, "Params", { + family: "hello", + description: "desc", + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + docdbClusterParameterGroup.DocdbClusterParameterGroup, + { + description: "desc", + family: "hello", + parameter: [{ name: "key", value: "value" }], + }, + ); + }); + + test("check automatically generated descriptions", () => { + // WHEN + new docdb.ClusterParameterGroup(stack, "Params", { + family: "hello", + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterParameterGroup.DocdbClusterParameterGroup, + { + description: "Cluster parameter group for hello", + family: "hello", + parameter: [{ name: "key", value: "value" }], + }, + ); + }); + + test("check that name defaults to a gridUUID-scoped generated name", () => { + // WHEN + new docdb.ClusterParameterGroup(stack, "Params", { + family: "hello", + parameters: {}, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + docdbClusterParameterGroup.DocdbClusterParameterGroup, + ) as any[]; + expect(resource.name).toEqual(expect.any(String)); + expect(resource.name).toEqual(resource.name.toLowerCase()); + }); + + test("check that an explicit name is honored", () => { + // WHEN + new docdb.ClusterParameterGroup(stack, "Params", { + family: "hello", + dbClusterParameterGroupName: "my-group", + parameters: {}, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + docdbClusterParameterGroup.DocdbClusterParameterGroup, + { + name: "my-group", + }, + ); + }); + + test("check that fromParameterGroupName imports by name", () => { + // WHEN + const group = docdb.ClusterParameterGroup.fromParameterGroupName( + stack, + "Imported", + "my-existing-group", + ); + + // THEN + expect(group.parameterGroupName).toEqual("my-existing-group"); + const t = new Template(stack); + t.resourceCountIs(docdbClusterParameterGroup.DocdbClusterParameterGroup, 0); + }); +});