diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index af17be84..7ce289ea 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -28,6 +28,10 @@ rds.groups: ## Test RDS SubnetGroup, ParameterGroup (both binds), OptionGroup, D go test -v -count 1 -timeout 20m ./... -run ^TestRdsGroups$ .PHONY: rds.groups +rds.instance: ## Test DatabaseInstance L2 (live Postgres db.t3.micro + attached secret) + go test -v -count 1 -timeout 45m ./... -run ^TestRdsInstance$ +.PHONY: rds.instance + 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/rds.instance.ts b/integ/aws/storage/apps/rds.instance.ts new file mode 100644 index 00000000..3b51d7d2 --- /dev/null +++ b/integ/aws/storage/apps/rds.instance.ts @@ -0,0 +1,80 @@ +// Live test for the storage.rds DatabaseInstance L2 (RDS PR 2c): a real +// Postgres db.t3.micro deployed through the ported construct in an isolated +// VPC, with credentials auto-generated into a DatabaseSecret and attached via +// the TerraConstructs attach() protocol -- the deployed secret must end up +// carrying engine/host/port/dbname/dbInstanceIdentifier merged in by +// `DatabaseInstanceBase.asSecretAttachmentTarget()` (the shipped reference +// implementation replacing the TEST-ONLY adapters in integ/aws/encryption). +// +// 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 ?? "rds.instance"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "g44444444-4444", + 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 instance = new aws.storage.rds.DatabaseInstance(stack, "Database", { + engine: aws.storage.rds.DatabaseInstanceEngine.postgres({ + version: aws.storage.rds.PostgresEngineVersion.VER_16, + }), + instanceType: aws.compute.InstanceType.of( + aws.compute.InstanceClass.BURSTABLE3, + aws.compute.InstanceSize.MICRO, + ), + vpc, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, + credentials: aws.storage.rds.Credentials.fromGeneratedSecret("dbadmin"), + databaseName: "appdb", + allocatedStorage: 20, + backupRetention: Duration.days(0), + multiAz: false, + // Terraform-native replacement for upstream removalPolicy (see the + // TERRACONSTRUCTS DEVIATION on the props): allow clean destroy. + skipFinalSnapshot: true, +}); + +new TerraformOutput(stack, "instance_identifier", { + value: instance.instanceIdentifier, + staticId: true, +}); +new TerraformOutput(stack, "instance_endpoint_address", { + value: instance.instanceEndpoint.hostname, + staticId: true, +}); +new TerraformOutput(stack, "secret_arn", { + value: instance.secret!.secretArn, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/rds_instance_test.go b/integ/aws/storage/rds_instance_test.go new file mode 100644 index 00000000..afd65d3f --- /dev/null +++ b/integ/aws/storage/rds_instance_test.go @@ -0,0 +1,68 @@ +package test + +import ( + "encoding/json" + "testing" + + "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/rds.instance.ts integration test: a real Postgres db.t3.micro +// deployed through the storage.rds DatabaseInstance L2 with auto-generated +// credentials. Validates the instance read-back, the attach() protocol's +// merged secret value (engine/host/port/dbname/dbInstanceIdentifier), and the +// post-apply drift oracle. +func TestRdsInstance(t *testing.T) { + runStorageIntegrationTest(t, "rds.instance", "us-east-1", validateRdsInstance) +} + +func validateRdsInstance(t *testing.T, tfWorkingDir string, awsRegion string) { + terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir) + outputs := terraform.OutputAll(t, terraformOptions) + + instanceID := outputs["instance_identifier"].(string) + endpointAddress := outputs["instance_endpoint_address"].(string) + secretArn := outputs["secret_arn"].(string) + + // --- 1. Instance read-back. --- + details, err := aws.GetRdsInstanceDetailsE(t, instanceID, awsRegion) + require.NoError(t, err) + require.Equal(t, "available", *details.DBInstanceStatus) + require.Equal(t, "postgres", *details.Engine) + require.Equal(t, "db.t3.micro", *details.DBInstanceClass) + require.False(t, *details.MultiAZ) + require.NotNil(t, details.Endpoint) + require.Equal(t, endpointAddress, *details.Endpoint.Address) + t.Logf("rds-instance: %s available (%s %s at %s:%d)", instanceID, + *details.Engine, *details.DBInstanceClass, *details.Endpoint.Address, *details.Endpoint.Port) + + // --- 2. The attached DatabaseSecret carries the merged connection fields + // from DatabaseInstanceBase.asSecretAttachmentTarget() (the shipped + // reference ISecretAttachmentTarget implementation). --- + // NOTE: `port` is a JSON NUMBER (not a string) -- CloudFormation's + // SecretTargetAttachment writes it as a number too, and the construct + // preserves that parity, so the map must be mixed-type. + secretValue := aws.GetSecretValue(t, awsRegion, secretArn) + var connection map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(secretValue), &connection)) + require.Equal(t, "dbadmin", connection["username"]) + require.NotEmpty(t, connection["password"]) + require.Equal(t, "postgres", connection["engine"]) + require.Equal(t, endpointAddress, connection["host"]) + require.Equal(t, float64(*details.Endpoint.Port), connection["port"], + "port must be a JSON number (CFN SecretTargetAttachment parity)") + require.Equal(t, "appdb", connection["dbname"]) + require.Equal(t, instanceID, connection["dbInstanceIdentifier"], + "attach() must merge dbInstanceIdentifier (CFN SecretTargetAttachment parity)") + t.Logf("rds-instance: attached secret carries full connection details incl. dbInstanceIdentifier=%s", instanceID) + + // --- Drift oracle: re-planning the already-applied stack must show zero + // changes. Catches sentinel-default and read-back mismatches invisible at + // synth time (password ignore_changes, storage/iops normalization, etc.). --- + 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/encryption/secret.ts b/src/aws/encryption/secret.ts index a3a87fde..d42b2a41 100644 --- a/src/aws/encryption/secret.ts +++ b/src/aws/encryption/secret.ts @@ -1009,6 +1009,27 @@ export class Secret extends SecretBase { this.rotationAttached = true; } + /** + * The value generated by `generateSecretString` (the underlying + * `aws_secretsmanager_random_password` data source's token), if this secret + * was constructed with one. `undefined` for secrets seeded via + * `secretStringValue`/`secretObjectValue`, or for imported secrets. + * + * TERRACONSTRUCTS DEVIATION: added for callers (e.g. `rds.DatabaseInstance`, + * via `rds.DatabaseSecret`) that must feed the SAME generated password into + * another resource's plaintext-password argument (e.g. `aws_db_instance.password`) + * so the stored secret and the live resource never drift apart. Upstream CDK + * solves this with `secret.secretValueFromJson('password').unsafeUnwrap()` (a + * CloudFormation dynamic reference), which has no Terraform-native equivalent + * and is not ported here (see the deviation note on `ISecret` above) -- this + * getter is the Terraform-native substitute: the raw generated-password token, + * surfaced directly instead of re-derived from the secret's JSON value. + * @internal + */ + public get _generatedPassword(): string | undefined { + return this.randomPassword?.randomPassword; + } + /** * Registers connection details contributed by an attached target so they are * merged into this secret's sole version. Called by `SecretTargetAttachment`. @@ -1034,14 +1055,14 @@ export class Secret extends SecretBase { /** * A secret attachment target. * - * NOTE: TerraConstructs does not yet ship any concrete implementations of - * this interface. In upstream aws-cdk, `ISecretAttachmentTarget` is - * implemented by `DatabaseInstance`, `DatabaseCluster`, and `DatabaseProxy` - * (aws-rds), and by the DocDB/Redshift equivalents -- none of which have - * been ported to TerraConstructs yet. Until they are, consumers who want to - * attach a secret to a database (or other supported target) must implement - * this interface themselves. See `integ/aws/encryption/apps/*.ts` for a - * worked (test-only) example. + * NOTE: `aws/storage/rds` `DatabaseInstanceBase` (`instance.ts`) is now the shipped reference + * implementation of this interface -- see its `asSecretAttachmentTarget()` for the AGREED DESIGN + * worked out here in practice. In upstream aws-cdk, `ISecretAttachmentTarget` is also implemented by + * `DatabaseCluster` and `DatabaseProxy` (aws-rds), and by the DocDB/Redshift equivalents -- none of + * which have been ported to TerraConstructs yet (tracked for PR 2d/2e and the DocDB/Redshift slices). + * Until they are, consumers who want to attach a secret to one of those not-yet-ported targets must + * implement this interface themselves. See `integ/aws/encryption/apps/*.ts` for a worked (test-only) + * example of that pattern. */ export interface ISecretAttachmentTarget { /** @@ -1183,11 +1204,12 @@ export interface ISecretTargetAttachment extends ISecret { * The result is exactly one `aws_secretsmanager_secret_version` containing the * base credentials plus the target's connection details. * - * Concrete `ISecretAttachmentTarget` implementers (`DatabaseInstance`, - * `DatabaseCluster`, ... in aws-rds/docdb/redshift) are not yet ported to - * TerraConstructs; until they are, consumers implement the interface - * themselves. See `integ/aws/encryption/apps/*.ts` for a worked (test-only) - * example of a target that supplies real connection details. + * `aws/storage/rds` `DatabaseInstance`/`DatabaseInstanceFromSnapshot` (via + * `DatabaseInstanceBase.asSecretAttachmentTarget()` in `instance.ts`) are now the shipped concrete + * `ISecretAttachmentTarget` implementers. `DatabaseCluster`/`DatabaseProxy` (aws-rds) and the + * docdb/redshift equivalents are not yet ported (tracked for PR 2d/2e and the DocDB/Redshift + * slices); until they are, consumers implement the interface themselves. See + * `integ/aws/encryption/apps/*.ts` for a worked (test-only) example of that pattern. * * `addToResourcePolicy` calls are forwarded to the original secret so that * only a single resource policy is ever created for the secret (AWS allows diff --git a/src/aws/storage/rds/index.ts b/src/aws/storage/rds/index.ts index 1d7663d7..296f770d 100644 --- a/src/aws/storage/rds/index.ts +++ b/src/aws/storage/rds/index.ts @@ -12,10 +12,10 @@ export * from "./parameter-group"; export * from "./database-secret"; export * from "./endpoint"; export * from "./option-group"; -// TODO: omitted — upstream also exports `./instance`, `./proxy`, `./proxy-endpoint`, and -// `./serverless-cluster` here (DatabaseInstance, DatabaseProxy/-Endpoint, ServerlessCluster v1). -// Those land in later PRs (RDS PR 2c/2e) — -// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/index.ts#L14-L17 +export * from "./instance"; +// TODO: omitted — upstream also exports `./proxy`, `./proxy-endpoint`, and `./serverless-cluster` +// here (DatabaseProxy/-Endpoint, ServerlessCluster v1). Those land in a later PR (RDS PR 2e) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/index.ts#L15-L17 export * from "./subnet-group"; // TODO: omitted — upstream also exports `./aurora-cluster-instance` here (Aurora Serverless v2 // cluster-instance helper). Lands in a later PR (RDS PR 2d) — diff --git a/src/aws/storage/rds/instance.ts b/src/aws/storage/rds/instance.ts new file mode 100644 index 00000000..793b1aa1 --- /dev/null +++ b/src/aws/storage/rds/instance.ts @@ -0,0 +1,2208 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts + +import { dbInstance, dbInstanceRoleAssociation } from "@cdktn/provider-aws"; +import { Annotations, Lazy, Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import type { CaCertificate } from "./ca-certificate"; +import { DatabaseInsightsMode } from "./database-insights-mode"; +import { DatabaseSecret } from "./database-secret"; +import { Endpoint } from "./endpoint"; +import type { IInstanceEngine } from "./instance-engine"; +import type { IOptionGroup } from "./option-group"; +import type { IParameterGroup } from "./parameter-group"; +import { ParameterGroup } from "./parameter-group"; +import { + applyDefaultRotationOptions, + defaultDeletionProtection, + engineDescription, + setupS3ImportExport, + validateManagedPasswordCredentials, +} from "./private/util"; +import type { + EngineLifecycleSupport, + RotationMultiUserOptions, + RotationSingleUserOptions, + SnapshotCredentials, +} from "./props"; +import { Credentials, PerformanceInsightRetention } from "./props"; +import type { ISubnetGroup } from "./subnet-group"; +import { SubnetGroup } from "./subnet-group"; +import { validateDatabaseInstanceProps } from "./validate-database-insights"; +import type { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; +import type * as encryption from "../../encryption"; +import * as secretsmanager from "../../encryption"; +import * as iam from "../../iam"; +import * as events from "../../notify"; +import type { IBucket } from "../bucket"; + +/** + * A database instance + * + * TODO: omitted — upstream also extends `aws_rds.IDBInstanceRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (see the identical omission on `ISubnetGroup` in + * `./subnet-group.ts`), so `dbInstanceRef` is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L41 + */ +export interface IDatabaseInstance + extends IAwsConstruct, + ec2.IConnectable, + secretsmanager.ISecretAttachmentTarget { + /** + * The instance identifier. + */ + readonly instanceIdentifier: string; + + /** + * The instance arn. + */ + readonly instanceArn: string; + + /** + * The instance endpoint address. + */ + readonly dbInstanceEndpointAddress: string; + + /** + * The instance endpoint port. + */ + readonly dbInstanceEndpointPort: string; + + /** + * The AWS Region-unique, immutable identifier for the DB instance. + * This identifier is found in AWS CloudTrail log entries whenever the AWS KMS key for the DB instance is accessed. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbinstance.html#aws-resource-rds-dbinstance-return-values + */ + readonly instanceResourceId?: string; + + /** + * The instance endpoint. + */ + readonly instanceEndpoint: Endpoint; + + /** + * The engine of this database Instance. + * May be not known for imported Instances if it wasn't provided explicitly, + * or for read replicas. + */ + readonly engine?: IInstanceEngine; + + // TODO: omitted — upstream also declares `addProxy(id, options): DatabaseProxy` here. `DatabaseProxy` + // (and the `./proxy` module it lives in) is not ported in this repo yet — it lands in a later PR + // (RDS PR 2e), matching the existing barrel deferral in `./index.ts` — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L86-L89 + + /** + * Grant the given identity connection access to the database. + * + * @param grantee the Principal to grant the permissions to + * @param dbUser the name of the database user to allow connecting as to the db instance + */ + grantConnect(grantee: iam.IGrantable, dbUser?: string): iam.Grant; + + /** + * Defines a CloudWatch event rule which triggers for instance events. Use + * `rule.addEventPattern(pattern)` to specify a filter. + */ + onEvent(id: string, options?: events.OnEventOptions): events.Rule; +} + +/** + * 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; + + /** + * The AWS Region-unique, immutable identifier for the DB instance. + * This identifier is found in AWS CloudTrail log entries whenever the AWS KMS key for the DB instance is accessed. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbinstance.html#aws-resource-rds-dbinstance-return-values + */ + readonly instanceResourceId?: string; + + /** + * The security groups of the instance. + */ + readonly securityGroups: ec2.ISecurityGroup[]; + + /** + * The engine of the existing database Instance. + * + * @default - the imported Instance's engine is unknown + */ + readonly engine?: IInstanceEngine; +} + +/** + * Properties for looking up an existing DatabaseInstance. + */ +export interface DatabaseInstanceLookupOptions { + /** + * The instance identifier of the DatabaseInstance + */ + readonly instanceIdentifier: string; +} + +/** + * A new or imported database instance. + */ +export abstract class DatabaseInstanceBase + extends AwsConstructBase + implements IDatabaseInstance +{ + /** + * Lookup an existing DatabaseInstance using instanceIdentifier. + */ + public static fromLookup( + scope: Construct, + _id: string, + _options: DatabaseInstanceLookupOptions, + ): IDatabaseInstance { + // TODO: omitted — upstream implements this via `ContextProvider.getValue(scope, { provider: + // cxschema.ContextProvider.CC_API_PROVIDER, ... })`, a CDK-CLI-side "cdk synth" context lookup + // against the CloudControl API (populated into `cdk.context.json` on a prior synth, then read + // back here). CDKTF/TerraConstructs has no equivalent synth-time context-provider/lookup-cache + // mechanism, so this cannot be ported — throwing here instead of silently returning nonsense. + // Reinstate if/when a CDKTF-native lookup mechanism (e.g. a `data "aws_db_instance"` -backed + // helper) is designed for this repo. The original implementation is left commented out below + // for reference — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L94-L147 + throw new ValidationError( + "DatabaseInstanceBase.fromLookup() is not supported in TerraConstructs (it depends on the CDK CLI's context-provider lookup mechanism, which has no CDKTF equivalent). Use `fromDatabaseInstanceAttributes()` with explicitly known attributes instead.", + scope, + ); + + // const response: {[key: string]: any}[] = ContextProvider.getValue(scope, { + // provider: cxschema.ContextProvider.CC_API_PROVIDER, + // props: { + // typeName: 'AWS::RDS::DBInstance', + // exactIdentifier: options.instanceIdentifier, + // propertiesToReturn: [ + // 'DBInstanceArn', + // 'Endpoint.Address', + // 'Endpoint.Port', + // 'DbiResourceId', + // 'DBSecurityGroups', + // 'VPCSecurityGroups', + // ], + // } as cxschema.CcApiContextQuery, + // dummyValue: [ + // { + // 'Identifier': 'TEST', + // 'DBInstanceArn': 'TESTARN', + // 'Endpoint.Address': 'TESTADDRESS', + // 'Endpoint.Port': '5432', + // 'DbiResourceId': 'TESTID', + // 'DBSecurityGroups': [], + // 'VPCSecurityGroups': [], + // }, + // ], + // }).value; + // + // // getValue returns a list of result objects. We are expecting 1 result or Error. + // const instance = response[0]; + // + // // Get ISecurityGroup from securityGroupId + // let securityGroups: ec2.ISecurityGroup[] = []; + // const sg: string[] = + // (instance.DBSecurityGroups && instance.DBSecurityGroups.length > 0) ? instance.DBSecurityGroups : + // (instance.VPCSecurityGroups && instance.VPCSecurityGroups.length > 0) ? instance.VPCSecurityGroups : + // []; + // securityGroups = sg.map(securityGroupId => { + // return ec2.SecurityGroup.fromSecurityGroupId( + // scope, + // `LSG-${securityGroupId}`, + // securityGroupId, + // ); + // }); + // + // return this.fromDatabaseInstanceAttributes(scope, id, { + // instanceEndpointAddress: instance['Endpoint.Address'], + // port: Number(instance['Endpoint.Port']), + // instanceIdentifier: options.instanceIdentifier, + // securityGroups: securityGroups, + // instanceResourceId: instance.DbiResourceId, + // }); + } + + /** + * Import an existing database instance. + */ + public static fromDatabaseInstanceAttributes( + scope: Construct, + id: string, + attrs: DatabaseInstanceAttributes, + ): IDatabaseInstance { + class Import extends DatabaseInstanceBase implements IDatabaseInstance { + public readonly defaultPort = ec2.Port.tcp(attrs.port); + public readonly connections = new ec2.Connections({ + securityGroups: attrs.securityGroups, + defaultPort: this.defaultPort, + }); + 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, + ); + public readonly engine = attrs.engine; + protected enableIamAuthentication = true; + public readonly instanceResourceId = attrs.instanceResourceId; + } + + return new Import(scope, id, {}); + } + + public abstract readonly instanceIdentifier: string; + public abstract readonly dbInstanceEndpointAddress: string; + public abstract readonly dbInstanceEndpointPort: string; + public abstract readonly instanceResourceId?: string; + public abstract readonly instanceEndpoint: Endpoint; + public abstract readonly engine?: IInstanceEngine; + protected abstract enableIamAuthentication?: boolean; + + /** + * Access to network connections. + */ + public abstract readonly connections: ec2.Connections; + + // TODO: omitted — see the TODO on `IDatabaseInstance` above; `DatabaseProxy`/`./proxy` is not + // ported yet (RDS PR 2e) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L246-L251 + // /** + // * Add a new db proxy to this instance. + // */ + // public addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy { + // return new DatabaseProxy(this, id, { + // proxyTarget: ProxyTarget.fromInstance(this), + // ...options, + // }); + // } + + /** + * [disable-awslint:no-grants] + */ + public grantConnect(grantee: iam.IGrantable, dbUser?: string): iam.Grant { + if (this.enableIamAuthentication === false) { + throw new ValidationError( + "Cannot grant connect when IAM authentication is disabled", + this, + ); + } + + if (!this.instanceResourceId) { + throw new ValidationError( + "For imported Database Instances, instanceResourceId is required to grantConnect()", + this, + ); + } + + if (!dbUser) { + throw new ValidationError( + "For imported Database Instances, the dbUser is required to grantConnect()", + this, + ); + } + + this.enableIamAuthentication = true; + return iam.Grant.addToPrincipal({ + grantee, + actions: ["rds-db:connect"], + resourceArns: [ + // The ARN of an IAM policy for IAM database access is not the same as the instance ARN, so we cannot use `this.instanceArn`. + // See https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html + this.stack.formatArn({ + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + service: "rds-db", + resource: "dbuser", + resourceName: [this.instanceResourceId, dbUser].join("/"), + }), + ], + }); + } + + /** + * Defines a CloudWatch event rule which triggers for instance events. Use + * `rule.addEventPattern(pattern)` to specify a filter. + */ + public onEvent(id: string, options: events.OnEventOptions = {}) { + const rule = new events.Rule(this, id, options); + rule.addEventPattern({ + source: ["aws.rds"], + resources: [this.instanceArn], + }); + rule.addTarget(options.target); + return rule; + } + + /** + * The instance arn. + * + * TERRACONSTRUCTS DEVIATION: upstream resolves this via `Stack.formatArn` for the common case, + * falling back to `getResourceArnAttribute` (a CFN two-phase Ref/attribute resolution helper — + * the physical name may not be known until deploy time) for owned resources. TerraConstructs has + * no equivalent two-phase resolution: `this.instanceIdentifier` is always the real, final value + * (either a caller-supplied string or the underlying `aws_db_instance.identifier` attribute), so + * a single `formatArn` call — which is exactly how CloudFormation derives this ARN too, since RDS + * instance ARNs are deterministic from region/account/identifier — is sufficient for both owned + * and imported instances. + */ + 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-rds/lib/instance.ts#L319-L327 + // /** + // * A reference to this database instance + // */ + // public get dbInstanceRef(): aws_rds.DBInstanceReference { + // return { + // dbInstanceIdentifier: this.instanceIdentifier, + // dbInstanceArn: this.instanceArn, + // }; + // } + + /** + * Renders the secret attachment target specifications. + * + * TERRACONSTRUCTS DEVIATION: upstream returns only `{ targetId, targetType }` — CloudFormation's + * `AWS::SecretsManager::SecretTargetAttachment` resolves the connection details (engine/host/port) + * server-side from those two fields. The Terraform AWS provider has no such server-side merge (see + * the AGREED DESIGN notes on `ISecretAttachmentTarget`/`SecretTargetAttachment` in + * `../../encryption/secret.ts`), so this — the REAL, shipped `ISecretAttachmentTarget` + * implementation for `DatabaseInstanceBase` (replacing the TEST-ONLY adapter in + * `integ/aws/encryption/apps/secret-attach.ts`) — also supplies `connectionFields` that + * `Secret.attach()` merges into the attached secret's JSON value. `dbname` is intentionally not + * included here: it isn't known at this base-class level (only `DatabaseInstanceSource` and its + * subclasses know the configured database name), and there is no abstract member for it on + * `DatabaseInstanceBase`/`IDatabaseInstance` to read it from generically — + * `DatabaseInstanceSource` overrides this method below to add `dbname` once it is known. + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + return { + targetId: this.instanceIdentifier, + targetType: secretsmanager.AttachmentTargetType.RDS_DB_INSTANCE, + connectionFields: { + // TERRACONSTRUCTS DEVIATION: mirrors CFN's `AWS::SecretsManager::SecretTargetAttachment`, + // which injects `dbInstanceIdentifier` (in addition to `engine`/`host`/`port`/`dbname`) into + // the attached secret's value for an RDS DB instance target — included here too so no + // connection field CFN provides is silently dropped. + dbInstanceIdentifier: this.instanceIdentifier, + ...(this.engine?.engineType ? { engine: this.engine.engineType } : {}), + host: this.instanceEndpoint.hostname, + port: Tokenization.stringifyNumber(this.instanceEndpoint.port), + }, + }; + } + + // TODO: omitted — `metricReadIOPS`/`metricWriteIOPS` (hand-written upstream) both call + // `this.metric(...)`, and `metric()` itself is not hand-written anywhere in this codebase — for + // every other service (see `QueueBase`/`sqs-augmentations.generated.ts`, + // `VpnConnectionBase`/`ec2-augmentations.generated.ts`, ...) the generic `metric()` method AND all + // named per-metric convenience methods (including the RDS equivalents of these two) are added via + // a `declare module` prototype-augmentation file generated by struct-builder from + // `rds-canned-metrics.generated.ts`. That `rds-augmentations.generated.ts` file lands in the NEXT + // PR — do not hand-write `metric()`/`metricReadIOPS`/`metricWriteIOPS` here in the meantime — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L339-L356 + // /** + // * The average number of disk read I/O operations per second. + // * + // * @default - average over 5 minutes + // */ + // public metricReadIOPS(props?: cloudwatch.MetricOptions) { + // return this.metric('ReadIOPS', { statistic: 'Average', ...props }); + // } + // + // /** + // * The average number of disk write I/O operations per second. + // * + // * @default - average over 5 minutes + // */ + // public metricWriteIOPS(props?: cloudwatch.MetricOptions) { + // return this.metric('WriteIOPS', { statistic: 'Average', ...props }); + // } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `SubnetGroup`/`OptionGroup`/`ParameterGroup` in this module) — bare, bound-per-construct + * `outputs` for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + identifier: this.instanceIdentifier, + arn: this.instanceArn, + endpointAddress: this.dbInstanceEndpointAddress, + endpointPort: this.dbInstanceEndpointPort, + ...(this.instanceResourceId && { resourceId: this.instanceResourceId }), + }; + } +} + +/** + * The license model. + */ +export enum LicenseModel { + /** + * License included. + */ + LICENSE_INCLUDED = "license-included", + + /** + * Bring your own license. + */ + BRING_YOUR_OWN_LICENSE = "bring-your-own-license", + + /** + * General public license. + */ + GENERAL_PUBLIC_LICENSE = "general-public-license", +} + +// TODO: omitted — `ProcessorFeatures` (CPU core count / threads-per-core) has no corresponding +// argument on the Terraform `aws_db_instance` resource at all (not a CFN-vs-Terraform semantic +// difference — the provider simply doesn't expose it; verified against the full config shape in +// `node_modules/@cdktn/provider-aws/lib/db-instance/index.d.ts`). `DatabaseInstanceNewProps.processorFeatures` +// and the `renderProcessorFeatures()` helper below are dropped for the same reason — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L378-L395 +// /** +// * The processor features. +// */ +// export interface ProcessorFeatures { +// /** +// * The number of CPU core. +// * +// * @default - the default number of CPU cores for the chosen instance class. +// */ +// readonly coreCount?: number; +// +// /** +// * The number of threads per core. +// * +// * @default - the default number of threads per core for the chosen instance class. +// */ +// readonly threadsPerCore?: number; +// } + +/** + * The type of storage. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html + */ +export enum StorageType { + /** + * Standard. + * + * Amazon RDS supports magnetic storage for backward compatibility. It is recommended to use + * General Purpose SSD or Provisioned IOPS SSD for any new storage needs. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html#CHAP_Storage.Magnetic + */ + STANDARD = "standard", + + /** + * General purpose SSD (gp2). + * + * Baseline performance determined by volume size + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html#Concepts.Storage.GeneralSSD + */ + GP2 = "gp2", + + /** + * General purpose SSD (gp3). + * + * Performance scales independently from storage + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html#Concepts.Storage.GeneralSSD + */ + GP3 = "gp3", + + /** + * Provisioned IOPS SSD (io1). + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html#USER_PIOPS + */ + IO1 = "io1", + + /** + * Provisioned IOPS SSD (io2). + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html#USER_PIOPS + */ + IO2 = "io2", +} + +/** + * The network type of the DB instance. + */ +export enum NetworkType { + /** + * IPv4 only network type. + */ + IPV4 = "IPV4", + + /** + * Dual-stack network type. + */ + DUAL = "DUAL", + + /** + * IPv6 only network type. + */ + IPV6 = "IPV6", +} + +/** + * Construction properties for a DatabaseInstanceNew + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), which + * upstream's `DatabaseInstanceNewProps` does not — matching the base-idiom used throughout this + * repo (e.g. `SubnetGroupProps`, `ParameterGroupProps`, `OptionGroupProps`) for cross-account/-region + * construct placement. + */ +export interface DatabaseInstanceNewProps extends AwsConstructProps { + /** + * Specifies if the database instance is a multiple Availability Zone deployment. + * + * @default false + */ + readonly multiAz?: boolean; + + /** + * The name of the Availability Zone where the DB instance will be located. + * + * @default - no preference + */ + readonly availabilityZone?: string; + + /** + * The storage type to associate with the DB instance. + * Storage types supported are gp2, gp3, io1, io2, and standard. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html#Concepts.Storage.GeneralSSD + * + * @default StorageType.GP2 + */ + readonly storageType?: StorageType; + + /** + * The storage throughput, specified in mebibytes per second (MiBps). + * + * Only applicable for GP3. + * + * @see https://docs.aws.amazon.com//AmazonRDS/latest/UserGuide/CHAP_Storage.html#gp3-storage + * + * @default - 125 MiBps if allocated storage is less than 400 GiB for MariaDB, MySQL, and PostgreSQL, + * less than 200 GiB for Oracle and less than 20 GiB for SQL Server. 500 MiBps otherwise (except for + * SQL Server where the default is always 125 MiBps). + */ + readonly storageThroughput?: number; + + /** + * The number of I/O operations per second (IOPS) that the database provisions. + * The value must be equal to or greater than 1000. + * + * @default - no provisioned iops if storage type is not specified. For GP3: 3,000 IOPS if allocated + * storage is less than 400 GiB for MariaDB, MySQL, and PostgreSQL, less than 200 GiB for Oracle and + * less than 20 GiB for SQL Server. 12,000 IOPS otherwise (except for SQL Server where the default is + * always 3,000 IOPS). + */ + readonly iops?: number; + + // TODO: omitted — see the TODO on `ProcessorFeatures` above; the provider has no argument for it — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L518-L526 + // readonly processorFeatures?: ProcessorFeatures; + + /** + * A name for the DB instance. If you specify a name, it is lowercased (RDS always lowercases DB + * instance identifiers server-side). + * + * @default - a gridUUID-scoped generated name + */ + readonly instanceIdentifier?: string; + + /** + * The VPC network where the DB subnet group should be created. + */ + readonly vpc: ec2.IVpc; + + /** + * The type of subnets to add to the created DB subnet group. + * + * @deprecated use `vpcSubnets` + * @default - private subnets + */ + readonly vpcPlacement?: ec2.SubnetSelection; + + /** + * The type of subnets to add to the created DB subnet group. + * + * @default - private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * The security groups to assign to the DB instance. + * + * @default - a new security group is created + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * The port for the instance. + * + * @default - the default port for the chosen engine. + */ + readonly port?: number; + + /** + * The DB parameter group to associate with the instance. + * + * @default - no parameter group + */ + readonly parameterGroup?: IParameterGroup; + + /** + * The option group to associate with the instance. + * + * @default - no option group + */ + readonly optionGroup?: IOptionGroup; + + /** + * Whether to enable mapping of AWS Identity and Access Management (IAM) accounts + * to database accounts. + * + * @default false + */ + readonly iamAuthentication?: boolean; + + /** + * The number of days during which automatic DB snapshots are retained. + * Set to zero to disable backups. + * When creating a read replica, you must enable automatic backups on the source + * database instance by setting the backup retention to a value other than zero. + * + * @default - Duration.days(1) for source instances, disabled for read replicas + */ + readonly backupRetention?: Duration; + + /** + * The daily time range during which automated backups are performed. + * + * Constraints: + * - Must be in the format `hh24:mi-hh24:mi`. + * - Must be in Universal Coordinated Time (UTC). + * - Must not conflict with the preferred maintenance window. + * - Must be at least 30 minutes. + * + * @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/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html#USER_WorkingWithAutomatedBackups.BackupWindow + */ + readonly preferredBackupWindow?: string; + + /** + * Indicates whether to copy all of the user-defined tags from the + * DB instance to snapshots of the DB instance. + * + * @default true + */ + readonly copyTagsToSnapshot?: boolean; + + /** + * Indicates whether automated backups should be deleted or retained when + * you delete a DB instance. + * + * @default true + */ + readonly deleteAutomatedBackups?: boolean; + + /** + * The interval, in seconds, between points when Amazon RDS collects enhanced + * monitoring metrics for the DB instance. + * + * @default - no enhanced monitoring + */ + readonly monitoringInterval?: Duration; + + /** + * Role that will be used to manage DB instance monitoring. + * + * TERRACONSTRUCTS DEVIATION: upstream types this as `iam.IRoleRef` (the newer, minimal + * ARN-bearing supertype of `IRole`). `IRoleRef` is not ported here (same deviation as + * `InstanceEngineBindOptions.s3ImportRole` in `./instance-engine.ts`), so `iam.IRole` is used + * instead. + * + * @default - A role is automatically created for you + */ + readonly monitoringRole?: iam.IRole; + + /** + * Whether to enable Performance Insights for the DB instance. + * + * @default - false, unless ``performanceInsightRetention`` or ``performanceInsightEncryptionKey`` is set. + */ + readonly enablePerformanceInsights?: boolean; + + /** + * The amount of time, in days, to retain Performance Insights data. + * + * If you set `databaseInsightsMode` to `DatabaseInsightsMode.ADVANCED`, you must set this property to `PerformanceInsightRetention.MONTHS_15`. + * + * @default 7 this is the free tier + */ + readonly performanceInsightRetention?: PerformanceInsightRetention; + + /** + * The AWS KMS key for encryption of Performance Insights data. + * + * TERRACONSTRUCTS DEVIATION: upstream types this as `kms.IKeyRef`. `IKeyRef` is not ported here; + * `encryption.IKey` is used instead (matches the base-idiom in `./props.ts`). + * + * @default - default master key + */ + readonly performanceInsightEncryptionKey?: encryption.IKey; + + /** + * The database insights mode. + * + * @default - DatabaseInsightsMode.STANDARD when performance insights are enabled, otherwise not set. + */ + readonly databaseInsightsMode?: DatabaseInsightsMode; + + /** + * The list of log types that need to be enabled for exporting to + * CloudWatch Logs. + * + * @default - no log exports + */ + readonly cloudwatchLogsExports?: string[]; + + // TODO: omitted — `cloudwatchLogsRetention`/`cloudwatchLogsRetentionRole` (and the + // `setLogRetention()`/`cloudwatchLogGroups` machinery below that consumes them) implement log + // retention via upstream's `logs.LogRetention`, a Lambda-backed CloudFormation custom resource + // that calls `PutRetentionPolicy`/`DeleteRetentionPolicy` on each exported log group. There is no + // Terraform-native equivalent (the `aws_db_instance` resource only controls WHICH logs are + // exported via `enabled_cloudwatch_logs_exports`, ported below as `cloudwatchLogsExports`; log + // group retention is a separate `aws_cloudwatch_log_group` concern the CDKTF caller must manage + // themselves, e.g. via `cloudwatch.LogGroup`). Same rule applied to the Neptune port plan — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L686-L701 + // readonly cloudwatchLogsRetention?: logs.RetentionDays; + // readonly cloudwatchLogsRetentionRole?: iam.IRole; + + /** + * 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/AmazonRDS/latest/UserGuide/USER_UpgradeDBInstance.Maintenance.html#Concepts.DBMaintenance + */ + readonly preferredMaintenanceWindow?: string; + + /** + * Indicates whether the DB instance should have deletion protection enabled. + * + * TERRACONSTRUCTS DEVIATION: upstream defaults this to `true` when `removalPolicy` is `RETAIN`. + * `core.RemovalPolicy` is not ported in this repo (see `skipFinalSnapshot`/`finalSnapshotIdentifier` + * below, and the identical omission throughout this module, e.g. `SubnetGroupProps`), so only the + * explicit flag is honored (see `defaultDeletionProtection` in `./private/util.ts`). + * + * @default false + */ + readonly deletionProtection?: boolean; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.SNAPSHOT`) + // is CloudFormation's DeletionPolicy concept: `RETAIN` orphans the resource, `SNAPSHOT` takes a + // CFN-auto-named final snapshot before deleting, `DESTROY` skips the snapshot entirely. `core.RemovalPolicy` + // is not ported anywhere in this repo (see `SubnetGroupProps`/`SecretProps`/`QueueProps` for the + // same omission). Terraform's `aws_db_instance` exposes the equivalent semantics natively via two + // separate arguments instead of one enum — `skipFinalSnapshot`/`finalSnapshotIdentifier` below are + // the TERRACONSTRUCTS-native replacement (deletion protection itself was already a native, + // independent argument both upstream and here — see `deletionProtection` above) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L723-L736 + // readonly removalPolicy?: 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 instance. When `false` (the default — matching upstream's `RemovalPolicy.SNAPSHOT` + * default) and `finalSnapshotIdentifier` is not set, `terraform destroy`/replace will FAIL at + * apply-time with an AWS API error (this is native `aws_db_instance` behavior, not enforced here at + * synth time, since whether the instance will ever be destroyed isn't known at synth time). + * + * @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 snapshot Terraform takes before destroying this instance. Unlike CloudFormation + * (which auto-generates a snapshot name), Terraform requires this to be supplied explicitly; there + * is no synth-time-safe way to auto-generate a unique one here. + * + * @default - no final snapshot identifier; required unless `skipFinalSnapshot` is `true` + */ + readonly finalSnapshotIdentifier?: string; + + /** + * Upper limit to which RDS can scale the storage in GiB(Gibibyte). + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PIOPS.StorageTypes.html#USER_PIOPS.Autoscaling + * @default - No autoscaling of RDS instance + */ + readonly maxAllocatedStorage?: number; + + /** + * The Active Directory directory ID to create the DB instance in. + * + * @default - Do not join domain + */ + readonly domain?: string; + + /** + * The IAM role to be used when making API calls to the Directory Service. The role needs the AWS-managed policy + * AmazonRDSDirectoryServiceAccess or equivalent. + * + * TERRACONSTRUCTS DEVIATION: `iam.IRole` instead of upstream's `iam.IRoleRef` — see `monitoringRole` above. + * + * @default - The role will be created for you if `DatabaseInstanceNewProps#domain` is specified + */ + readonly domainRole?: iam.IRole; + + /** + * Existing subnet group for the instance. + * + * TERRACONSTRUCTS DEVIATION: `ISubnetGroup` instead of upstream's `aws_rds.IDBSubnetGroupRef` — + * see the identical omission on `ISubnetGroup` in `./subnet-group.ts`. + * + * @default - a new subnet group will be created. + */ + readonly subnetGroup?: ISubnetGroup; + + /** + * Role that will be associated with this DB instance to enable S3 import. + * This feature is only supported by the Microsoft SQL Server, Oracle, and PostgreSQL engines. + * + * This property must not be used if `s3ImportBuckets` is used. + * + * For Microsoft SQL Server: + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/SQLServer.Procedural.Importing.html + * For Oracle: + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/oracle-s3-integration.html + * For PostgreSQL: + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/PostgreSQL.Procedural.Importing.html + * + * @default - New role is created if `s3ImportBuckets` is set, no role is defined otherwise + */ + readonly s3ImportRole?: iam.IRole; + + /** + * S3 buckets that you want to load data from. + * This feature is only supported by the Microsoft SQL Server, Oracle, and PostgreSQL engines. + * + * This property must not be used if `s3ImportRole` is used. + * + * @default - None + */ + readonly s3ImportBuckets?: IBucket[]; + + /** + * Role that will be associated with this DB instance to enable S3 export. + * + * This property must not be used if `s3ExportBuckets` is used. + * + * @default - New role is created if `s3ExportBuckets` is set, no role is defined otherwise + */ + readonly s3ExportRole?: iam.IRole; + + /** + * S3 buckets that you want to load data into. + * + * This property must not be used if `s3ExportRole` is used. + * + * @default - None + */ + readonly s3ExportBuckets?: IBucket[]; + + /** + * Indicates whether the DB instance is an internet-facing instance. If not specified, + * the instance's vpcSubnets will be used to determine if the instance is internet-facing + * or not. + * + * @default - `true` if the instance's `vpcSubnets` is `subnetType: SubnetType.PUBLIC`, `false` otherwise + */ + readonly publiclyAccessible?: boolean; + + /** + * The network type of the DB instance. + * + * @default - IPV4 + */ + readonly networkType?: NetworkType; + + /** + * The identifier of the CA certificate for this DB instance. + * + * Specifying or updating this property triggers a reboot. + * + * @default - RDS will choose a certificate authority + */ + readonly caCertificate?: CaCertificate; + + /** + * Specifies whether changes to the DB instance and any pending modifications are applied immediately, regardless of the `preferredMaintenanceWindow` setting. + * If set to `false`, changes are applied during the next maintenance window. + * + * TERRACONSTRUCTS DEVIATION: upstream's `@default` is "Changes will be applied immediately" because + * CloudFormation's `AWS::RDS::DBInstance` `ApplyImmediately` defaults to `true`. The Terraform + * `aws_db_instance` resource's `apply_immediately` argument defaults to `false` instead (see + * `node_modules/@cdktn/provider-aws/lib/db-instance/index.d.ts`), so leaving this prop unset — as + * this port does — renders no `apply_immediately` argument and changes are applied during the next + * maintenance window, not immediately. + * + * @default false - changes are applied during the next maintenance window (the `aws_db_instance` + * provider default) + */ + readonly applyImmediately?: boolean; + + /** + * The life cycle type for this DB instance. + * This setting applies only to RDS for MySQL and RDS for PostgreSQL. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support.html + * + * @default undefined - AWS RDS default setting is `EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT` + */ + readonly engineLifecycleSupport?: EngineLifecycleSupport; +} + +/** + * A role associated with a DB instance, to be materialized as an + * `aws_db_instance_role_association` resource. + * + * TERRACONSTRUCTS DEVIATION: not present upstream (see `createInstanceRoleAssociations` below) -- + * a named interface (rather than an inline object type) is required here because jsii only + * supports string-indexed map types for inline object-literal types + * (`JSII1003: Only string-indexed map types are supported`). + */ +export interface InstanceAssociatedRole { + /** The ARN of the role to associate with the DB instance. */ + readonly roleArn: string; + + /** The name of the feature for the DB instance that the role is to be associated with. */ + readonly featureName: string; +} + +/** + * A new database instance. + */ +abstract class DatabaseInstanceNew + extends DatabaseInstanceBase + implements IDatabaseInstance +{ + /** + * The VPC where this database instance is deployed. + */ + public readonly vpc: ec2.IVpc; + + public readonly connections: ec2.Connections; + + protected abstract readonly instanceType: ec2.InstanceType; + + protected readonly vpcPlacement?: ec2.SubnetSelection; + /** + * TERRACONSTRUCTS DEVIATION: typed loosely (`Record`, mirroring upstream's + * `CfnDBInstanceProps` grab-bag) rather than `Partial` — the L1 + * config's `instanceClass` argument is required (non-optional), which a `Partial<>` intermediate + * would fight with across the multi-step construction upstream uses (`newCfnProps` -> + * `sourceCfnProps` -> final `CfnDBInstanceProps` spread, mirrored here as `newInstanceProps` -> + * `sourceInstanceProps` -> final `DbInstanceConfig` spread in each leaf class). + */ + protected readonly newInstanceProps: Record; + + private readonly cloudwatchLogsExports?: string[]; + + private readonly domainId?: string; + private readonly domainRole?: iam.IRole; + + protected enableIamAuthentication?: boolean; + + constructor(scope: Construct, id: string, props: DatabaseInstanceNewProps) { + super(scope, id, props); + + this.vpc = props.vpc; + if (props.vpcSubnets && props.vpcPlacement) { + throw new ValidationError( + "Only one of `vpcSubnets` or `vpcPlacement` can be specified", + this, + ); + } + this.vpcPlacement = props.vpcSubnets ?? props.vpcPlacement; + + if (props.multiAz === true && props.availabilityZone) { + throw new ValidationError( + "Requesting a specific availability zone is not valid for Multi-AZ instances", + this, + ); + } + + const subnetGroup = + props.subnetGroup ?? + new SubnetGroup(this, "SubnetGroup", { + description: `Subnet group for ${this.node.id} database`, + vpc: this.vpc, + vpcSubnets: this.vpcPlacement, + // TERRACONSTRUCTS DEVIATION: no `removalPolicy` to pass — see the omission note on + // `SubnetGroupProps.removalPolicy` in `./subnet-group.ts`. + }); + + const securityGroups = props.securityGroups || [ + new ec2.SecurityGroup(this, "SecurityGroup", { + description: `Security group for ${this.node.id} database`, + vpc: props.vpc, + }), + ]; + + this.connections = new ec2.Connections({ + securityGroups, + defaultPort: ec2.Port.tcp( + Lazy.numberValue({ produce: () => this.instanceEndpoint.port }), + ), + }); + + let monitoringRole: iam.IRole | undefined; + if (props.monitoringInterval && props.monitoringInterval.toSeconds()) { + monitoringRole = + props.monitoringRole || + new iam.Role(this, "MonitoringRole", { + assumedBy: new iam.ServicePrincipal("monitoring.rds.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + this, + "MonitoringPolicy", + "service-role/AmazonRDSEnhancedMonitoringRole", + ), + ], + }); + } + + const storageType = props.storageType ?? StorageType.GP2; + const iops = defaultIops(storageType, props.iops); + if (props.storageThroughput && storageType !== StorageType.GP3) { + throw new ValidationError( + `The storage throughput can only be specified with GP3 storage type. Got ${storageType}.`, + this, + ); + } + if ( + storageType === StorageType.GP3 && + props.storageThroughput && + iops && + !Token.isUnresolved(props.storageThroughput) && + !Token.isUnresolved(iops) && + props.storageThroughput / iops > 0.25 + ) { + throw new ValidationError( + `The maximum ratio of storage throughput to IOPS is 0.25. Got ${props.storageThroughput / iops}.`, + this, + ); + } + + this.cloudwatchLogsExports = props.cloudwatchLogsExports; + this.enableIamAuthentication = props.iamAuthentication; + + const enablePerformanceInsights = + props.enablePerformanceInsights ?? + (props.performanceInsightRetention !== undefined || + props.performanceInsightEncryptionKey !== undefined || + props.databaseInsightsMode === DatabaseInsightsMode.ADVANCED || + undefined); + + if (props.domain) { + this.domainId = props.domain; + this.domainRole = + props.domainRole || + new iam.Role(this, "RDSDirectoryServiceRole", { + assumedBy: new iam.CompositePrincipal( + new iam.ServicePrincipal("rds.amazonaws.com"), + new iam.ServicePrincipal("directoryservice.rds.amazonaws.com"), + ), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + this, + "DirectoryServicePolicy", + "service-role/AmazonRDSDirectoryServiceAccess", + ), + ], + }); + } + + // TERRACONSTRUCTS DEVIATION: repo invariant -- unnamed resources get a gridUUID-scoped + // `uniqueResourceName` default (lowercased; RDS 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 `SubnetGroup`/`OptionGroup`/`ParameterGroup` for the same + // idiom) or the provider's own generated `terraform-` fallback. Also drops the + // `RDS_LOWERCASE_DB_IDENTIFIER` feature-flag branch upstream guards this with: that flag exists + // purely to preserve pre-existing (non-lowercased) CloudFormation template output for already + // deployed stacks; there is no legacy template to stay compatible with here, so the corrected + // (always-lowercase) behavior is simply the only behavior. Unlike the sibling `SubnetGroup` / + // `OptionGroup` / `ParameterGroup` (255-char AWS limits, close enough to the 256-char + // `uniqueResourceName` fallback to leave unbounded), `DBInstanceIdentifier` is capped at 63 + // characters, so `maxLength` is passed explicitly here. + const instanceIdentifier = Token.isUnresolved(props.instanceIdentifier) + ? props.instanceIdentifier + : ( + props.instanceIdentifier ?? + this.stack.uniqueResourceName(this, { maxLength: 63 }) + ).toLowerCase(); + + const instanceParameterGroupConfig = props.parameterGroup?.bindToInstance( + {}, + ); + const isInPublicSubnet = + this.vpcPlacement && + this.vpcPlacement.subnetType === ec2.SubnetType.PUBLIC; + this.newInstanceProps = { + autoMinorVersionUpgrade: props.autoMinorVersionUpgrade, + availabilityZone: props.multiAz ? undefined : props.availabilityZone, + backupRetentionPeriod: props.backupRetention?.toDays(), + copyTagsToSnapshot: props.copyTagsToSnapshot ?? true, + instanceClass: Lazy.stringValue({ + produce: () => `db.${this.instanceType}`, + }), + identifier: instanceIdentifier, + dbSubnetGroupName: subnetGroup.subnetGroupName, + deleteAutomatedBackups: props.deleteAutomatedBackups, + deletionProtection: defaultDeletionProtection(props.deletionProtection), + enabledCloudwatchLogsExports: this.cloudwatchLogsExports, + iamDatabaseAuthenticationEnabled: Lazy.anyValue({ + produce: () => this.enableIamAuthentication, + }), + performanceInsightsEnabled: enablePerformanceInsights, + iops, + monitoringInterval: props.monitoringInterval?.toSeconds(), + monitoringRoleArn: monitoringRole?.roleArn, + multiAz: props.multiAz, + parameterGroupName: instanceParameterGroupConfig?.parameterGroupName, + optionGroupName: props.optionGroup?.optionGroupName, + performanceInsightsKmsKeyId: + props.performanceInsightEncryptionKey?.keyArn, + performanceInsightsRetentionPeriod: enablePerformanceInsights + ? props.performanceInsightRetention || + PerformanceInsightRetention.DEFAULT + : undefined, + databaseInsightsMode: props.databaseInsightsMode, + port: props.port, + backupWindow: props.preferredBackupWindow, + maintenanceWindow: props.preferredMaintenanceWindow, + publiclyAccessible: props.publiclyAccessible ?? isInPublicSubnet, + storageType, + storageThroughput: props.storageThroughput, + vpcSecurityGroupIds: securityGroups.map((s) => s.securityGroupId), + maxAllocatedStorage: props.maxAllocatedStorage, + domain: this.domainId, + domainIamRoleName: this.domainRole?.roleName, + networkType: props.networkType, + caCertIdentifier: props.caCertificate + ? props.caCertificate.toString() + : undefined, + applyImmediately: props.applyImmediately, + engineLifecycleSupport: props.engineLifecycleSupport, + skipFinalSnapshot: props.skipFinalSnapshot, + finalSnapshotIdentifier: props.finalSnapshotIdentifier, + }; + + // TERRACONSTRUCTS DEVIATION: upstream's `RemovalPolicy.SNAPSHOT` (the effective default) never + // fails at delete/replace time because CloudFormation auto-generates the final snapshot name. + // Here, with neither `skipFinalSnapshot: true` nor an explicit `finalSnapshotIdentifier`, the + // provider's own default (`skip_final_snapshot: false` with no `final_snapshot_identifier`) + // causes `terraform destroy`/replace to fail at apply time with an AWS API error. That can't be + // caught at synth time (whether the instance will ever be destroyed isn't known here), so surface + // it as a synth-time warning instead — see `skipFinalSnapshot`/`finalSnapshotIdentifier` above. + if (props.skipFinalSnapshot !== true && !props.finalSnapshotIdentifier) { + Annotations.of(this).addWarning( + "Neither `skipFinalSnapshot` nor `finalSnapshotIdentifier` is set: `terraform destroy` (or any change that replaces this instance) 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.", + ); + } + } + + // TODO: omitted — `setLogRetention()`/`cloudwatchLogGroups` implement upstream's Lambda-backed + // `logs.LogRetention` custom resource per exported log; see the TODO on + // `DatabaseInstanceNewProps.cloudwatchLogsRetention` above for why this isn't portable — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L1053-L1065 + // public readonly cloudwatchLogGroups: {[engine: string]: logs.ILogGroup}; + // protected setLogRetention() { + // if (this.cloudwatchLogsExports && this.cloudwatchLogsRetention) { + // for (const log of this.cloudwatchLogsExports) { + // const logGroupName = `/aws/rds/instance/${this.instanceIdentifier}/${log}`; + // new logs.LogRetention(this, `LogRetention${log}`, { + // logGroupName, + // retention: this.cloudwatchLogsRetention, + // role: this.cloudwatchLogsRetentionRole, + // }); + // this.cloudwatchLogGroups[log] = logs.LogGroup.fromLogGroupName(this, `LogGroup${this.instanceIdentifier}${log}`, logGroupName); + // } + // } + // } + + /** + * Creates `aws_db_instance_role_association` resources for the given roles. + * + * TERRACONSTRUCTS DEVIATION: not present upstream. Upstream's `AssociatedRoles` is an inline + * array property on `AWS::RDS::DBInstance` itself (`CfnDBInstance.DBInstanceRoleProperty[]`); the + * Terraform `aws_db_instance` resource has no equivalent inline argument — role/feature-name + * associations are a SEPARATE `aws_db_instance_role_association` resource per role instead (see + * `node_modules/@cdktn/provider-aws/lib/db-instance-role-association/index.d.ts`). Called by + * `DatabaseInstanceSource` subclasses after the `aws_db_instance` resource exists. + */ + protected createInstanceRoleAssociations( + instanceIdentifier: string, + roles: InstanceAssociatedRole[], + ): void { + roles.forEach((role, index) => { + new dbInstanceRoleAssociation.DbInstanceRoleAssociation( + this, + `RoleAssociation${index}`, + { + dbInstanceIdentifier: instanceIdentifier, + featureName: role.featureName, + roleArn: role.roleArn, + }, + ); + }); + } +} + +/** + * Construction properties for a DatabaseInstanceSource + */ +export interface DatabaseInstanceSourceProps extends DatabaseInstanceNewProps { + /** + * The database engine. + */ + readonly engine: IInstanceEngine; + + /** + * The name of the compute and memory capacity for the instance. + * + * @default - m5.large (or, more specifically, db.m5.large) + */ + readonly instanceType?: ec2.InstanceType; + + /** + * The license model. + * + * @default - RDS default license model + */ + readonly licenseModel?: LicenseModel; + + /** + * Whether to allow major version upgrades. + * + * @default false + */ + readonly allowMajorVersionUpgrade?: boolean; + + /** + * The time zone of the instance. This is currently supported only by Microsoft Sql Server. + * + * @default - RDS default timezone + */ + readonly timezone?: string; + + /** + * The allocated storage size, specified in gibibytes (GiB). + * + * @default 100 + */ + readonly allocatedStorage?: number; + + /** + * The name of the database. + * + * @default - no name + */ + readonly databaseName?: string; + + /** + * The parameters in the DBParameterGroup to create automatically + * + * You can only specify parameterGroup or parameters but not both. + * You need to use a versioned engine to auto-generate a DBParameterGroup. + * + * @default - None + */ + readonly parameters?: { [key: string]: string }; +} + +/** + * A new source database instance (not a read replica) + */ +abstract class DatabaseInstanceSource + extends DatabaseInstanceNew + implements IDatabaseInstance +{ + public readonly engine?: IInstanceEngine; + /** + * The AWS Secrets Manager secret attached to the instance. + */ + public abstract readonly secret?: secretsmanager.ISecret; + + protected readonly sourceInstanceProps: Record; + protected readonly instanceType: ec2.InstanceType; + protected readonly instanceAssociatedRoles: InstanceAssociatedRole[]; + + protected manageMasterUserPassword?: boolean; + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Upstream's `grantConnect()` override (below) + * defaults `dbUser` by reading it back out of the attached secret's JSON value via + * `secret.secretValueFromJson('username').unsafeUnwrap()` — a CloudFormation dynamic reference, + * not portable here (see the `ISecret` deviation note in `../../encryption/secret.ts`). The + * master username is always known as a plain string at construction time in this port (see + * `renderInstanceCredentials` below), so leaf classes stash it here instead and `grantConnect()` + * reads it back directly. + */ + protected masterUsername?: string; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Stashed so `asSecretAttachmentTarget()` + * (overridden below) can contribute `dbname` to the attached secret's connection fields — see the + * deviation note there. + */ + protected readonly databaseName?: string; + + private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; + private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; + + constructor( + scope: Construct, + id: string, + props: DatabaseInstanceSourceProps, + ) { + super(scope, id, props); + + this.singleUserRotationApplication = + props.engine.singleUserRotationApplication; + this.multiUserRotationApplication = + props.engine.multiUserRotationApplication; + this.engine = props.engine; + this.databaseName = props.databaseName; + + const engineType = props.engine.engineType; + + if ( + props.engineLifecycleSupport && + !["mysql", "postgres"].includes(engineType) + ) { + throw new ValidationError( + `'engineLifecycleSupport' can only be specified for RDS for MySQL and RDS for PostgreSQL, got: '${engineType}'`, + this, + ); + } + + // only Oracle and SQL Server require the import and export Roles to be the same + const combineRoles = + engineType.startsWith("oracle-") || engineType.startsWith("sqlserver-"); + const { s3ImportRole, s3ExportRole } = setupS3ImportExport( + this, + props, + combineRoles, + ); + const engineConfig = props.engine.bindToInstance(this, { + ...props, + s3ImportRole, + s3ExportRole, + }); + + const instanceAssociatedRoles: InstanceAssociatedRole[] = []; + const engineFeatures = engineConfig.features; + if (s3ImportRole) { + if (!engineFeatures?.s3Import) { + throw new ValidationError( + `Engine '${engineDescription(props.engine)}' does not support S3 import`, + this, + ); + } + instanceAssociatedRoles.push({ + roleArn: s3ImportRole.roleArn, + featureName: engineFeatures.s3Import, + }); + } + if (s3ExportRole) { + if (!engineFeatures?.s3Export) { + throw new ValidationError( + `Engine '${engineDescription(props.engine)}' does not support S3 export`, + this, + ); + } + // only add the export feature if it's different from the import feature + if (engineFeatures.s3Import !== engineFeatures?.s3Export) { + instanceAssociatedRoles.push({ + roleArn: s3ExportRole.roleArn, + featureName: engineFeatures.s3Export, + }); + } + } + this.instanceAssociatedRoles = instanceAssociatedRoles; + + this.instanceType = + props.instanceType ?? + ec2.InstanceType.of(ec2.InstanceClass.M5, ec2.InstanceSize.LARGE); + + if (props.parameterGroup && props.parameters) { + throw new ValidationError( + "You cannot specify both parameterGroup and parameters", + this, + ); + } + + const parameterGroupName = props.parameters + ? new ParameterGroup(this, "ParameterGroup", { + engine: props.engine, + parameters: props.parameters, + }).bindToInstance({}).parameterGroupName + : this.newInstanceProps.parameterGroupName; + + this.sourceInstanceProps = { + ...this.newInstanceProps, + optionGroupName: engineConfig.optionGroup?.optionGroupName, + allocatedStorage: props.allocatedStorage ?? 100, + allowMajorVersionUpgrade: props.allowMajorVersionUpgrade, + dbName: props.databaseName, + engine: engineType, + engineVersion: props.engine.engineVersion?.fullVersion, + licenseModel: props.licenseModel, + timezone: props.timezone, + parameterGroupName, + }; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Overrides `DatabaseInstanceBase`'s + * `asSecretAttachmentTarget()` to also contribute `dbname` to the attached secret's connection + * fields, now that a configured database name is available at this level (`this.databaseName`, + * stashed above from `DatabaseInstanceSourceProps.databaseName`). + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + const target = super.asSecretAttachmentTarget(); + return { + ...target, + connectionFields: { + ...target.connectionFields, + ...(this.databaseName ? { dbname: this.databaseName } : {}), + }, + }; + } + + /** + * Adds the single user rotation of the master password to this instance. + * + * @param options the options for the rotation, + * if you want to override the defaults + */ + public addRotationSingleUser( + options: RotationSingleUserOptions = {}, + ): secretsmanager.SecretRotation { + if (this.manageMasterUserPassword) { + throw new ValidationError( + "Cannot add rotation when `manageMasterUserPassword` is enabled. RDS automatically rotates the master password when it manages the secret.", + this, + ); + } + if (!this.secret) { + throw new ValidationError( + "Cannot add single user rotation for an instance 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 instance.", + this, + ); + } + + return new secretsmanager.SecretRotation(this, id, { + ...applyDefaultRotationOptions(options, this.vpcPlacement), + secret: this.secret, + application: this.singleUserRotationApplication, + vpc: this.vpc, + target: this, + }); + } + + /** + * Adds the multi user rotation to this instance. + */ + public addRotationMultiUser( + id: string, + options: RotationMultiUserOptions, + ): secretsmanager.SecretRotation { + if (this.manageMasterUserPassword) { + throw new ValidationError( + "Cannot add rotation when `manageMasterUserPassword` is enabled. RDS automatically rotates the master password when it manages the secret.", + this, + ); + } + if (!this.secret) { + throw new ValidationError( + "Cannot add multi user rotation for an instance without secret.", + this, + ); + } + + return new secretsmanager.SecretRotation(this, id, { + ...applyDefaultRotationOptions(options, this.vpcPlacement), + secret: options.secret, + masterSecret: this.secret, + application: this.multiUserRotationApplication, + vpc: this.vpc, + target: this, + }); + } + + /** + * Grant the given identity connection access to the database. + * + * [disable-awslint:no-grants] + * + * @param grantee the Principal to grant the permissions to + * @param dbUser the name of the database user to allow connecting as to the db instance, + * or the default database user, obtained from the Secret, if not specified + */ + public grantConnect(grantee: iam.IGrantable, dbUser?: string): iam.Grant { + if (!dbUser) { + // TERRACONSTRUCTS DEVIATION: see the deviation note on `masterUsername` above — reads the + // plain-string username stashed at construction time instead of + // `secret.secretValueFromJson('username').unsafeUnwrap()`. + if (!this.secret && !this.masterUsername) { + throw new ValidationError( + "A secret or dbUser is required to grantConnect()", + this, + ); + } + + dbUser = this.masterUsername; + if (!dbUser) { + throw new ValidationError( + "A secret or dbUser is required to grantConnect()", + this, + ); + } + } + + return super.grantConnect(grantee, dbUser); + } +} + +/** + * Construction properties for a DatabaseInstance. + */ +export interface DatabaseInstanceProps extends DatabaseInstanceSourceProps { + /** + * Credentials for the administrative user + * + * @default - A username of 'admin' (or 'postgres' for PostgreSQL) and SecretsManager-generated password + */ + readonly credentials?: Credentials; + + /** + * Whether to use RDS native integration with AWS Secrets Manager for master user password management. + * + * When enabled, RDS generates and manages the master user password in Secrets Manager. + * Cannot be used together with credentials containing a password. + * + * @default false + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-secrets-manager.html + */ + readonly manageMasterUserPassword?: boolean; + + /** + * For supported engines, specifies the character set to associate with the + * DB instance. + * + * @default - RDS default character set name + */ + readonly characterSetName?: string; + + /** + * Indicates whether the DB instance is encrypted. + * + * @default - true if storageEncryptionKey has been provided, false otherwise + */ + readonly storageEncrypted?: boolean; + + /** + * The KMS key that's used to encrypt the DB instance. + * + * TERRACONSTRUCTS DEVIATION: `encryption.IKey` instead of upstream's `kms.IKeyRef` — see + * `DatabaseInstanceNewProps.performanceInsightEncryptionKey` above. + * + * @default - default master key if storageEncrypted is true, no key otherwise + */ + readonly storageEncryptionKey?: encryption.IKey; +} + +/** + * A database instance + * + * @resource aws_db_instance + */ +export class DatabaseInstance + extends DatabaseInstanceSource + implements IDatabaseInstance +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.DatabaseInstance"; + + public readonly instanceIdentifier: string; + public readonly dbInstanceEndpointAddress: string; + public readonly dbInstanceEndpointPort: string; + public readonly instanceResourceId?: string; + public readonly instanceEndpoint: Endpoint; + public readonly secret?: secretsmanager.ISecret; + + /** + * The underlying `aws_db_instance` 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: dbInstance.DbInstance; + + constructor(scope: Construct, id: string, props: DatabaseInstanceProps) { + super(scope, id, props); + + // Validate database instance props + validateDatabaseInstanceProps(this, props); + + // Validate manageMasterUserPassword conflicts with unsupported credential properties + if (props.manageMasterUserPassword) { + validateManagedPasswordCredentials(this, props.credentials); + } + + this.manageMasterUserPassword = props.manageMasterUserPassword; + + // Prepare credential-specific configuration + let secret: secretsmanager.ISecret | undefined; + let masterUsername: string | undefined; + let masterUserPassword: string | undefined; + let manageMasterUserPassword: boolean | undefined; + let masterUserSecretKmsKeyId: string | undefined; + + if (props.manageMasterUserPassword) { + // RDS-managed approach: RDS creates and manages the Secret automatically + masterUsername = + props.credentials?.username ?? props.engine.defaultUsername ?? "admin"; + manageMasterUserPassword = props.manageMasterUserPassword; + masterUserSecretKmsKeyId = props.credentials?.encryptionKey?.keyArn; + } else { + // Standard approach: CDK creates and manages the Secret via DatabaseSecret + const rendered = renderInstanceCredentials( + this, + props.engine, + props.credentials, + ); + secret = rendered.secret; + masterUsername = rendered.username; + masterUserPassword = rendered.password; + } + this.masterUsername = masterUsername; + + const instance = new dbInstance.DbInstance(this, "Resource", { + ...this.sourceInstanceProps, + characterSetName: props.characterSetName, + kmsKeyId: props.storageEncryptionKey?.keyArn, + username: masterUsername, + password: masterUserPassword, + manageMasterUserPassword, + masterUserSecretKmsKeyId, + storageEncrypted: props.storageEncryptionKey + ? true + : props.storageEncrypted, + } as dbInstance.DbInstanceConfig); + + this.resource = instance; + this.instanceIdentifier = instance.identifier; + this.dbInstanceEndpointAddress = instance.address; + this.dbInstanceEndpointPort = Tokenization.stringifyNumber(instance.port); + this.instanceResourceId = instance.resourceId; + // NOTE: must be set before `secret.attach(this)` below -- `attach()` calls + // `asSecretAttachmentTarget()` synchronously, which reads `this.instanceEndpoint`. + this.instanceEndpoint = new Endpoint(instance.address, instance.port); + + // TERRACONSTRUCTS DEVIATION: `secret` is only set here when a + // `DatabaseSecret` was just generated for us (see `renderInstanceCredentials`), + // in which case `masterUserPassword` above is the SAME + // `aws_secretsmanager_random_password` data-source token stored in that + // secret (`Secret._generatedPassword`). That data source regenerates a new + // value on every plan/refresh; without `ignore_changes` every apply after + // the first would drift `aws_db_instance.password` and REPLACE the live + // master password, permanently diverging it from the value frozen in the + // secret (mirrors `Secret.toTerraform()`'s `ignore_changes: ["secret_string"]` + // in `../../encryption/secret.ts`). Caller-supplied literal passwords + // (`credentials.password`) are NOT affected -- `secret` is undefined for + // those, so normal diffing/replacement semantics apply. + // + // Accumulated into a single array + single `addOverride()` call (mirroring + // `Secret.toTerraform()`'s `ignoreChanges` accumulation) rather than one `addOverride()` per + // entry -- `addOverride("lifecycle.ignore_changes", ...)` REPLACES the whole list, so multiple + // calls would clobber each other instead of merging. See `resource`'s doc comment. + const ignoreChanges: string[] = []; + if (secret) { + ignoreChanges.push("password"); + } + if (ignoreChanges.length > 0) { + instance.addOverride("lifecycle.ignore_changes", ignoreChanges); + } + + // Set up the secret reference + if (props.manageMasterUserPassword) { + this.secret = secretsmanager.Secret.fromSecretAttributes( + this, + "ManagedSecret", + { + secretCompleteArn: instance.masterUserSecret.get(0).secretArn, + encryptionKey: props.credentials?.encryptionKey, + }, + ); + } else if (secret) { + this.secret = secret.attach(this); + } + + this.createInstanceRoleAssociations( + instance.identifier, + this.instanceAssociatedRoles, + ); + } + + public get outputs(): Record { + return { + ...super.outputs, + ...(this.secret && { secretArn: this.secret.secretArn }), + }; + } +} + +/** + * Construction properties for a DatabaseInstanceFromSnapshot. + */ +export interface DatabaseInstanceFromSnapshotProps + extends DatabaseInstanceSourceProps { + /** + * The name or Amazon Resource Name (ARN) of the DB snapshot that's used to + * restore the DB instance. If you're restoring from a shared manual DB + * snapshot, you must specify the ARN of the snapshot. + * + * @default - None + */ + readonly snapshotIdentifier?: string; + + // TODO: omitted — the Terraform `aws_db_instance` resource has no argument for restoring from a + // Multi-AZ DB CLUSTER snapshot (only `snapshotIdentifier`, ported below, for a single-instance + // snapshot — verified against the full config shape in + // `node_modules/@cdktn/provider-aws/lib/db-instance/index.d.ts`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L1458-L1473 + // readonly clusterSnapshotIdentifier?: string; + + /** + * Master user credentials. + * + * Note - It is not possible to change the master username for a snapshot; + * however, it is possible to provide (or generate) a new password. + * + * @default - The existing username and password from the snapshot will be used. + */ + readonly credentials?: SnapshotCredentials; +} + +/** + * A database instance restored from a snapshot. + * + * @resource aws_db_instance + */ +export class DatabaseInstanceFromSnapshot + extends DatabaseInstanceSource + implements IDatabaseInstance +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.DatabaseInstanceFromSnapshot"; + + public readonly instanceIdentifier: string; + public readonly dbInstanceEndpointAddress: string; + public readonly dbInstanceEndpointPort: string; + public readonly instanceResourceId?: string; + public readonly instanceEndpoint: Endpoint; + public readonly secret?: secretsmanager.ISecret; + + /** + * The underlying `aws_db_instance` 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: dbInstance.DbInstance; + + constructor( + scope: Construct, + id: string, + props: DatabaseInstanceFromSnapshotProps, + ) { + super(scope, id, props); + + // TERRACONSTRUCTS DEVIATION: upstream also accepts `clusterSnapshotIdentifier` as an + // alternative to `snapshotIdentifier` — dropped, see the TODO on + // `DatabaseInstanceFromSnapshotProps.clusterSnapshotIdentifier` above. + if (!props.snapshotIdentifier) { + throw new ValidationError("You must specify `snapshotIdentifier`", this); + } + + const credentials = props.credentials; + if (credentials?.secret) { + // TERRACONSTRUCTS DEVIATION: see `renderInstanceCredentials` below — an existing, caller-supplied + // secret's password cannot be read back out portably (`secretValueFromJson` not ported). + throw new ValidationError( + "SnapshotCredentials with an existing `secret` are not supported in TerraConstructs (depends on ISecret.secretValueFromJson, not ported). Use SnapshotCredentials.fromPassword(), SnapshotCredentials.fromGeneratedSecret()/fromGeneratedPassword(), or leave `credentials` unset to keep the snapshot's existing password.", + this, + ); + } + + let secret: DatabaseSecret | undefined; + let generatedPassword: string | undefined; + if (credentials?.generatePassword) { + if (!credentials.username) { + throw new ValidationError( + "`credentials` `username` must be specified when `generatePassword` is set to true", + this, + ); + } + + secret = new DatabaseSecret(this, "Secret", { + username: credentials.username, + encryptionKey: credentials.encryptionKey, + excludeCharacters: credentials.excludeCharacters, + replaceOnPasswordCriteriaChanges: + credentials.replaceOnPasswordCriteriaChanges, + replicaRegions: credentials.replicaRegions, + }); + generatedPassword = secret._generatedPassword; + } + this.masterUsername = credentials?.username; + + const instance = new dbInstance.DbInstance(this, "Resource", { + ...this.sourceInstanceProps, + snapshotIdentifier: props.snapshotIdentifier, + password: generatedPassword ?? credentials?.password, + } as dbInstance.DbInstanceConfig); + + this.resource = instance; + this.instanceIdentifier = instance.identifier; + this.dbInstanceEndpointAddress = instance.address; + this.dbInstanceEndpointPort = Tokenization.stringifyNumber(instance.port); + this.instanceResourceId = instance.resourceId; + + this.instanceEndpoint = new Endpoint(instance.address, instance.port); + + // TERRACONSTRUCTS DEVIATION: see the identical `ignore_changes` note in + // `DatabaseInstance` above -- `secret` is only set here when a new + // `DatabaseSecret` was just generated (`credentials.generatePassword`), in + // which case `password` 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. A caller-supplied literal + // `credentials.password` (no `secret` created) keeps normal diffing. + // + // Accumulated into a single array + single `addOverride()` call -- see the identical note in + // `DatabaseInstance` above and `resource`'s doc comment. + const ignoreChanges: string[] = []; + if (secret) { + ignoreChanges.push("password"); + this.secret = secret.attach(this); + } + if (ignoreChanges.length > 0) { + instance.addOverride("lifecycle.ignore_changes", ignoreChanges); + } + + this.createInstanceRoleAssociations( + instance.identifier, + this.instanceAssociatedRoles, + ); + } + + public get outputs(): Record { + return { + ...super.outputs, + ...(this.secret && { secretArn: this.secret.secretArn }), + }; + } +} + +/** + * Construction properties for a DatabaseInstanceReadReplica. + */ +export interface DatabaseInstanceReadReplicaProps + extends DatabaseInstanceNewProps { + /** + * The name of the compute and memory capacity classes. + */ + readonly instanceType: ec2.InstanceType; + + /** + * The source database instance. + * + * Each DB instance can have a limited number of read replicas. For more + * information, see https://docs.aws.amazon.com/AmazonRDS/latest/DeveloperGuide/USER_ReadRepl.html. + */ + readonly sourceDatabaseInstance: IDatabaseInstance; + + /** + * Indicates whether the DB instance is encrypted. + * + * @default - true if storageEncryptionKey has been provided, false otherwise + */ + readonly storageEncrypted?: boolean; + + /** + * The KMS key that's used to encrypt the DB instance. + * + * TERRACONSTRUCTS DEVIATION: `encryption.IKey` instead of upstream's `kms.IKeyRef` — see + * `DatabaseInstanceNewProps.performanceInsightEncryptionKey` above. + * + * @default - default master key if storageEncrypted is true, no key otherwise + */ + readonly storageEncryptionKey?: encryption.IKey; + + /** + * The allocated storage size, specified in gibibytes (GiB). + * + * @default - The replica will inherit the allocated storage of the source database instance + */ + readonly allocatedStorage?: number; +} + +/** + * A read replica database instance. + * + * @resource aws_db_instance + */ +export class DatabaseInstanceReadReplica + extends DatabaseInstanceNew + implements IDatabaseInstance +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.DatabaseInstanceReadReplica"; + + public readonly instanceIdentifier: string; + public readonly dbInstanceEndpointAddress: string; + public readonly dbInstanceEndpointPort: string; + + /** + * The AWS Region-unique, immutable identifier for the DB instance. + * This identifier is found in AWS CloudTrail log entries whenever the AWS KMS key for the DB instance is accessed. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbinstance.html#aws-resource-rds-dbinstance-return-values + */ + public readonly instanceResourceId?: string; + public readonly instanceEndpoint: Endpoint; + public readonly engine?: IInstanceEngine = undefined; + protected readonly instanceType: ec2.InstanceType; + + public readonly resource: dbInstance.DbInstance; + + constructor( + scope: Construct, + id: string, + props: DatabaseInstanceReadReplicaProps, + ) { + super(scope, id, props); + + if ( + props.sourceDatabaseInstance.engine && + !props.sourceDatabaseInstance.engine.supportsReadReplicaBackups && + props.backupRetention + ) { + throw new ValidationError( + `Cannot set 'backupRetention', as engine '${engineDescription(props.sourceDatabaseInstance.engine)}' does not support automatic backups for read replicas`, + this, + ); + } + + const engineType = props.sourceDatabaseInstance.engine?.engineType; + if ( + engineType && + props.engineLifecycleSupport && + !["mysql", "postgres"].includes(engineType) + ) { + throw new ValidationError( + `'engineLifecycleSupport' can only be specified for RDS for MySQL and RDS for PostgreSQL, got: '${engineType}'`, + this, + ); + } + + // The read replica instance always uses the same engine as the source instance + // but some CF validations require the engine to be explicitly passed when some + // properties are specified. + const shouldPassEngine = props.domain != null; + + const instance = new dbInstance.DbInstance(this, "Resource", { + ...this.newInstanceProps, + // this must be ARN, not ID, because of https://github.com/terraform-providers/terraform-provider-aws/issues/528#issuecomment-391169012 + replicateSourceDb: props.sourceDatabaseInstance.instanceArn, + kmsKeyId: props.storageEncryptionKey?.keyArn, + storageEncrypted: props.storageEncryptionKey + ? true + : props.storageEncrypted, + engine: shouldPassEngine ? engineType : undefined, + allocatedStorage: props.allocatedStorage, + } as dbInstance.DbInstanceConfig); + + this.resource = instance; + this.instanceType = props.instanceType; + this.instanceIdentifier = instance.identifier; + this.dbInstanceEndpointAddress = instance.address; + this.dbInstanceEndpointPort = Tokenization.stringifyNumber(instance.port); + + // TERRACONSTRUCTS DEVIATION: upstream branches on the + // `USE_CORRECT_VALUE_FOR_INSTANCE_RESOURCE_ID_PROPERTY` feature flag between + // `attrDbiResourceId` (correct) and `attrDbInstanceArn` (legacy/incorrect, kept only for + // backward compatibility with already-deployed CFN stacks predating the fix). There is no + // legacy template to stay compatible with here, so only the correct value is used. + this.instanceResourceId = instance.resourceId; + + this.instanceEndpoint = new Endpoint(instance.address, instance.port); + } +} + +/** + * TERRACONSTRUCTS DEVIATION: replaces upstream's `renderCredentials` (`./private/util.ts`), which + * is commented out there because it depends on `Credentials.fromSecret` (itself commented out in + * `./props.ts` — needs `ISecret.secretValueFromJson`, not portable). This local equivalent produces + * the same three pieces of information (username / password token / owned secret) directly, using + * `Secret._generatedPassword` (see the TERRACONSTRUCTS DEVIATION on that getter in + * `../../encryption/secret.ts`) instead of a dynamic-reference `SecretValue`. + */ +function renderInstanceCredentials( + scope: Construct, + engine: IInstanceEngine, + credentials?: Credentials, +): { + username: string; + password?: string; + secret?: secretsmanager.ISecret; +} { + const rendered = + credentials ?? Credentials.fromUsername(engine.defaultUsername ?? "admin"); + + if (rendered.secret) { + // TERRACONSTRUCTS DEVIATION: upstream also supports `Credentials.fromSecret(existingSecret)` + // here (an *existing*, caller-supplied secret). That factory is commented out in `./props.ts` + // (needs `secretValueFromJson`), so `rendered.secret` can only be non-undefined here if a + // caller hand-builds a `Credentials`-shaped object literal (TS structural typing permits this + // even with the factory unavailable). There's no portable way to pull a plaintext password out + // of an arbitrary existing secret, so this is rejected explicitly instead of silently producing + // a DB instance with an unset password. + throw new ValidationError( + "Credentials with an existing `secret` are not supported in TerraConstructs (depends on ISecret.secretValueFromJson, not ported). Use Credentials.fromPassword(), Credentials.fromUsername(), or leave `credentials` unset to auto-generate a DatabaseSecret.", + scope, + ); + } + + if (rendered.password) { + return { username: rendered.username, password: rendered.password }; + } + + const secret = new DatabaseSecret(scope, "Secret", { + username: rendered.username, + secretName: rendered.secretName, + encryptionKey: rendered.encryptionKey, + excludeCharacters: rendered.excludeCharacters, + replaceOnPasswordCriteriaChanges: credentials?.usernameAsString, + replicaRegions: rendered.replicaRegions, + }); + + return { + username: rendered.username, + password: secret._generatedPassword, + secret, + }; +} + +function defaultIops( + storageType: StorageType, + iops?: number, +): number | undefined { + switch (storageType) { + case StorageType.STANDARD: + case StorageType.GP2: + return undefined; + case StorageType.GP3: + return iops; + case StorageType.IO1: + case StorageType.IO2: + return iops ?? 1000; + } +} + +// TODO: omitted — see the TODO on `ProcessorFeatures` above; the provider has no argument to render +// these into — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L1686-L1695 +// /** +// * Renders the processor features specifications +// * +// * @param features the processor features +// */ +// function renderProcessorFeatures(features: ProcessorFeatures): CfnDBInstance.ProcessorFeatureProperty[] | undefined { +// const featuresList = Object.entries(features).map(([name, value]) => ({ name, value: value.toString() })); +// +// return featuresList.length === 0 ? undefined : featuresList; +// } diff --git a/src/aws/storage/rds/validate-database-insights.ts b/src/aws/storage/rds/validate-database-insights.ts index 32522781..03d75151 100644 --- a/src/aws/storage/rds/validate-database-insights.ts +++ b/src/aws/storage/rds/validate-database-insights.ts @@ -1,14 +1,6 @@ // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/validate-database-insights.ts -// TODO: omitted — entire file depends on DatabaseCluster/DatabaseClusterProps (./cluster), -// DatabaseInstance/DatabaseInstanceProps (./instance), and PerformanceInsightRetention (./props), -// none of which are ported in this slice (they land in later RDS PRs: 2c DatabaseInstance, -// 2d DatabaseCluster). Re-enable verbatim once those files land. -// — https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/validate-database-insights.ts -/* import type { Construct } from "constructs"; -import type { DatabaseClusterProps } from "./cluster"; -import { ClusterScailabilityType, DatabaseCluster, DBClusterStorageType } from "./cluster"; import { DatabaseInsightsMode } from "./database-insights-mode"; import type { DatabaseInstanceProps } from "./instance"; import { DatabaseInstance } from "./instance"; @@ -16,6 +8,13 @@ import { PerformanceInsightRetention } from "./props"; import type { ValidationRule } from "../../../helpers-internal"; import { validateAllProps } from "../../../helpers-internal"; +// TODO: omitted — `DatabaseClusterProps`/`DatabaseCluster`/`ClusterScailabilityType`/ +// `DBClusterStorageType` (./cluster) are not ported in this slice (lands in RDS PR 2d). The +// cluster-specific rule sets (`clusterSpecificRules`, `limitlessDatabaseRules`) and +// `validateDatabaseClusterProps` below are left commented out until then; only the +// instance-applicable `databaseInsightsRules`/`validateDatabaseInstanceProps` are reinstated here — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/validate-database-insights.ts#L1-L90 + // Common validation rules for database insights const databaseInsightsRules: ValidationRule[] = [ { @@ -24,68 +23,85 @@ const databaseInsightsRules: ValidationRule[] = [ (props.performanceInsightRetention !== undefined || props.performanceInsightEncryptionKey !== undefined || props.databaseInsightsMode === DatabaseInsightsMode.ADVANCED), - message: () => '`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set, or `databaseInsightsMode` was set to \'${DatabaseInsightsMode.ADVANCED}\'', + message: () => + "`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set, or `databaseInsightsMode` was set to '${DatabaseInsightsMode.ADVANCED}'", }, { - condition: (props) => props.databaseInsightsMode === DatabaseInsightsMode.ADVANCED && - props.performanceInsightRetention !== PerformanceInsightRetention.MONTHS_15, - message: () => '`performanceInsightRetention` must be set to \'${PerformanceInsightRetention.MONTHS_15}\' when `databaseInsightsMode` is set to \'${DatabaseInsightsMode.ADVANCED}\'', + condition: (props) => + props.databaseInsightsMode === DatabaseInsightsMode.ADVANCED && + props.performanceInsightRetention !== + PerformanceInsightRetention.MONTHS_15, + message: () => + "`performanceInsightRetention` must be set to '${PerformanceInsightRetention.MONTHS_15}' when `databaseInsightsMode` is set to '${DatabaseInsightsMode.ADVANCED}'", }, ]; -// Cluster-specific validation rules -const clusterSpecificRules: ValidationRule[] = [ - { - condition: (props) => props.replicationSourceIdentifier !== undefined && props.credentials !== undefined, - message: () => "Cannot specify both `replicationSourceIdentifier` and `credentials`. The value is inherited from the source DB cluster", - }, -]; - -// Rules for Aurora Limitless database -const limitlessDatabaseRules: ValidationRule[] = [ - { - condition: (props) => !props.enablePerformanceInsights, - message: () => "Performance Insights must be enabled for Aurora Limitless Database", - }, - { - condition: (props) => !props.performanceInsightRetention - || props.performanceInsightRetention < PerformanceInsightRetention.MONTHS_1, - message: () => "Performance Insights retention period must be set to at least 31 days for Aurora Limitless Database", - }, - { - condition: (props) => !props.monitoringInterval || !props.enableClusterLevelEnhancedMonitoring, - message: () => "Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set 'monitoringInterval' and enable 'enableClusterLevelEnhancedMonitoring'", - }, - { - condition: (props) => !!(props.writer || props.readers), - message: () => "Aurora Limitless Database does not support reader or writer instances", - }, - { - condition: (props) => !props.engine.engineVersion?.fullVersion?.endsWith("limitless"), - message: (props) => `Aurora Limitless Database requires an engine version that supports it, got: ${props.engine.engineVersion?.fullVersion}`, - }, - { - condition: (props) => props.storageType !== DBClusterStorageType.AURORA_IOPT1, - message: (props) => `Aurora Limitless Database requires I/O optimized storage type, got: ${props.storageType}`, - }, - { - condition: (props) => props.cloudwatchLogsExports === undefined || props.cloudwatchLogsExports.length === 0, - message: () => "Aurora Limitless Database requires CloudWatch Logs exports to be set", - }, -]; +// TODO: omitted — cluster-specific validation rules; depend on `DatabaseClusterProps` (not ported +// in this slice, lands in RDS PR 2d) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/validate-database-insights.ts#L36-L75 +// // Cluster-specific validation rules +// const clusterSpecificRules: ValidationRule[] = [ +// { +// condition: (props) => props.replicationSourceIdentifier !== undefined && props.credentials !== undefined, +// message: () => "Cannot specify both `replicationSourceIdentifier` and `credentials`. The value is inherited from the source DB cluster", +// }, +// ]; +// +// // Rules for Aurora Limitless database +// const limitlessDatabaseRules: ValidationRule[] = [ +// { +// condition: (props) => !props.enablePerformanceInsights, +// message: () => "Performance Insights must be enabled for Aurora Limitless Database", +// }, +// { +// condition: (props) => !props.performanceInsightRetention +// || props.performanceInsightRetention < PerformanceInsightRetention.MONTHS_1, +// message: () => "Performance Insights retention period must be set to at least 31 days for Aurora Limitless Database", +// }, +// { +// condition: (props) => !props.monitoringInterval || !props.enableClusterLevelEnhancedMonitoring, +// message: () => "Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set 'monitoringInterval' and enable 'enableClusterLevelEnhancedMonitoring'", +// }, +// { +// condition: (props) => !!(props.writer || props.readers), +// message: () => "Aurora Limitless Database does not support reader or writer instances", +// }, +// { +// condition: (props) => !props.engine.engineVersion?.fullVersion?.endsWith("limitless"), +// message: (props) => `Aurora Limitless Database requires an engine version that supports it, got: ${props.engine.engineVersion?.fullVersion}`, +// }, +// { +// condition: (props) => props.storageType !== DBClusterStorageType.AURORA_IOPT1, +// message: (props) => `Aurora Limitless Database requires I/O optimized storage type, got: ${props.storageType}`, +// }, +// { +// condition: (props) => props.cloudwatchLogsExports === undefined || props.cloudwatchLogsExports.length === 0, +// message: () => "Aurora Limitless Database requires CloudWatch Logs exports to be set", +// }, +// ]; // Validates database instance properties -export function validateDatabaseInstanceProps(scope: Construct, props: DatabaseInstanceProps): void { - validateAllProps(scope, DatabaseInstance.name, props, databaseInsightsRules as ValidationRule[]); +export function validateDatabaseInstanceProps( + scope: Construct, + props: DatabaseInstanceProps, +): void { + validateAllProps( + scope, + DatabaseInstance.name, + props, + databaseInsightsRules as ValidationRule[], + ); } -// Validates database cluster properties -export function validateDatabaseClusterProps(scope: Construct, props: DatabaseClusterProps): void { - const isLimitlessCluster = props.clusterScailabilityType === ClusterScailabilityType.LIMITLESS; - const applicableRules = isLimitlessCluster - ? [...databaseInsightsRules as ValidationRule[], ...clusterSpecificRules, ...limitlessDatabaseRules] - : [...databaseInsightsRules as ValidationRule[], ...clusterSpecificRules]; - - validateAllProps(scope, DatabaseCluster.name, props, applicableRules); -} -*/ +// TODO: omitted — depends on `DatabaseClusterProps`/`DatabaseCluster` (not ported in this slice, +// lands in RDS PR 2d) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/validate-database-insights.ts#L82-L90 +// // Validates database cluster properties +// export function validateDatabaseClusterProps(scope: Construct, props: DatabaseClusterProps): void { +// const isLimitlessCluster = props.clusterScailabilityType === ClusterScailabilityType.LIMITLESS; +// const applicableRules = isLimitlessCluster +// ? [...databaseInsightsRules as ValidationRule[], ...clusterSpecificRules, ...limitlessDatabaseRules] +// : [...databaseInsightsRules as ValidationRule[], ...clusterSpecificRules]; +// +// validateAllProps(scope, DatabaseCluster.name, props, applicableRules); +// } diff --git a/test/aws/storage/rds/__snapshots__/instance.test.ts.snap b/test/aws/storage/rds/__snapshots__/instance.test.ts.snap new file mode 100644 index 00000000..ef009ba5 --- /dev/null +++ b/test/aws/storage/rds/__snapshots__/instance.test.ts.snap @@ -0,0 +1,415 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`instance create a DB instance 1`] = ` +"{ + "data": { + "aws_availability_zones": { + "AvailabilityZones": { + "provider": "aws" + } + }, + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_iam_policy_document": { + "Instance_MonitoringRole_AssumeRolePolicy_632D7D7B": { + "statement": [ + { + "actions": [ + "sts:AssumeRole" + ], + "effect": "Allow", + "principals": [ + { + "identifiers": [ + "\${data.aws_service_principal.aws_svcp_default_region_monitoringrdsamazonawscom.name}" + ], + "type": "Service" + } + ] + } + ] + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + }, + "aws_secretsmanager_random_password": { + "Instance_Secret_RandomPassword_930AA29C": { + "exclude_characters": "\\"@/\\\\", + "exclude_lowercase": false, + "exclude_numbers": false, + "exclude_punctuation": false, + "exclude_uppercase": false, + "include_space": false, + "password_length": 30, + "require_each_included_type": true + } + }, + "aws_service_principal": { + "aws_svcp_default_region_monitoringrdsamazonawscom": { + "service_name": "monitoring.rds.amazonaws.com" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_db_instance": { + "Instance_C1063A87": { + "allocated_storage": 100, + "auto_minor_version_upgrade": false, + "backup_retention_period": 7, + "copy_tags_to_snapshot": true, + "db_name": "ORCL", + "db_subnet_group_name": "\${aws_db_subnet_group.Instance_SubnetGroup_F2CBA54F.name}", + "enabled_cloudwatch_logs_exports": [ + "trace", + "audit", + "alert", + "listener" + ], + "engine": "oracle-se2", + "engine_version": "19.0.0.0.ru-2020-04.rur-2020-04.r1", + "identifier": "mystackinstancec8b5f353", + "instance_class": "db.t2.medium", + "iops": 1000, + "license_model": "bring-your-own-license", + "lifecycle": { + "ignore_changes": [ + "password" + ] + }, + "monitoring_interval": 60, + "monitoring_role_arn": "\${aws_iam_role.Instance_MonitoringRole_3E2B4286.arn}", + "multi_az": true, + "password": "\${data.aws_secretsmanager_random_password.Instance_Secret_RandomPassword_930AA29C.random_password}", + "performance_insights_enabled": true, + "performance_insights_retention_period": 7, + "storage_encrypted": true, + "storage_type": "io1", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "username": "syscdk", + "vpc_security_group_ids": [ + "\${aws_security_group.Instance_SecurityGroup_B4E5FA83.id}" + ] + } + }, + "aws_db_subnet_group": { + "Instance_SubnetGroup_F2CBA54F": { + "description": "Subnet group for Instance database", + "name": "mystackinstancesubnetgroup4dc37c5c", + "subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ], + "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_iam_role": { + "Instance_MonitoringRole_3E2B4286": { + "assume_role_policy": "\${data.aws_iam_policy_document.Instance_MonitoringRole_AssumeRolePolicy_632D7D7B.json}", + "name_prefix": "a123e4567-e89b-12d3tanceMonitoringRole", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_iam_role_policy_attachment": { + "Instance_MonitoringPolicy_Roles0_8DFE1E88": { + "policy_arn": "arn:\${data.aws_partition.Partitition.partition}:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole", + "role": "\${aws_iam_role.Instance_MonitoringRole_3E2B4286.name}" + } + }, + "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_secretsmanager_secret": { + "Instance_Secret_478E0A47": { + "description": "Generated by the CDK for stack: MyStack", + "name": "MyStackInstanceSecretF7BF4815", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_secretsmanager_secret_version": { + "Instance_Secret_SecretVersion_AE6914F2": { + "lifecycle": { + "ignore_changes": [ + "secret_string" + ] + }, + "secret_id": "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}", + "secret_string": "\${jsonencode(merge(jsondecode(jsonencode({\\"username\\" = \\"syscdk\\", \\"password\\" = data.aws_secretsmanager_random_password.Instance_Secret_RandomPassword_930AA29C.random_password})), {\\"dbInstanceIdentifier\\" = aws_db_instance.Instance_C1063A87.identifier, \\"engine\\" = \\"oracle-se2\\", \\"host\\" = aws_db_instance.Instance_C1063A87.address, \\"port\\" = aws_db_instance.Instance_C1063A87.port, \\"dbname\\" = \\"ORCL\\"}))}" + } + }, + "aws_security_group": { + "Instance_SecurityGroup_B4E5FA83": { + "description": "Security group for Instance database", + "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-12d3MyStackInstanceSecurityGroup981C92B4", + "tags": { + "Name": "Test-Instance", + "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/rds/instance.test.ts b/test/aws/storage/rds/instance.test.ts new file mode 100644 index 00000000..2babbfdc --- /dev/null +++ b/test/aws/storage/rds/instance.test.ts @@ -0,0 +1,2815 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts + +import { + dbInstance, + dbInstanceRoleAssociation, + dbSubnetGroup, + securityGroup, + vpcSecurityGroupIngressRule, + vpcSecurityGroupEgressRule, + iamRole, + dataAwsIamPolicyDocument, + secretsmanagerSecret, + secretsmanagerSecretVersion, + dataAwsSecretsmanagerRandomPassword, + secretsmanagerSecretRotation, + serverlessapplicationrepositoryCloudformationStack, + cloudwatchEventRule, +} from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { Construct } from "constructs"; +import { ArnFormat, AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import { + ManagedPolicy, + Role, + ServicePrincipal, + AccountPrincipal, + CompositePrincipal, +} from "../../../../src/aws/iam"; +import { IRuleTarget } from "../../../../src/aws/notify"; +import { Bucket } from "../../../../src/aws/storage/bucket"; +import * as rds from "../../../../src/aws/storage/rds"; +import { Duration } from "../../../../src/duration"; +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; +let vpc: compute.IVpc; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); +}); + +describe("instance", () => { + test("create a DB instance", () => { + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.oracleSe2({ + version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1, + }), + licenseModel: rds.LicenseModel.BRING_YOUR_OWN_LICENSE, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.MEDIUM, + ), + multiAz: true, + storageType: rds.StorageType.IO1, + credentials: rds.Credentials.fromUsername("syscdk", { + excludeCharacters: '"@/\\', + }), + vpc, + databaseName: "ORCL", + storageEncrypted: true, + backupRetention: Duration.days(7), + monitoringInterval: Duration.minutes(1), + enablePerformanceInsights: true, + cloudwatchLogsExports: ["trace", "audit", "alert", "listener"], + // TODO: omitted — upstream also passes `cloudwatchLogsRetention: + // logs.RetentionDays.ONE_MONTH` here and then asserts + // `resourceCountIs('Custom::LogRetention', 4)`. Lambda-backed per-export log retention + // (`aws-logs` `LogRetention` custom resource) is not portable to a Terraform-native + // `enabled_cloudwatch_logs_exports` list -- see the "CloudWatch log exports" rule in the + // slice plan (same omission as the neptune plan) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L47 + autoMinorVersionUpgrade: false, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + instance_class: "db.t2.medium", + allocated_storage: 100, + auto_minor_version_upgrade: false, + backup_retention_period: 7, + copy_tags_to_snapshot: true, + db_name: "ORCL", + db_subnet_group_name: expect.any(String), + enabled_cloudwatch_logs_exports: ["trace", "audit", "alert", "listener"], + performance_insights_enabled: true, + engine: "oracle-se2", + engine_version: "19.0.0.0.ru-2020-04.rur-2020-04.r1", + iops: 1000, + license_model: "bring-your-own-license", + username: expect.any(String), + password: expect.any(String), + monitoring_interval: 60, + monitoring_role_arn: expect.any(String), + multi_az: true, + performance_insights_retention_period: 7, + storage_encrypted: true, + storage_type: "io1", + }); + + t.expect.toHaveResourceWithProperties(dbSubnetGroup.DbSubnetGroup, { + description: "Subnet group for Instance database", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }); + + t.expect.toHaveResourceWithProperties(securityGroup.SecurityGroup, { + description: "Security group for Instance database", + }); + + t.resourceCountIs(iamRole.IamRole, 1); + + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + description: expect.stringMatching(/^Generated by the CDK for stack: /), + }, + ); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + password_length: 30, + exclude_characters: '"@/\\', + }, + ); + t.expectResources( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + ).toHaveLength(1); + }); + + test.each([ + [rds.StorageType.STANDARD, "standard", 2000, undefined], + [rds.StorageType.GP2, "gp2", 2000, undefined], + [rds.StorageType.GP3, "gp3", 2000, 2000], + [rds.StorageType.IO1, "io1", 2000, 2000], + [rds.StorageType.IO1, "io1", undefined, 1000], + [rds.StorageType.IO2, "io2", 2000, 2000], + [rds.StorageType.IO2, "io2", undefined, 1000], + ])( + "storage type and IOPS for %s storage type", + (inStorageType, outStorageType, inIops, outIops) => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_30, + }), + vpc, + storageType: inStorageType, + iops: inIops, + }); + + const t = new Template(stack); + // `objectContaining` cannot assert key absence (it requires the key to + // be present with value `undefined`), so only pass `iops` through when + // it is expected to be set, and check the raw resource otherwise. + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + storage_type: outStorageType, + ...(outIops !== undefined && { iops: outIops }), + }); + if (outIops === undefined) { + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.iops).toBeUndefined(); + } + }, + ); + + test("throws when create database with specific AZ and multiAZ enabled", () => { + expect(() => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + multiAz: true, + availabilityZone: "ew-west-1a", + }); + }).toThrow( + /Requesting a specific availability zone is not valid for Multi-AZ instances/, + ); + }); + + test("instance with option and parameter group", () => { + const optionGroup = new rds.OptionGroup(stack, "OptionGroup", { + engine: rds.DatabaseInstanceEngine.oracleSe2({ + version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1, + }), + configurations: [{ name: "XMLDB" }], + }); + + const parameterGroup = new rds.ParameterGroup(stack, "ParameterGroup", { + engine: rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_11, + }), + description: "desc", + parameters: { key: "value" }, + }); + + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + optionGroup, + parameterGroup, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + parameter_group_name: stack.resolve( + parameterGroup.bindToInstance({}).parameterGroupName, + ), + option_group_name: stack.resolve(optionGroup.optionGroupName), + }); + }); + + test("instance with inline parameter group", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_11, + }), + vpc, + parameters: { locks: "100" }, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + parameter_group_name: expect.any(String), + }); + // TERRACONSTRUCTS DEVIATION: this repo's `dbParameterGroup.DbParameterGroup` `family` field + // name is namespaced with the `T1DbParameterGroup` provider resource type import; see + // parameter-group.test.ts for the same assertion pattern. + }); + + test("instance with inline parameter group and parameterGroup arg fails", () => { + const parameterGroup = new rds.ParameterGroup(stack, "ParameterGroup", { + engine: rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_11, + }), + parameters: { key: "value" }, + }); + + expect(() => { + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_11, + }), + vpc, + parameters: { locks: "100" }, + parameterGroup, + }); + }).toThrow(/You cannot specify both parameterGroup and parameters/); + }); + + test("can specify subnet type", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + credentials: rds.Credentials.fromUsername("syscdk"), + vpc, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS }, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + db_subnet_group_name: expect.any(String), + publicly_accessible: false, + }); + t.expect.toHaveResourceWithProperties(dbSubnetGroup.DbSubnetGroup, { + description: "Subnet group for Instance database", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }); + }); + + test("instance with IPv4 network type", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + networkType: rds.NetworkType.IPV4, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + network_type: "IPV4", + }); + }); + + // TODO: omitted — upstream's "instance with cloudwatchLogsExports" / "instance replica with + // cloudwatchLogsExports" / "instance snapshot with cloudwatchLogsExports" all assert + // `instance.cloudwatchLogGroups[...]` (a map of `logs.ILogGroup` built by the Lambda-backed + // `LogRetention` custom resource per exported log type -- `cloudwatchLogsRetention`). That + // per-export log-group/retention machinery has no Terraform-native equivalent to + // `enabled_cloudwatch_logs_exports` (a fire-and-forget list -- RDS creates the underlying + // CloudWatch Log Groups itself, with no retention control) -- see the "CloudWatch log exports" + // rule in the slice plan. The `enabled_cloudwatch_logs_exports` property itself IS ported and + // exercised in "create a DB instance" above; only the `cloudwatchLogGroups` getter and its + // backing `cloudwatchLogsRetention` prop are omitted here — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L363-L432 + // test('instance with cloudwatchLogsExports', () => { ... }); + // test('instance replica with cloudwatchLogsExports', () => { ... }); + // test('instance snapshot with cloudwatchLogsExports', () => { ... }); + + test("instance with dual-stack network type", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + networkType: rds.NetworkType.DUAL, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + network_type: "DUAL", + }); + }); + + test.each([[true], [false]])( + "instance with applyImmediately set to %s", + (applyImmediately) => { + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE4_GRAVITON, + compute.InstanceSize.SMALL, + ), + vpc, + applyImmediately, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + apply_immediately: applyImmediately, + }); + }, + ); + + describe("DatabaseInstanceFromSnapshot", () => { + test("create an instance from snapshot", () => { + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.LARGE, + ), + vpc, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + snapshot_identifier: "my-snapshot", + }); + }); + + test("can generate a new snapshot password", () => { + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + credentials: rds.SnapshotCredentials.fromGeneratedSecret("admin", { + excludeCharacters: '"@/\\', + }), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + password: expect.any(String), + }); + // `username` is not settable when restoring from a snapshot -- the + // matcher's `objectContaining` cannot assert key absence (it requires + // the key to be present), so check the raw synthesized resource instead. + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.username).toBeUndefined(); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + password_length: 30, + exclude_characters: '"@/\\', + }, + ); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + description: expect.stringMatching( + /^Generated by the CDK for stack: /, + ), + }, + ); + }); + + test("fromGeneratedSecret with replica regions", () => { + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + credentials: rds.SnapshotCredentials.fromGeneratedSecret("admin", { + replicaRegions: [{ region: "eu-west-1" }], + }), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + replica: [{ region: "eu-west-1" }], + }, + ); + }); + + test("throws if generating a new password without a username", () => { + expect( + () => + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + credentials: { generatePassword: true }, + }), + ).toThrow( + /`credentials` `username` must be specified when `generatePassword` is set to true/, + ); + }); + + test("can set a new snapshot password from an existing plain password", () => { + // TERRACONSTRUCTS DEVIATION: upstream passes `cdk.SecretValue.unsafePlainText(...)`; + // `core.SecretValue` is not ported (see the DEVIATION note at the top of `props.ts`), so + // `SnapshotCredentials.fromPassword` here takes a plain string directly — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L538-L551 + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + credentials: rds.SnapshotCredentials.fromPassword("mysecretpassword"), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + password: "mysecretpassword", + }); + }); + + // TODO: omitted — `SnapshotCredentials.fromSecret()` depends on `ISecret.secretValueFromJson`, + // which is not ported in this repo (commented out in `../../../../src/aws/storage/rds/props.ts` + // — see the TERRACONSTRUCTS DEVIATION note on `ISecret` in `../../../../src/aws/encryption/secret.ts`). + // Reinstate once that capability lands — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L553-L571 + // test('can set a new snapshot password from an existing Secret', () => { ... }); + + test("can create a new database instance with fromDatabaseInstanceAttributes using a token for the port", () => { + // GIVEN + const databasePort = new TerraformVariable(stack, "DatabasePort", { + type: "number", + default: 5432, + }); + + // WHEN + const instance = rds.DatabaseInstance.fromDatabaseInstanceAttributes( + stack, + "DatabaseInstance", + { + instanceIdentifier: "", + securityGroups: [], + instanceEndpointAddress: "", + port: databasePort.numberValue, + }, + ); + + // THEN + expect(stack.resolve(instance.dbInstanceEndpointPort)).toEqual( + stack.resolve(`${databasePort.numberValue}`), + ); + }); + + // TERRACONSTRUCTS DEVIATION: `DatabaseInstanceBase.fromLookup()` throws instead of performing + // a CDK-CLI context-provider lookup (no CDKTF-native equivalent) — see the TERRACONSTRUCTS + // DEVIATION note on `fromLookup` in `../../../../src/aws/storage/rds/instance.ts`. + test("fromLookup throws because context-provider lookups are not supported in TerraConstructs", () => { + expect(() => + rds.DatabaseInstanceBase.fromLookup(stack, "Lookup", { + instanceIdentifier: "my-instance", + }), + ).toThrow(/not supported in TerraConstructs/); + }); + + // TODO: omitted — `DBClusterSnapshotIdentifier` (restoring a DB instance from an Aurora + // cluster snapshot) has no equivalent in the Terraform `aws_db_instance` resource schema + // (only `snapshot_identifier`, for restoring from a DB *instance* snapshot, is exposed -- + // see `node_modules/@cdktn/provider-aws/lib/db-instance/index.d.ts`). `clusterSnapshotIdentifier` + // is therefore dropped from `DatabaseInstanceFromSnapshotProps` entirely, along with its + // mutual-exclusivity validation against `snapshotIdentifier` — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L602-L629 + // test('create an instance from clusterSnapshotIdentifier', () => { ... }); + // test('throws when both snapshotIdentifier and clusterSnapshotIdentifier specified', () => { ... }); + // test('throws when none of snapshotIdentifier or clusterSnapshotIdentifier specified', () => { ... }); + }); + + test("create a read replica in the same region - with the subnet group name", () => { + const sourceInstance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + }); + + // WHEN + new rds.DatabaseInstanceReadReplica(stack, "ReadReplica", { + sourceDatabaseInstance: sourceInstance, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.LARGE, + ), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + replicate_source_db: stack.resolve(sourceInstance.instanceArn), + db_subnet_group_name: expect.any(String), + }); + }); + + describe("events", () => { + const mockTarget: IRuleTarget = { + bind: () => ({ arn: "ARN", id: "" }), + }; + + test("on event", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + }); + + // WHEN + instance.onEvent("InstanceEvent", { target: mockTarget }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + cloudwatchEventRule.CloudwatchEventRule, + { + event_pattern: stack.resolve( + stack.toJsonString({ + source: ["aws.rds"], + resources: [instance.instanceArn], + }), + ), + }, + ); + }); + + test("on event without target", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + }); + + // WHEN + instance.onEvent("InstanceEvent"); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + cloudwatchEventRule.CloudwatchEventRule, + { + event_pattern: stack.resolve( + stack.toJsonString({ + source: ["aws.rds"], + resources: [instance.instanceArn], + }), + ), + }, + ); + }); + }); + + // TODO: omitted — `metricCPUUtilization()` / `metricReadIOPS()` / `metricWriteIOPS()` (and every + // other `metric*()` convenience method on `IDatabaseInstance`) come from + // `rds-augmentations.generated.ts`, which lands in the NEXT PR (not hand-written here). Reinstate + // once that generated file is ported — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L770-L819 + // test('can use metricCPUUtilization', () => { ... }); + // test('can use metricReadIOPS', () => { ... }); + // test('can use metricWriteIOPS', () => { ... }); + + test("can resolve endpoint port and socket address", () => { + // WHEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + }); + + expect(stack.resolve(instance.instanceEndpoint.port)).toEqual( + stack.resolve(instance.resource.port), + ); + + expect(stack.resolve(instance.instanceEndpoint.socketAddress)).toEqual( + stack.resolve( + `${instance.instanceEndpoint.hostname}:${instance.instanceEndpoint.port}`, + ), + ); + }); + + test("can deactivate backup", () => { + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + backupRetention: Duration.seconds(0), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + backup_retention_period: 0, + }); + }); + + test("imported instance with imported security group with allowAllOutbound set to false", () => { + const instance = rds.DatabaseInstance.fromDatabaseInstanceAttributes( + stack, + "Database", + { + instanceEndpointAddress: "address", + instanceIdentifier: "identifier", + port: 3306, + securityGroups: [ + compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { allowAllOutbound: false }, + ), + ], + }, + ); + + // WHEN + instance.connections.allowToAnyIpv4(compute.Port.tcp(443)); + + // THEN + const t = new Template(stack); + // TERRACONSTRUCTS DEVIATION: `AWS::EC2::SecurityGroupEgress` maps onto the + // `aws_vpc_security_group_egress_rule` resource (see `src/aws/compute/security-group.ts`). + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupEgressRule.VpcSecurityGroupEgressRule, + { + security_group_id: "sg-123456789", + }, + ); + }); + + test("create an instance with imported monitoring role", () => { + const monitoringRole = new Role(stack, "MonitoringRole", { + assumedBy: new ServicePrincipal("monitoring.rds.amazonaws.com"), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName( + stack, + "MonitoringPolicy", + "service-role/AmazonRDSEnhancedMonitoringRole", + ), + ], + }); + + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + monitoringInterval: Duration.minutes(1), + monitoringRole, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + monitoring_interval: 60, + monitoring_role_arn: stack.resolve(monitoringRole.roleArn), + }); + }); + + test("create an instance with an existing security group", () => { + const sg = compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { allowAllOutbound: false }, + ); + + // WHEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + securityGroups: [sg], + }); + instance.connections.allowDefaultPortFromAnyIpv4(); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + vpc_security_group_ids: ["sg-123456789"], + }); + + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupIngressRule.VpcSecurityGroupIngressRule, + { + security_group_id: "sg-123456789", + }, + ); + }); + + test("addRotationSingleUser()", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + }); + + // WHEN + instance.addRotationSingleUser(); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + { + rotation_rules: { schedule_expression: "rate(30 days)" }, + }, + ); + t.expect.toHaveResourceWithProperties( + serverlessapplicationrepositoryCloudformationStack.ServerlessapplicationrepositoryCloudformationStack, + { + application_id: expect.stringContaining( + "SecretsManagerRDSPostgreSQLRotationSingleUser", + ), + }, + ); + }); + + test("addRotationMultiUser()", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + }); + + // WHEN + const userSecret = new rds.DatabaseSecret(stack, "UserSecret", { + username: "user", + }); + instance.addRotationMultiUser("user", { + secret: userSecret.attach(instance), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + serverlessapplicationrepositoryCloudformationStack.ServerlessapplicationrepositoryCloudformationStack, + { + application_id: expect.stringContaining( + "SecretsManagerRDSPostgreSQLRotationMultiUser", + ), + parameters: expect.objectContaining({ + masterSecretArn: stack.resolve(instance.secret!.secretArn), + }), + }, + ); + }); + + test("addRotationSingleUser() with custom automaticallyAfter, excludeCharacters, vpcSubnets and securityGroup", () => { + // GIVEN + // The shared `vpc` fixture (from `beforeEach`) has no isolated subnets -- + // build a dedicated VPC with public/private-with-egress/isolated subnets + // so the instance can sit in an isolated subnet while rotation runs in a + // NAT-backed private subnet. + const vpcWithIsolated = new compute.Vpc(stack, "VpcWithIsolated", { + maxAzs: 2, + subnetConfiguration: [ + { name: "public", subnetType: compute.SubnetType.PUBLIC }, + { + name: "private", + subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS, + }, + { name: "isolated", subnetType: compute.SubnetType.PRIVATE_ISOLATED }, + ], + }); + const securityGroup2 = new compute.SecurityGroup(stack, "SecurityGroup", { + vpc: vpcWithIsolated, + }); + + // WHEN + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc: vpcWithIsolated, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_ISOLATED }, + }); + + instance.addRotationSingleUser({ + automaticallyAfter: Duration.days(15), + excludeCharacters: "°_@", + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroup: securityGroup2, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + { + rotation_rules: { schedule_expression: "rate(15 days)" }, + }, + ); + }); + + test("addRotationMultiUser() with custom automaticallyAfter, excludeCharacters, vpcSubnets and securityGroup", () => { + // GIVEN + // The shared `vpc` fixture (from `beforeEach`) has no isolated subnets -- + // build a dedicated VPC with public/private-with-egress/isolated subnets + // so the instance can sit in an isolated subnet while rotation runs in a + // NAT-backed private subnet. + const vpcWithIsolated = new compute.Vpc(stack, "VpcWithIsolated", { + maxAzs: 2, + subnetConfiguration: [ + { name: "public", subnetType: compute.SubnetType.PUBLIC }, + { + name: "private", + subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS, + }, + { name: "isolated", subnetType: compute.SubnetType.PRIVATE_ISOLATED }, + ], + }); + const securityGroup3 = new compute.SecurityGroup(stack, "SecurityGroup", { + vpc: vpcWithIsolated, + }); + const userSecret = new rds.DatabaseSecret(stack, "UserSecret", { + username: "user", + }); + + // WHEN + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc: vpcWithIsolated, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_ISOLATED }, + }); + + instance.addRotationMultiUser("user", { + secret: userSecret.attach(instance), + automaticallyAfter: Duration.days(15), + excludeCharacters: "°_@", + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroup: securityGroup3, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + { + rotation_rules: { schedule_expression: "rate(15 days)" }, + }, + ); + }); + + test("addRotationSingleUser() with VPC interface endpoint", () => { + // GIVEN + const vpcIsolatedOnly = new compute.Vpc(stack, "VpcIsolated", { + natGateways: 0, + }); + + const endpoint = new compute.InterfaceVpcEndpoint(stack, "Endpoint", { + service: compute.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, + vpc: vpcIsolatedOnly, + subnets: { subnetType: compute.SubnetType.PRIVATE_ISOLATED }, + }); + + // WHEN + // DB in isolated subnet (no internet connectivity) + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc: vpcIsolatedOnly, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_ISOLATED }, + }); + + // Rotation in isolated subnet with access to Secrets Manager API via endpoint + instance.addRotationSingleUser({ endpoint }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + serverlessapplicationrepositoryCloudformationStack.ServerlessapplicationrepositoryCloudformationStack, + { + parameters: expect.objectContaining({ + endpoint: expect.stringContaining(`.secretsmanager.${stack.region}.`), + }), + }, + ); + }); + + test("throws when trying to add rotation to an instance without secret", () => { + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + credentials: rds.Credentials.fromUsername("syscdk", { + password: "tooshort", + }), + vpc, + }); + + // THEN + expect(() => instance.addRotationSingleUser()).toThrow(/without secret/); + }); + + test("throws when trying to add single user rotation multiple times", () => { + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + credentials: rds.Credentials.fromUsername("syscdk"), + vpc, + }); + + // WHEN + instance.addRotationSingleUser(); + + // THEN + expect(() => instance.addRotationSingleUser()).toThrow( + /A single user rotation was already added to this instance/, + ); + }); + + test("throws when timezone is set for non-sqlserver database engine", () => { + const tzSupportedEngines = [ + rds.DatabaseInstanceEngine.SQL_SERVER_EE, + rds.DatabaseInstanceEngine.SQL_SERVER_EX, + rds.DatabaseInstanceEngine.SQL_SERVER_SE, + rds.DatabaseInstanceEngine.SQL_SERVER_WEB, + ]; + const tzUnsupportedEngines = [ + rds.DatabaseInstanceEngine.MYSQL, + rds.DatabaseInstanceEngine.POSTGRES, + rds.DatabaseInstanceEngine.ORACLE_EE, + rds.DatabaseInstanceEngine.MARIADB, + ]; + + // THEN + tzSupportedEngines.forEach((engine) => { + expect( + new rds.DatabaseInstance(stack, `${engine.engineType}-db`, { + engine, + timezone: "Europe/Zurich", + vpc, + }), + ).toBeDefined(); + }); + + tzUnsupportedEngines.forEach((engine) => { + expect( + () => + new rds.DatabaseInstance(stack, `${engine.engineType}-db`, { + engine, + timezone: "Europe/Zurich", + vpc, + }), + ).toThrow(/timezone property can not be configured for/); + }); + }); + + test("create an instance from snapshot with maximum allocated storage", () => { + // WHEN + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.POSTGRES, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.LARGE, + ), + vpc, + maxAllocatedStorage: 200, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + snapshot_identifier: "my-snapshot", + max_allocated_storage: 200, + }); + }); + + test("create a DB instance with maximum allocated storage", () => { + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.MYSQL, + vpc, + backupRetention: Duration.seconds(0), + maxAllocatedStorage: 250, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + backup_retention_period: 0, + max_allocated_storage: 250, + }); + }); + + test("iam authentication - off by default", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + + // `objectContaining` cannot assert key absence (it requires the key to + // be present with value `undefined`), so check the raw synthesized + // resource instead. + const t = new Template(stack); + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect( + dbInstanceResource.iam_database_authentication_enabled, + ).toBeUndefined(); + }); + + test("createGrant - creates IAM policy and enables IAM auth", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + const role = new Role(stack, "DBRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + instance.grantConnect(role); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + iam_database_authentication_enabled: true, + }); + // no dbUser passed and no secret attached -> falls back to the generated + // master username ("admin" for MySQL, see `engine.defaultUsername`). + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["rds-db:connect"], + effect: "Allow", + resources: [ + stack.resolve( + stack.formatArn({ + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + service: "rds-db", + resource: "dbuser", + resourceName: `${instance.instanceResourceId}/admin`, + }), + ), + ], + }, + ], + }, + ); + }); + + test("createGrant - creates IAM policy and enables IAM auth for a specific user", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + const role = new Role(stack, "DBRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + instance.grantConnect(role, "my-user"); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + iam_database_authentication_enabled: true, + }); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["rds-db:connect"], + effect: "Allow", + resources: [ + stack.resolve( + stack.formatArn({ + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + service: "rds-db", + resource: "dbuser", + resourceName: `${instance.instanceResourceId}/my-user`, + }), + ), + ], + }, + ], + }, + ); + }); + + test("createGrant - creates IAM policy and enables IAM auth on instance with secret credentials without passing dbUser", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + vpc, + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_14, + }), + credentials: rds.Credentials.fromGeneratedSecret("dbuser"), + }); + const role = new Role(stack, "DBRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + instance.grantConnect(role); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + iam_database_authentication_enabled: true, + }); + // no dbUser passed -> TERRACONSTRUCTS DEVIATION: falls back to the plain-string + // `masterUsername` stashed at construction time ("dbuser", from `Credentials.fromGeneratedSecret`) + // rather than upstream's `secret.secretValueFromJson('username')` dynamic reference — see the + // deviation note on `DatabaseInstanceSource.masterUsername` / `DatabaseInstance.grantConnect`. + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["rds-db:connect"], + effect: "Allow", + resources: [ + stack.resolve( + stack.formatArn({ + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + service: "rds-db", + resource: "dbuser", + resourceName: `${instance.instanceResourceId}/dbuser`, + }), + ), + ], + }, + ], + }, + ); + }); + + test("createGrant - throws if IAM auth disabled", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + iamAuthentication: false, + }); + const role = new Role(stack, "DBRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + + expect(() => { + instance.grantConnect(role); + }).toThrow(/Cannot grant connect when IAM authentication is disabled/); + }); + + // TODO: omitted — "creates IAM policy for instance replica when the + // USE_CORRECT_VALUE_FOR_INSTANCE_RESOURCE_ID_PROPERTY feature flag is enabled/disabled" exercise + // `cx-api` CDK context feature flags, which are not ported in this repo (no equivalent + // synth-time feature-flag registry exists here) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L1442-L1564 + // test('createGrant - creates IAM policy for instance replica when the ... feature flag is enabled', () => { ... }); + // test('createGrant - creates IAM policy for instance replica when the ... feature flag is disabled by default', () => { ... }); + + describe("domain", () => { + test("sets domain property", () => { + const domain = "d-90670a8d36"; + + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.sqlServerWeb({ + version: rds.SqlServerEngineVersion.VER_14_00_3192_2_V1, + }), + vpc, + domain: domain, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + domain, + }); + }); + + test("uses role if provided", () => { + const domain = "d-90670a8d36"; + + // WHEN + const role = new Role(stack, "DomainRole", { + assumedBy: new CompositePrincipal( + new ServicePrincipal("rds.amazonaws.com"), + new ServicePrincipal("directoryservice.rds.amazonaws.com"), + ), + }); + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.sqlServerWeb({ + version: rds.SqlServerEngineVersion.VER_14_00_3192_2_V1, + }), + vpc, + domain: domain, + domainRole: role, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + domain, + domain_iam_role_name: stack.resolve(role.roleName), + }); + }); + + test("creates role if not provided", () => { + const domain = "d-90670a8d36"; + + // WHEN + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.sqlServerWeb({ + version: rds.SqlServerEngineVersion.VER_14_00_3192_2_V1, + }), + vpc, + domain: domain, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + domain, + domain_iam_role_name: expect.any(String), + }); + + t.resourceCountIs(iamRole.IamRole, 1); + }); + + test("throws when domain is set for mariadb database engine", () => { + const domainSupportedEngines = [ + rds.DatabaseInstanceEngine.SQL_SERVER_EE, + rds.DatabaseInstanceEngine.SQL_SERVER_EX, + rds.DatabaseInstanceEngine.SQL_SERVER_SE, + rds.DatabaseInstanceEngine.SQL_SERVER_WEB, + rds.DatabaseInstanceEngine.MYSQL, + rds.DatabaseInstanceEngine.POSTGRES, + rds.DatabaseInstanceEngine.ORACLE_EE, + ]; + const domainUnsupportedEngines = [rds.DatabaseInstanceEngine.MARIADB]; + + // THEN + domainSupportedEngines.forEach((engine) => { + expect( + () => + new rds.DatabaseInstance(stack, `${engine.engineType}-db`, { + engine, + domain: "d-90670a8d36", + vpc, + }), + ).not.toThrow(); + }); + + domainUnsupportedEngines.forEach((engine) => { + const expectedError = new RegExp( + `domain property cannot be configured for ${engine.engineType}`, + ); + + expect( + () => + new rds.DatabaseInstance(stack, `${engine.engineType}-db`, { + engine, + domain: "d-90670a8d36", + vpc, + }), + ).toThrow(expectedError); + }); + }); + }); + + describe("performance insights", () => { + test("instance with all performance insights properties", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + enablePerformanceInsights: true, + performanceInsightRetention: rds.PerformanceInsightRetention.LONG_TERM, + performanceInsightEncryptionKey: new encryption.Key(stack, "Key"), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + performance_insights_enabled: true, + performance_insights_retention_period: 731, + performance_insights_kms_key_id: expect.any(String), + }); + }); + + test("setting performance insights fields enables performance insights", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + performanceInsightRetention: rds.PerformanceInsightRetention.LONG_TERM, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + performance_insights_enabled: true, + performance_insights_retention_period: 731, + }); + }); + + test.each([ + "DEFAULT", + "MONTHS_1", + "MONTHS_2", + "MONTHS_3", + "MONTHS_4", + "MONTHS_5", + "MONTHS_6", + "MONTHS_7", + "MONTHS_8", + "MONTHS_9", + "MONTHS_10", + "MONTHS_11", + "MONTHS_12", + "MONTHS_13", + "MONTHS_14", + "MONTHS_15", + "MONTHS_16", + "MONTHS_17", + "MONTHS_18", + "MONTHS_19", + "MONTHS_20", + "MONTHS_21", + "MONTHS_22", + "MONTHS_23", + "LONG_TERM", + ])( + "performance insights retention of %s", + (performanceInsightRetentionKey) => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + performanceInsightRetention: + rds.PerformanceInsightRetention[performanceInsightRetentionKey], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + performance_insights_retention_period: + rds.PerformanceInsightRetention[performanceInsightRetentionKey], + }); + }, + ); + + test("explicitly disabling performance insights is respected", () => { + new rds.DatabaseInstanceFromSnapshot(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + snapshotIdentifier: "my-snapshot", + enablePerformanceInsights: false, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + performance_insights_enabled: false, + }); + }); + + test("throws if performance insights fields are set but performance insights is disabled", () => { + expect(() => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + enablePerformanceInsights: false, + performanceInsightRetention: rds.PerformanceInsightRetention.DEFAULT, + }); + }).toThrow( + /`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set/, + ); + }); + }); + + test("reuse an existing subnet group", () => { + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + subnetGroup: rds.SubnetGroup.fromSubnetGroupName( + stack, + "SubnetGroup", + "my-subnet-group", + ), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + db_subnet_group_name: "my-subnet-group", + }); + t.resourceCountIs(dbSubnetGroup.DbSubnetGroup, 0); + }); + + test("defaultChild returns the DB Instance", () => { + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + }); + + // THEN + expect(instance.node.defaultChild instanceof dbInstance.DbInstance).toBe( + true, + ); + }); + + test("PostgreSQL database instance uses a different default master username than 'admin', which is a reserved word", () => { + new rds.DatabaseInstance(stack, "Instance", { + vpc, + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + { + secret_string: expect.stringContaining('"username" = "postgres"'), + }, + ); + }); + + test("default instance identifier is truncated to the 63-char RDS DBInstanceIdentifier limit even with a deeply nested construct path", () => { + // TERRACONSTRUCTS DEVIATION: the default identifier is generated via + // `this.stack.uniqueResourceName(this, { maxLength: 63 })` -- RDS's `DBInstanceIdentifier` is + // capped at 63 characters, unlike the sibling `SubnetGroup`/`OptionGroup`/`ParameterGroup` + // (255-char AWS limits, close enough to the 256-char `uniqueResourceName` fallback to leave + // unbounded). A deeply nested construct path is the case that would overflow the default + // 256-char fallback were `maxLength` not passed explicitly. + let scope: Construct = stack; + for (let i = 0; i < 10; i++) { + scope = new Construct( + scope, + `NestedScopeWithAVeryLongConstructIdNumber${i}`, + ); + } + new rds.DatabaseInstance(scope, "InstanceWithAnotherVeryLongConstructId", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + + const t = new Template(stack); + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(typeof dbInstanceResource.identifier).toBe("string"); + expect( + (dbInstanceResource.identifier as string).length, + ).toBeLessThanOrEqual(63); + }); + + test("applyImmediately is unset by default (aws_db_instance provider default: false / changes applied at next maintenance window)", () => { + // TERRACONSTRUCTS DEVIATION: upstream's CloudFormation `ApplyImmediately` defaults to `true`; + // the `aws_db_instance` provider's `apply_immediately` defaults to `false`. This port leaves the + // argument unset when the prop is unset, so the rendered behavior is the provider default + // (`false`), not upstream's documented `true` default -- see the deviation note on + // `DatabaseInstanceNewProps.applyImmediately`. + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + + const t = new Template(stack); + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.apply_immediately).toBeUndefined(); + }); + + test("applyImmediately is rendered when explicitly set", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + applyImmediately: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + apply_immediately: true, + }); + }); + + describe("S3 Import/Export", () => { + test("instance with s3 import and export buckets", () => { + const instance = new rds.DatabaseInstance(stack, "DB", { + engine: rds.DatabaseInstanceEngine.sqlServerSe({ + version: rds.SqlServerEngineVersion.VER_14_00_3192_2_V1, + }), + vpc, + s3ImportBuckets: [new Bucket(stack, "S3Import")], + s3ExportBuckets: [new Bucket(stack, "S3Export")], + }); + + // TERRACONSTRUCTS DEVIATION: upstream asserts `AssociatedRoles: [{ FeatureName: + // 'S3_INTEGRATION', RoleArn: ... }]` as an inline array property directly on + // `AWS::RDS::DBInstance`. The Terraform `aws_db_instance` resource has no equivalent inline + // argument (see `node_modules/@cdktn/provider-aws/lib/db-instance/index.d.ts`); instead + // `DatabaseInstanceBase.createInstanceRoleAssociations()` (`instance.ts`) creates one + // `aws_db_instance_role_association` resource per associated role. `setupS3ImportExport()` + // (`../../../../src/aws/storage/rds/private/util.ts`) creates the IAM role(s) and grants + // bucket access; `combineRoles` is `true` for SQL Server (see `setupS3ImportExport` + // docstring), so a single shared role is created and granted both read (import) and + // read/write (export) access, and only one role association is emitted (`s3Import` and + // `s3Export` share the same `S3_INTEGRATION` feature name for SQL Server, so the + // "only add the export feature if it's different from the import feature" branch in + // `DatabaseInstanceSource`'s constructor collapses them into one) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L1817-L1875 + const t = new Template(stack); + t.resourceCountIs(iamRole.IamRole, 1); + t.resourceCountIs(dbInstanceRoleAssociation.DbInstanceRoleAssociation, 1); + // `arn` is a computed attribute (not a resource argument), so it does not appear in the + // synthesized `aws_iam_role` config -- look up the shared role's logical id from the + // synthesized resource map and build the expected `${aws_iam_role..arn}` reference, + // mirroring how `db_instance_role_association.role_arn` is actually rendered. + const s3RoleId = Object.keys(t.resourcesByType(iamRole.IamRole))[0]; + t.expect.toHaveResourceWithProperties( + dbInstanceRoleAssociation.DbInstanceRoleAssociation, + { + db_instance_identifier: stack.resolve(instance.instanceIdentifier), + feature_name: "S3_INTEGRATION", + role_arn: `\${aws_iam_role.${s3RoleId}.arn}`, + }, + ); + }); + + test("instance with different s3 import and export feature names creates two role associations", () => { + // Postgres uses distinct feature names ("s3Import" / "s3Export") for import vs. export, and + // is not one of the `combineRoles` engines (only Oracle/SQL Server require a single shared + // role), so passing the same role for both still yields two separate + // `aws_db_instance_role_association` resources -- one per feature name. + const s3Role = new Role(stack, "S3Role", { + assumedBy: new ServicePrincipal("rds.amazonaws.com"), + }); + const instance = new rds.DatabaseInstance(stack, "DB", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + s3ImportRole: s3Role, + s3ExportRole: s3Role, + }); + + const t = new Template(stack); + t.resourceCountIs(dbInstanceRoleAssociation.DbInstanceRoleAssociation, 2); + t.expect.toHaveResourceWithProperties( + dbInstanceRoleAssociation.DbInstanceRoleAssociation, + { + db_instance_identifier: stack.resolve(instance.instanceIdentifier), + feature_name: "s3Import", + role_arn: stack.resolve(s3Role.roleArn), + }, + ); + t.expect.toHaveResourceWithProperties( + dbInstanceRoleAssociation.DbInstanceRoleAssociation, + { + db_instance_identifier: stack.resolve(instance.instanceIdentifier), + feature_name: "s3Export", + role_arn: stack.resolve(s3Role.roleArn), + }, + ); + }); + + test("throws if using s3 import on unsupported engine", () => { + const s3ImportRole = new Role(stack, "S3ImportRole", { + assumedBy: new ServicePrincipal("rds.amazonaws.com"), + }); + + expect(() => { + new rds.DatabaseInstance(stack, "DBWithImportBucket", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + s3ImportBuckets: [new Bucket(stack, "S3Import")], + }); + }).toThrow(/Engine 'mysql-8.0.19' does not support S3 import/); + expect(() => { + new rds.DatabaseInstance(stack, "DBWithImportRole", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + s3ImportRole, + }); + }).toThrow(/Engine 'mysql-8.0.19' does not support S3 import/); + }); + + test("throws if using s3 export on unsupported engine", () => { + const s3ExportRole = new Role(stack, "S3ExportRole", { + assumedBy: new ServicePrincipal("rds.amazonaws.com"), + }); + + expect(() => { + new rds.DatabaseInstance(stack, "DBWithExportBucket", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + s3ExportBuckets: [new Bucket(stack, "S3Export")], + }); + }).toThrow(/Engine 'mysql-8.0.19' does not support S3 export/); + expect(() => { + new rds.DatabaseInstance(stack, "DBWithExportRole", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + s3ExportRole: s3ExportRole, + }); + }).toThrow(/Engine 'mysql-8.0.19' does not support S3 export/); + }); + + test("throws if provided two different roles for import/export", () => { + const s3ImportRole = new Role(stack, "S3ImportRole", { + assumedBy: new ServicePrincipal("rds.amazonaws.com"), + }); + const s3ExportRole = new Role(stack, "S3ExportRole", { + assumedBy: new ServicePrincipal("rds.amazonaws.com"), + }); + + expect(() => { + new rds.DatabaseInstance(stack, "DBWithExportBucket", { + engine: rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_14_00_3192_2_V1, + }), + vpc, + s3ImportRole, + s3ExportRole, + }); + }).toThrow(/S3 import and export roles must be the same/); + }); + }); + + test("fromGeneratedSecret", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + credentials: rds.Credentials.fromGeneratedSecret("postgres"), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + username: "postgres", // username is a string + }); + }); + + test("fromGeneratedSecret with replica regions", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + credentials: rds.Credentials.fromGeneratedSecret("postgres", { + replicaRegions: [{ region: "eu-west-1" }], + }), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + replica: [{ region: "eu-west-1" }], + }, + ); + }); + + test("fromPassword", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + credentials: rds.Credentials.fromPassword("postgres", "s3cr3t!"), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + username: "postgres", // username is a string + password: "s3cr3t!", + }); + }); + + // TODO: omitted — `Credentials.fromSecret()` depends on `ISecret.secretValueFromJson`, which is + // not ported in this repo (commented out in + // `../../../../src/aws/storage/rds/props.ts` — see the TERRACONSTRUCTS DEVIATION note on + // `ISecret` in `../../../../src/aws/encryption/secret.ts`). Reinstate once that capability + // lands — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L1999-L2018 + // test('can set custom name to database secret by fromSecret', () => { ... }); + + test("can set custom name to database secret by fromGeneratedSecret", () => { + // WHEN + const secretName = "custom-secret-name"; + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + credentials: rds.Credentials.fromGeneratedSecret("admin", { + secretName, + }), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + name: secretName, + }, + ); + }); + + describe("manageMasterUserPassword", () => { + test("with username and KMS encryption key", () => { + // GIVEN + const kmsKey = new encryption.Key(stack, "Key"); + + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + manageMasterUserPassword: true, + credentials: { + username: "testuser", + encryptionKey: kmsKey, + } as rds.Credentials, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + engine: "mysql", + username: "testuser", + manage_master_user_password: true, + master_user_secret_kms_key_id: stack.resolve(kmsKey.keyArn), + }); + // `objectContaining` cannot assert key absence (it requires the key to + // be present with value `undefined`), so check the raw synthesized + // resource instead. + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.password).toBeUndefined(); + + t.resourceCountIs(secretsmanagerSecret.SecretsmanagerSecret, 0); + }); + + test("without username (uses engine default)", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + manageMasterUserPassword: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + engine: "mysql", + username: "admin", // engine default username + manage_master_user_password: true, + }); + // `objectContaining` cannot assert key absence (it requires the key to + // be present with value `undefined`), so check the raw synthesized + // resource instead. + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.password).toBeUndefined(); + expect(dbInstanceResource.master_user_secret_kms_key_id).toBeUndefined(); + + t.resourceCountIs(secretsmanagerSecret.SecretsmanagerSecret, 0); + }); + + test("secret.grantRead() grants kms:Decrypt when a customer managed key is used", () => { + // GIVEN + const kmsKey = new encryption.Key(stack, "Key"); + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + manageMasterUserPassword: true, + credentials: { + username: "testuser", + encryptionKey: kmsKey, + } as rds.Credentials, + }); + const role = new Role(stack, "Role", { + assumedBy: new ServicePrincipal("lambda.amazonaws.com"), + }); + + // WHEN + instance.secret!.grantRead(role); + + // THEN + // TERRACONSTRUCTS DEVIATION: `instance.secret` for a `manageMasterUserPassword` instance + // refers to the RDS-managed secret (`master_user_secret` computed block, exposed via + // `Secret.fromSecretAttributes`), not a TerraConstructs-owned `encryption.Secret`/ + // `DatabaseSecret`. `grantRead()` still renders the usual IAM read-policy statement scoped to + // that secret's ARN, plus a `kms:Decrypt` grant (with the `kms:ViaService` condition) on the + // customer-managed key's policy. + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ], + effect: "Allow", + resources: [stack.resolve(instance.secret!.secretArn)], + }, + ], + }, + ); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expect.arrayContaining([ + { + actions: ["kms:Decrypt"], + condition: [ + { + test: "StringEquals", + values: ["secretsmanager.us-east-1.amazonaws.com"], + variable: "kms:ViaService", + }, + ], + effect: "Allow", + principals: [ + { + identifiers: [stack.resolve(role.roleArn)], + type: "AWS", + }, + ], + resources: ["*"], + }, + ]), + }, + ); + }); + }); + + describe("manageMasterUserPassword validation errors", () => { + test("should reject all unsupported credential properties", () => { + // THEN + expect(() => { + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + manageMasterUserPassword: true, + credentials: { + username: "testuser", + password: "password", + excludeCharacters: '"@/\\', + secretName: "my-secret", + replicaRegions: [{ region: "us-west-2" }], + usernameAsString: true, + } as rds.Credentials, + }); + }).toThrow( + /When manageMasterUserPassword is enabled, only 'username' and 'encryptionKey' are allowed in credentials\. Found unsupported properties: excludeCharacters, password, replicaRegions, secretName, usernameAsString\./, + ); + }); + }); + + describe("manageMasterUserPassword rotation conflict", () => { + test("addRotationSingleUser throws when manageMasterUserPassword is enabled", () => { + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + manageMasterUserPassword: true, + }); + + expect(() => instance.addRotationSingleUser()).toThrow( + /Cannot add rotation when `manageMasterUserPassword` is enabled\. RDS automatically rotates the master password when it manages the secret\./, + ); + }); + + test("addRotationMultiUser throws when manageMasterUserPassword is enabled", () => { + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + manageMasterUserPassword: true, + }); + const userSecret = new rds.DatabaseSecret(stack, "UserSecret", { + username: "user", + }); + + expect(() => + instance.addRotationMultiUser("user", { + secret: userSecret.attach(instance), + }), + ).toThrow( + /Cannot add rotation when `manageMasterUserPassword` is enabled\. RDS automatically rotates the master password when it manages the secret\./, + ); + }); + + test("addRotationSingleUser works when manageMasterUserPassword is not enabled (regression)", () => { + const instance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + + // WHEN - should not throw + instance.addRotationSingleUser(); + + // THEN + const t = new Template(stack); + t.resourceCountIs( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + 1, + ); + }); + }); + + test("can set publiclyAccessible to false with public subnets", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + vpcSubnets: { subnetType: compute.SubnetType.PUBLIC }, + publiclyAccessible: false, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + publicly_accessible: false, + }); + }); + + test("can set publiclyAccessible to true with private subnets", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS }, + publiclyAccessible: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + publicly_accessible: true, + }); + }); + + test("changes the case of the instance identifier", () => { + // WHEN + const instanceIdentifier = "TestInstanceIdentifier"; + new rds.DatabaseInstance(stack, "DB", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + instanceIdentifier, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + identifier: instanceIdentifier.toLowerCase(), + }); + }); + + // TODO: omitted — "does not change[] the case of the cluster identifier if the + // lowercaseDbIdentifier feature flag is disabled" exercises a `cx-api` CDK context feature flag + // (`@aws-cdk/aws-rds:lowercaseDbIdentifier`). CDK's synth-time feature-flag registry is not + // ported in this repo; every other already-landed RDS construct (`SubnetGroup`, `OptionGroup`, + // ...) unconditionally lowercases generated/explicit Terraform resource names (RDS stores names + // lowercase server-side regardless), so `DatabaseInstance` is expected to do the same + // unconditionally, with no opt-out — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L2252-L2272 + + test("throws with backupRetention on a read replica if engine does not support it", () => { + // GIVEN + const instanceType = compute.InstanceType.of( + compute.InstanceClass.T3, + compute.InstanceSize.SMALL, + ); + const backupRetention = Duration.days(5); + const source = new rds.DatabaseInstance(stack, "Source", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + backupRetention, + instanceType, + vpc, + }); + + expect(() => { + new rds.DatabaseInstanceReadReplica(stack, "Replica", { + sourceDatabaseInstance: source, + backupRetention, + instanceType, + vpc, + }); + }).toThrow( + /Cannot set 'backupRetention', as engine 'postgres-16.3' does not support automatic backups for read replicas/, + ); + }); + + test("read replica with allocatedStorage", () => { + // GIVEN + const instanceType = compute.InstanceType.of( + compute.InstanceClass.T3, + compute.InstanceSize.SMALL, + ); + const engine = rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }); + const parameterGroup = new rds.ParameterGroup(stack, "ParameterGroup", { + engine, + }); + const source = new rds.DatabaseInstance(stack, "Source", { + engine, + instanceType, + vpc, + }); + + // WHEN + new rds.DatabaseInstanceReadReplica(stack, "Replica", { + sourceDatabaseInstance: source, + parameterGroup, + instanceType, + vpc, + allocatedStorage: 500, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + allocated_storage: 500, + }); + }); + + test("can set parameter group on read replica", () => { + // GIVEN + const instanceType = compute.InstanceType.of( + compute.InstanceClass.T3, + compute.InstanceSize.SMALL, + ); + const engine = rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }); + const parameterGroup = new rds.ParameterGroup(stack, "ParameterGroup", { + engine, + }); + const source = new rds.DatabaseInstance(stack, "Source", { + engine, + instanceType, + vpc, + }); + + // WHEN + new rds.DatabaseInstanceReadReplica(stack, "Replica", { + sourceDatabaseInstance: source, + parameterGroup, + instanceType, + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + parameter_group_name: stack.resolve( + parameterGroup.bindToInstance({}).parameterGroupName, + ), + }); + }); + + test("instance with port provided as a number", () => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.MYSQL, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + port: 3306, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + port: 3306, + }); + }); + + test("instance with port provided as a CloudFormation parameter", () => { + // GIVEN + const port = new TerraformVariable(stack, "Port", { type: "number" }); + + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.MYSQL, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.SMALL, + ), + vpc, + port: port.numberValue, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + port: stack.resolve(port.numberValue), + }); + }); + + test("engine is specified for read replica using domain", () => { + // GIVEN + const instanceType = compute.InstanceType.of( + compute.InstanceClass.T3, + compute.InstanceSize.SMALL, + ); + const engine = rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }); + const source = new rds.DatabaseInstance(stack, "Source", { + engine, + instanceType, + vpc, + }); + + // WHEN + new rds.DatabaseInstanceReadReplica(stack, "Replica", { + sourceDatabaseInstance: source, + instanceType, + vpc, + domain: "my-domain", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + replicate_source_db: expect.any(String), + engine: "postgres", + }); + }); + + test("specify `storageThroughput` for gp3 storage type", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_30, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.SMALL, + ), + vpc, + allocatedStorage: 500, + storageType: rds.StorageType.GP3, + storageThroughput: 500, + iops: 4000, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + storage_type: "gp3", + storage_throughput: 500, + iops: 4000, + }); + }); + + test("with CA certificate", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_30, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.SMALL, + ), + vpc, + caCertificate: rds.CaCertificate.RDS_CA_RSA2048_G1, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + ca_cert_identifier: "rds-ca-rsa2048-g1", + }); + }); + + test("throws with storage throughput and not GP3", () => { + expect( + () => + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_30, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.SMALL, + ), + vpc, + storageType: rds.StorageType.GP2, + storageThroughput: 500, + }), + ).toThrow(/storage throughput can only be specified with GP3 storage type/); + }); + + test("throws with a ratio of storage throughput to IOPS greater than 0.25", () => { + expect( + () => + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_30, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.SMALL, + ), + vpc, + allocatedStorage: 1000, + storageType: rds.StorageType.GP3, + iops: 5000, + storageThroughput: 2500, + }), + ).toThrow(/maximum ratio of storage throughput to IOPS is 0.25/); + }); + + test.each([ + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT, + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + ])( + "DatabaseInstance can specify engine lifecycle support %s", + (engineLifecycleSupport) => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + engineLifecycleSupport, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + engine_lifecycle_support: engineLifecycleSupport, + }); + }, + ); + + test.each([ + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT, + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + ])( + "DatabaseInstanceFromSnapshot can specify engine lifecycle support %s", + (engineLifecycleSupport) => { + // WHEN + new rds.DatabaseInstanceFromSnapshot(stack, "Database", { + snapshotIdentifier: "my-snapshot", + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + engineLifecycleSupport, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + engine_lifecycle_support: engineLifecycleSupport, + }); + }, + ); + + test.each([ + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT, + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + ])( + "DatabaseInstanceReadReplica can specify engine lifecycle support %s", + (engineLifecycleSupport) => { + // GIVEN + const sourceInstance = new rds.DatabaseInstance(stack, "Database", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + }); + + // WHEN + new rds.DatabaseInstanceReadReplica(stack, "ReadReplica", { + sourceDatabaseInstance: sourceInstance, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.LARGE, + ), + vpc, + engineLifecycleSupport, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + replicate_source_db: stack.resolve(sourceInstance.instanceArn), + engine_lifecycle_support: engineLifecycleSupport, + }); + }, + ); + + test.each([ + rds.DatabaseInstanceEngine.oracleEe({ + version: rds.OracleEngineVersion.VER_19, + }), + rds.DatabaseInstanceEngine.mariaDb({ + version: rds.MariaDbEngineVersion.VER_10_6, + }), + rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_16_00_4185_3_V1, + }), + ])( + "DatabaseInstance cannot specify engine lifecycle support for engine %s", + (engine) => { + expect( + () => + new rds.DatabaseInstance(stack, "Database", { + engine, + vpc, + engineLifecycleSupport: + rds.EngineLifecycleSupport + .OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + }), + ).toThrow( + /'engineLifecycleSupport' can only be specified for RDS for MySQL and RDS for PostgreSQL/, + ); + }, + ); + + test.each([ + rds.DatabaseInstanceEngine.oracleEe({ + version: rds.OracleEngineVersion.VER_19, + }), + rds.DatabaseInstanceEngine.mariaDb({ + version: rds.MariaDbEngineVersion.VER_10_6, + }), + rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_16_00_4185_3_V1, + }), + ])( + "DatabaseInstanceFromSnapshot cannot specify engine lifecycle support for engine %s", + (engine) => { + expect( + () => + new rds.DatabaseInstanceFromSnapshot(stack, "Database", { + snapshotIdentifier: "my-snapshot", + engine, + vpc, + engineLifecycleSupport: + rds.EngineLifecycleSupport + .OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + }), + ).toThrow( + /'engineLifecycleSupport' can only be specified for RDS for MySQL and RDS for PostgreSQL/, + ); + }, + ); + + test.each([ + rds.DatabaseInstanceEngine.oracleEe({ + version: rds.OracleEngineVersion.VER_19, + }), + rds.DatabaseInstanceEngine.mariaDb({ + version: rds.MariaDbEngineVersion.VER_10_6, + }), + rds.DatabaseInstanceEngine.sqlServerEe({ + version: rds.SqlServerEngineVersion.VER_16_00_4185_3_V1, + }), + ])( + "DatabaseInstanceReadReplica cannot specify engine lifecycle support for engine %s", + (engine) => { + // GIVEN + const sourceInstance = new rds.DatabaseInstance(stack, "Database", { + engine, + vpc, + }); + + expect( + () => + new rds.DatabaseInstanceReadReplica(stack, "ReadReplica", { + sourceDatabaseInstance: sourceInstance, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.LARGE, + ), + vpc, + engineLifecycleSupport: + rds.EngineLifecycleSupport + .OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + }), + ).toThrow( + /'engineLifecycleSupport' can only be specified for RDS for MySQL and RDS for PostgreSQL/, + ); + }, + ); + + test.each([ + rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_15, + }), + ])( + "DatabaseInstance can specify engine lifecycle support for engine %s", + (engine) => { + // WHEN + new rds.DatabaseInstance(stack, "Database", { + engine, + vpc, + engineLifecycleSupport: + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + engine_lifecycle_support: "open-source-rds-extended-support-disabled", + }); + }, + ); + + test.each([ + rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_15, + }), + ])( + "DatabaseInstanceFromSnapshot can specify engine lifecycle support for engine %s", + (engine) => { + // WHEN + new rds.DatabaseInstanceFromSnapshot(stack, "Database", { + snapshotIdentifier: "my-snapshot", + engine, + vpc, + engineLifecycleSupport: + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + engine_lifecycle_support: "open-source-rds-extended-support-disabled", + }); + }, + ); + + test.each([ + rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_15, + }), + ])( + "DatabaseInstanceReadReplica can specify engine lifecycle support for engine %s", + (engine) => { + // GIVEN + const sourceInstance = new rds.DatabaseInstance(stack, "Database", { + engine, + vpc, + }); + + // WHEN + new rds.DatabaseInstanceReadReplica(stack, "ReadReplica", { + sourceDatabaseInstance: sourceInstance, + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE2, + compute.InstanceSize.LARGE, + ), + vpc, + engineLifecycleSupport: + rds.EngineLifecycleSupport.OPEN_SOURCE_RDS_EXTENDED_SUPPORT_DISABLED, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + replicate_source_db: stack.resolve(sourceInstance.instanceArn), + engine_lifecycle_support: "open-source-rds-extended-support-disabled", + }); + }, + ); +}); + +// TERRACONSTRUCTS DEVIATION: upstream's module-level `test.each(['RETAIN','SNAPSHOT','DESTROY'])(... +// instance RemovalPolicy ...)` exercises `cdk.RemovalPolicy`, mapped onto CloudFormation's +// `DeletionPolicy`/`UpdateReplacePolicy`. `core.RemovalPolicy` is not ported in this repo (see the +// TERRACONSTRUCTS DEVIATION notes on `SubnetGroupProps.removalPolicy` and +// `ParameterGroupProps.removalPolicy`); per the slice plan, `DatabaseInstance` instead exposes the +// underlying `aws_db_instance` fields directly and Terraform-natively (`skip_final_snapshot` / +// `final_snapshot_identifier` / `deletion_protection`) rather than an upstream-shaped +// `removalPolicy` enum — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L2656-L2685 +describe("removal policy replacement props", () => { + test("skipFinalSnapshot, finalSnapshotIdentifier and deletionProtection are rendered when set", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + skipFinalSnapshot: false, + finalSnapshotIdentifier: "my-final-snapshot", + deletionProtection: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + skip_final_snapshot: false, + final_snapshot_identifier: "my-final-snapshot", + deletion_protection: true, + }); + }); + + test("skipFinalSnapshot true omits finalSnapshotIdentifier", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + skipFinalSnapshot: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + skip_final_snapshot: true, + }); + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.final_snapshot_identifier).toBeUndefined(); + }); + + test("skipFinalSnapshot, finalSnapshotIdentifier and deletionProtection are absent when unset", () => { + new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_0_19, + }), + vpc, + }); + + // `objectContaining` cannot assert key absence (it requires the key to be present with value + // `undefined`), so check the raw synthesized resource instead. + const t = new Template(stack); + const [dbInstanceResource] = t.resourceTypeArray( + dbInstance.DbInstance, + ) as any[]; + expect(dbInstanceResource.skip_final_snapshot).toBeUndefined(); + expect(dbInstanceResource.final_snapshot_identifier).toBeUndefined(); + expect(dbInstanceResource.deletion_protection).toBeUndefined(); + }); +}); + +// TODO: omitted — upstream's "cross-account instance" describe block depends on +// `cdk.PhysicalName.GENERATE_IF_NEEDED` (a CDK cross-environment physical-name-generation +// mechanism), which is not ported in this repo, plus cross-stack `CfnOutput` referencing of a +// dynamically-generated instance ARN/identifier across two `env`-scoped stacks. Revisit once +// physical-name generation and multi-account/-region `AwsStack` cross-referencing conventions are +// established elsewhere in the repo — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/instance.test.ts#L2687-L2731 +// describe('cross-account instance', () => { ... }); + +describe("database insights for instance", () => { + let app2: App; + let stack2: AwsStack; + let vpc2: compute.IVpc; + beforeEach(() => { + app2 = Testing.app(); + stack2 = new AwsStack(app2, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + vpc2 = new compute.Vpc(stack2, "VPC", { maxAzs: 2 }); + }); + + test("instance with the advanced mode of database insights", () => { + // WHEN + new rds.DatabaseInstance(stack2, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_13_7, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.MEDIUM, + ), + vpc: vpc2, + databaseInsightsMode: rds.DatabaseInsightsMode.ADVANCED, + performanceInsightRetention: rds.PerformanceInsightRetention.MONTHS_15, + }); + + // THEN + const t = new Template(stack2); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + performance_insights_enabled: true, + performance_insights_retention_period: 465, + database_insights_mode: "advanced", + }); + }); + + test.each([true, false])( + "instance with the standard mode of database insights when enablePerformanceInsights is %s", + (enablePerformanceInsights) => { + // WHEN + new rds.DatabaseInstance(stack2, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_17_5, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.BURSTABLE3, + compute.InstanceSize.MEDIUM, + ), + vpc: vpc2, + enablePerformanceInsights, + databaseInsightsMode: rds.DatabaseInsightsMode.STANDARD, + }); + + // THEN + const t = new Template(stack2); + t.expect.toHaveResourceWithProperties(dbInstance.DbInstance, { + performance_insights_enabled: enablePerformanceInsights, + database_insights_mode: "standard", + }); + }, + ); + + test("throw if performance insights is disabled and the advanced mode of database insights is set", () => { + // THEN + expect(() => { + new rds.DatabaseInstance(stack2, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_17_5, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.LARGE, + ), + vpc: vpc2, + enablePerformanceInsights: false, + databaseInsightsMode: rds.DatabaseInsightsMode.ADVANCED, + performanceInsightRetention: rds.PerformanceInsightRetention.MONTHS_15, + }); + }).toThrow( + /`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set, or `databaseInsightsMode` was set to '\$\{DatabaseInsightsMode\.ADVANCED\}'/, + ); + }); + + test("throw if the advanced mode of database insights is set and any retention other than MONTHS_15 is set for performanceInsightRetention", () => { + // THEN + expect(() => { + new rds.DatabaseInstance(stack2, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_17_5, + }), + instanceType: compute.InstanceType.of( + compute.InstanceClass.R5, + compute.InstanceSize.LARGE, + ), + vpc: vpc2, + performanceInsightRetention: rds.PerformanceInsightRetention.LONG_TERM, + databaseInsightsMode: rds.DatabaseInsightsMode.ADVANCED, + }); + }).toThrow( + /`performanceInsightRetention` must be set to '\$\{PerformanceInsightRetention\.MONTHS_15\}' when `databaseInsightsMode` is set to '\$\{DatabaseInsightsMode\.ADVANCED\}'/, + ); + }); +});