diff --git a/go.mod b/go.mod index f076a77c..fc5b8a3b 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/service/neptune v1.48.4 // indirect github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 // indirect + github.com/aws/aws-sdk-go-v2/service/redshift v1.65.4 // indirect github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 // indirect diff --git a/go.sum b/go.sum index 8d16e93c..47a3ed74 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/aws/aws-sdk-go-v2/service/neptune v1.48.4 h1:OKqe8wmRV8/b0PgfEm2xBKUd github.com/aws/aws-sdk-go-v2/service/neptune v1.48.4/go.mod h1:QpgA5y9y/SywZTBw8ylR5gDS4Uc2qlVYbudXmzr/VSw= github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 h1:eqHz3Uih+gb0vLE5Cc4Xf733vOxsxDp6GFUUVQU4d7w= github.com/aws/aws-sdk-go-v2/service/rds v1.91.0/go.mod h1:h2jc7IleH3xHY7y+h8FH7WAZcz3IVLOB6/jXotIQ/qU= +github.com/aws/aws-sdk-go-v2/service/redshift v1.65.4 h1:kpCVKoiWkkd0Ma4Z03brq3sQpGRv47FTaaf0LgjsZwo= +github.com/aws/aws-sdk-go-v2/service/redshift v1.65.4/go.mod h1:sMXbazIzJ+VjS4GmSlSTRnIpC7bpLuFpt0Lhv+qYANs= github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 h1:wmt05tPp/CaRZpPV5B4SaJ5TwkHKom07/BzHoLdkY1o= github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2/go.mod h1:d+K9HESMpGb1EU9/UmmpInbGIUcAkwmcY6ZO/A3zZsw= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE= diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 02db2a20..1e62b9f8 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -52,6 +52,10 @@ neptune.cluster: ## Test Neptune DatabaseCluster L2 (live serverless cluster + i go test -v -count 1 -timeout 45m ./... -run ^TestNeptuneCluster$ .PHONY: neptune.cluster +redshift.cluster: ## Test Redshift Cluster L2 (live single-node ra3.large cluster) + go test -v -count 1 -timeout 45m ./... -run ^TestRedshiftCluster$ +.PHONY: redshift.cluster + bucket-notifications: ## Test S3 Bucket with EventBridge Notifications go test -v -count 1 -timeout 15m ./... -run ^TestBucketNotifications$ .PHONY: bucket-notifications diff --git a/integ/aws/storage/apps/redshift.cluster.ts b/integ/aws/storage/apps/redshift.cluster.ts new file mode 100644 index 00000000..489b3d6b --- /dev/null +++ b/integ/aws/storage/apps/redshift.cluster.ts @@ -0,0 +1,97 @@ +// Live test for the storage.redshift Cluster L2 (scope-reduced alpha port): a real +// single-node ra3.large Redshift cluster deployed through the ported construct in an +// isolated VPC. Validates the aws_redshift_cluster mapping, the generated-secret +// master-password double-freeze (drift oracle), the ClusterParameterGroup attachment, +// IAM role association plus the native default_iam_role_arn deviation +// (upstream: AwsCustomResource), and grid-scoped lowercased naming against live AWS. +import { App, LocalBackend, TerraformOutput } from "cdktn"; +import { aws } 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 ?? "redshift.cluster"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "gcccccccc-cccc", + 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 role = new aws.iam.Role(stack, "ClusterRole", { + assumedBy: new aws.iam.ServicePrincipal("redshift.amazonaws.com"), +}); + +const parameterGroup = new aws.storage.redshift.ClusterParameterGroup( + stack, + "Params", + { + description: "Redshift integ cluster parameter group", + parameters: { + require_ssl: "true", + }, + }, +); + +const cluster = new aws.storage.redshift.Cluster(stack, "Cluster", { + // No masterPassword: exercises the generated DatabaseSecret + the + // master_password ignore_changes double-freeze (validated by the drift oracle). + masterUser: { + masterUsername: "admin", + }, + vpc, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, + clusterType: aws.storage.redshift.ClusterType.SINGLE_NODE, + nodeType: aws.storage.redshift.NodeType.RA3_LARGE, + parameterGroup, + roles: [role], + // Exercises the native default_iam_role_arn deviation (upstream shells out to + // modifyClusterIamRoles via an AwsCustomResource). + defaultRole: role, + // Terraform-native replacement for upstream removalPolicy: allow clean destroy. + skipFinalSnapshot: true, +}); + +new TerraformOutput(stack, "cluster_identifier", { + value: cluster.clusterName, + staticId: true, +}); +new TerraformOutput(stack, "cluster_endpoint_address", { + value: cluster.clusterEndpoint.hostname, + staticId: true, +}); +new TerraformOutput(stack, "parameter_group_name", { + value: parameterGroup.clusterParameterGroupName, + staticId: true, +}); +new TerraformOutput(stack, "default_role_arn", { + value: role.roleArn, + staticId: true, +}); +new TerraformOutput(stack, "secret_arn", { + value: cluster.secret!.secretArn, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/redshift_cluster_test.go b/integ/aws/storage/redshift_cluster_test.go new file mode 100644 index 00000000..40a06939 --- /dev/null +++ b/integ/aws/storage/redshift_cluster_test.go @@ -0,0 +1,104 @@ +package test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/redshift" + "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/redshift.cluster.ts integration test: a real single-node +// ra3.large Redshift cluster deployed through the storage.redshift Cluster L2 +// (scope-reduced alpha port). Validates cluster read-back incl. the attached +// ClusterParameterGroup, IAM role association plus the native +// default_iam_role_arn deviation, the attach() protocol's merged secret, +// grid-scoped lowercased naming, and the post-apply drift oracle (the +// generated-secret master_password double-freeze). +func TestRedshiftCluster(t *testing.T) { + runStorageIntegrationTest(t, "redshift.cluster", "us-east-1", validateRedshiftCluster) +} + +func validateRedshiftCluster(t *testing.T, tfWorkingDir string, awsRegion string) { + terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir) + outputs := terraform.OutputAll(t, terraformOptions) + + clusterID := outputs["cluster_identifier"].(string) + endpointAddress := outputs["cluster_endpoint_address"].(string) + parameterGroupName := outputs["parameter_group_name"].(string) + defaultRoleArn := outputs["default_role_arn"].(string) + secretArn := outputs["secret_arn"].(string) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion)) + require.NoError(t, err) + client := redshift.NewFromConfig(cfg) + + // --- 1. Cluster read-back: status, node shape, encryption, endpoint, naming. --- + dc, err := client.DescribeClusters(ctx, &redshift.DescribeClustersInput{ + ClusterIdentifier: &clusterID, + }) + require.NoError(t, err) + require.Len(t, dc.Clusters, 1) + c := dc.Clusters[0] + require.Equal(t, "available", *c.ClusterStatus) + require.Equal(t, "ra3.large", *c.NodeType) + require.Equal(t, int32(1), *c.NumberOfNodes) + require.Equal(t, "admin", *c.MasterUsername) + require.True(t, *c.Encrypted, "encryption defaults to true") + require.False(t, *c.PubliclyAccessible) + require.Equal(t, endpointAddress, *c.Endpoint.Address) + t.Logf("redshift-cluster: %s available (%s x%d, encrypted, private)", + clusterID, *c.NodeType, *c.NumberOfNodes) + + // --- 2. Parameter group attached and in-sync-able. --- + foundParams := false + for _, pg := range c.ClusterParameterGroups { + if *pg.ParameterGroupName == parameterGroupName { + foundParams = true + } + } + require.True(t, foundParams, + "cluster must be associated with the ported ClusterParameterGroup %s", parameterGroupName) + t.Logf("redshift-cluster: parameter group %s attached", parameterGroupName) + + // --- 3. IAM role associated AND set as the cluster default (the native + // default_iam_role_arn deviation -- upstream uses an AwsCustomResource). --- + foundRole := false + for _, r := range c.IamRoles { + if *r.IamRoleArn == defaultRoleArn { + foundRole = true + } + } + require.True(t, foundRole, "role %s must be associated with the cluster", defaultRoleArn) + require.NotNil(t, c.DefaultIamRoleArn) + require.Equal(t, defaultRoleArn, *c.DefaultIamRoleArn, + "default_iam_role_arn must reach AWS (native replacement for upstream's AwsCustomResource)") + t.Logf("redshift-cluster: default IAM role %s set natively", defaultRoleArn) + + // --- 4. Attached secret carries merged connection fields (port is a JSON + // NUMBER -- CFN SecretTargetAttachment parity). --- + secretValue := aws.GetSecretValue(t, awsRegion, secretArn) + var connection map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(secretValue), &connection)) + require.Equal(t, "admin", connection["username"]) + require.NotEmpty(t, connection["password"]) + require.Equal(t, "redshift", connection["engine"]) + require.Equal(t, endpointAddress, connection["host"]) + require.Equal(t, float64(*c.Endpoint.Port), connection["port"]) + require.Equal(t, clusterID, connection["dbClusterIdentifier"], + "attach() must merge dbClusterIdentifier (CFN SecretTargetAttachment parity)") + t.Logf("redshift-cluster: attached secret carries full connection details incl. dbClusterIdentifier=%s", clusterID) + + // --- Drift oracle: re-planning the already-applied stack must show zero + // changes. Proves the generated-secret master_password ignore_changes + // double-freeze reads back cleanly. --- + planExitCode := terraform.PlanExitCode(t, terraformOptions) + require.Equal(t, terraform.DefaultSuccessExitCode, planExitCode, + "expected `tofu plan -detailed-exitcode` to report no drift after apply (got exit code %d)", planExitCode) +} diff --git a/src/aws/storage/index.ts b/src/aws/storage/index.ts index a43ca18a..b8d066e3 100644 --- a/src/aws/storage/index.ts +++ b/src/aws/storage/index.ts @@ -48,3 +48,6 @@ export * as elasticache from "./elasticache"; // aws-neptune-alpha export * as neptune from "./neptune"; + +// aws-redshift-alpha +export * as redshift from "./redshift"; diff --git a/src/aws/storage/redshift/cluster.ts b/src/aws/storage/redshift/cluster.ts new file mode 100644 index 00000000..4a3dc84e --- /dev/null +++ b/src/aws/storage/redshift/cluster.ts @@ -0,0 +1,1171 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-redshift-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. +// +// SCOPE REDUCTION (this PR): three upstream surfaces backed by Lambda-backed custom resources / +// CloudFormation-only mechanisms are reduced or replaced below -- see the TERRACONSTRUCTS +// DEVIATION / TODO notes at each call site: +// - `addDefaultIamRole()` -- REPLACED: upstream shells out to the Redshift API via an +// `AwsCustomResource` (`modifyClusterIamRoles`); the provider +// exposes the same capability natively as +// `aws_redshift_cluster.default_iam_role_arn`. +// - `enableRebootForParameterChanges()` -- OMITTED (commented out, not deleted): backed by a +// `Custom::RedshiftClusterRebooter` Lambda-backed custom +// resource; no Terraform-native equivalent. +// - `loggingProperties` -- REPLACED: provider 6.x moved Redshift audit logging off the +// `aws_redshift_cluster` resource itself and onto a standalone +// `aws_redshift_logging` resource. + +import { redshiftCluster, redshiftLogging } from "@cdktn/provider-aws"; +import { Annotations, Lazy, Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import { DatabaseSecret } from "./database-secret"; +import { Endpoint } from "./endpoint"; +import type { IClusterParameterGroup } from "./parameter-group"; +import { ClusterParameterGroup } from "./parameter-group"; +import type { IClusterSubnetGroup } from "./subnet-group"; +import { ClusterSubnetGroup } from "./subnet-group"; +import type { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; +import * as secretsmanager from "../../encryption"; +import type * as encryption from "../../encryption"; +import * as iam from "../../iam"; +import type { IBucket } from "../bucket"; + +/** + * Possible Node Types to use in the cluster + * used for defining `ClusterProps.nodeType`. + */ +export enum NodeType { + /** + * ds2.xlarge + */ + DS2_XLARGE = "ds2.xlarge", + + /** + * ds2.8xlarge + */ + DS2_8XLARGE = "ds2.8xlarge", + + /** + * dc1.large + */ + DC1_LARGE = "dc1.large", + + /** + * dc1.8xlarge + */ + DC1_8XLARGE = "dc1.8xlarge", + + /** + * dc2.large + */ + DC2_LARGE = "dc2.large", + + /** + * dc2.8xlarge + */ + DC2_8XLARGE = "dc2.8xlarge", + + /** + * ra3.large + */ + RA3_LARGE = "ra3.large", + + /** + * ra3.xlplus + */ + RA3_XLPLUS = "ra3.xlplus", + + /** + * ra3.4xlarge + */ + RA3_4XLARGE = "ra3.4xlarge", + + /** + * ra3.16xlarge + */ + RA3_16XLARGE = "ra3.16xlarge", +} + +/** + * What cluster type to use. + * Used by `ClusterProps.clusterType` + */ +export enum ClusterType { + /** + * single-node cluster, the `ClusterProps.numberOfNodes` parameter is not required + */ + SINGLE_NODE = "single-node", + /** + * multi-node cluster, set the amount of nodes using `ClusterProps.numberOfNodes` parameter + */ + MULTI_NODE = "multi-node", +} + +// TODO: omitted — upstream's `ResourceAction` enum and the `ClusterProps.resourceAction` prop that +// consumes it (`pause-cluster` / `resume-cluster` / `failover-primary-compute`) map onto +// `AWS::Redshift::Cluster.ResourceAction`, a CloudFormation-only mechanism: CloudFormation issues +// the corresponding Redshift API action (`PauseCluster`/`ResumeCluster`/`FailoverPrimaryCompute`) +// as a side effect of a stack update, rather than persisting the value as cluster state. The +// `aws_redshift_cluster` Terraform resource has no equivalent argument at all (verified against the +// full config shape in `node_modules/@cdktn/provider-aws/lib/redshift-cluster/index.d.ts` — there is +// no `resource_action`/`pause_cluster`/`resume_cluster` argument), so there is nothing to assign it +// to -- unlike the `addDefaultIamRole()`/`enableRebootForParameterChanges()` omissions above, this +// is not a Lambda-custom-resource gap that could theoretically be closed with a framework; it is a +// capability CloudFormation has that Terraform's Redshift resource simply does not expose — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L95-L115 +// export enum ResourceAction { +// PAUSE_CLUSTER = 'pause-cluster', +// RESUME_CLUSTER = 'resume-cluster', +// FAILOVER_PRIMARY_COMPUTE = 'failover-primary-compute', +// } + +/** + * Username and password combination + * + * TERRACONSTRUCTS DEVIATION: `masterPassword` is typed as a plain `string` instead of upstream's + * `core.SecretValue` (a CloudFormation dynamic-reference wrapper), which is not ported in this repo + * -- identical deviation to `Login.password` in `../docdb/props.ts` and `Credentials.password` in + * `../rds/props.ts`. The value may itself be an unresolved Token. + */ +export interface Login { + /** + * Username + */ + readonly masterUsername: string; + + /** + * Password + * + * Do not put passwords in your CDK code directly. + * + * @default - a Secrets Manager generated password + */ + readonly masterPassword?: string; + + /** + * KMS encryption key to encrypt the generated secret. + * + * @default - default master key + */ + readonly encryptionKey?: encryption.IKey; + + /** + * Characters to not include in the generated password. + * + * @default '"@/\\\ \'' + */ + readonly excludeCharacters?: string; +} + +/** + * Logging bucket and S3 prefix combination + */ +export interface LoggingProperties { + /** + * Bucket to send logs to. + * Logging information includes queries and connection attempts, for the specified Amazon Redshift cluster. + */ + readonly loggingBucket: IBucket; + + /** + * Prefix used for logging. + */ + readonly loggingKeyPrefix: string; +} + +/** + * Options to add the multi user rotation + */ +export interface RotationMultiUserOptions { + /** + * The secret to rotate. It must be a JSON string with the following format: + * ``` + * { + * "engine": , + * "host": , + * "username": , + * "password": , + * "dbname": , + * "port": , + * "masterarn": + * } + * ``` + */ + readonly secret: secretsmanager.ISecret; + + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default Duration.days(30) + */ + readonly automaticallyAfter?: Duration; +} + +/** + * The maintenance track for the cluster. + * + * @see https://docs.aws.amazon.com/redshift/latest/mgmt/managing-cluster-considerations.html#rs-mgmt-maintenance-tracks + */ +export enum MaintenanceTrackName { + /** + * Updated to the most recently certified maintenance release. + */ + CURRENT = "current", + + /** + * Update to the previously certified maintenance release. + */ + TRAILING = "trailing", +} + +/** + * Create a Redshift Cluster with a given number of nodes. + * Implemented by `Cluster` via `ClusterBase`. + * + * TODO: omitted — upstream also extends `aws_redshift.IClusterRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (identical omission to `IClusterParameterGroup` in + * `./parameter-group.ts` / `IClusterSubnetGroup` in `./subnet-group.ts`), so it is dropped. + */ +export interface ICluster + extends IAwsConstruct, + ec2.IConnectable, + secretsmanager.ISecretAttachmentTarget { + /** + * Name of the cluster + */ + readonly clusterName: string; + + /** + * The endpoint to use for read/write operations + */ + readonly clusterEndpoint: Endpoint; +} + +/** + * Properties that describe an existing cluster instance + */ +export interface ClusterAttributes { + /** + * The security groups of the redshift cluster + * + * @default no security groups will be attached to the import + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * Identifier for the cluster + */ + readonly clusterName: string; + + /** + * Cluster endpoint address + */ + readonly clusterEndpointAddress: string; + + /** + * Cluster endpoint port + */ + readonly clusterEndpointPort: number; +} + +/** + * Properties for a new database cluster + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `ClusterProps` does not — matching the base-idiom used throughout this repo + * (e.g. `DatabaseClusterProps` in `../docdb/cluster.ts` / `../neptune/cluster.ts`) for + * cross-account/-region construct placement. + */ +export interface ClusterProps extends AwsConstructProps { + /** + * An optional identifier for the cluster + * + * TERRACONSTRUCTS DEVIATION: when unnamed, upstream lets CloudFormation generate a name from the + * logical id; the repo invariant is a gridUUID-scoped `uniqueResourceName` default instead + * (mirroring `ClusterSubnetGroupProps.clusterSubnetGroupName` in `./subnet-group.ts`), lowercased + * to match Redshift's server-side storage convention (`ClusterIdentifier` is stored lowercase). + * + * @default - A gridUUID-scoped generated name is used. + */ + readonly clusterName?: string; + + /** + * Additional parameters to pass to the database engine + * https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-parameter-groups.html + * + * @default - No parameter group. + */ + readonly parameterGroup?: IClusterParameterGroup; + + /** + * Number of compute nodes in the cluster. Only specify this property for multi-node clusters. + * + * Value must be at least 2 and no more than 100. + * + * @default - 2 if `clusterType` is ClusterType.MULTI_NODE, undefined otherwise + */ + readonly numberOfNodes?: number; + + /** + * The node type to be provisioned for the cluster. + * + * @default `NodeType.RA3_LARGE` + */ + readonly nodeType?: NodeType; + + /** + * Settings for the individual instances that are launched + * + * @default `ClusterType.MULTI_NODE` + */ + readonly clusterType?: ClusterType; + + /** + * What port to listen on + * + * @default - The default for the engine is used. + */ + readonly port?: number; + + /** + * Whether to enable encryption of data at rest in the cluster. + * + * @default true + */ + readonly encrypted?: boolean; + + /** + * The KMS key to use for encryption of data at rest. + * + * TERRACONSTRUCTS DEVIATION: `encryption.IKey` instead of upstream's `kms.IKeyRef` (CDK's newer + * decoupled cross-region/-account KMS reference type, not ported here) -- mirrors `Login`'s own + * `encryptionKey` above and every other KMS-key prop across this repo's storage ports. + * + * @default - AWS-managed key, if encryption at rest is enabled + */ + readonly encryptionKey?: encryption.IKey; + + /** + * A preferred maintenance window day/time range. Should be specified as a range ddd:hh24:mi-ddd:hh24:mi (24H Clock UTC). + * + * Example: 'Sun:23:45-Mon:00:15' + * + * @default - 30-minute window selected at random from an 8-hour block of time for + * each AWS Region, occurring on a random day of the week. + * @see https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_UpgradeDBInstance.Maintenance.html#Concepts.DBMaintenance + */ + readonly preferredMaintenanceWindow?: string; + + /** + * The VPC to place the cluster in. + */ + readonly vpc: ec2.IVpc; + + /** + * Where to place the instances within the VPC + * + * @default - private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * Security group. + * + * @default - a new security group is created. + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * A cluster subnet group to use with this cluster. + * + * @default - a new subnet group will be created. + */ + readonly subnetGroup?: IClusterSubnetGroup; + + /** + * Username and password for the administrative user + */ + readonly masterUser: Login; + + /** + * A list of AWS Identity and Access Management (IAM) role that can be used by the cluster to access other AWS services. + * The maximum number of roles to attach to a cluster is subject to a quota. + * + * @default - No role is attached to the cluster. + */ + readonly roles?: iam.IRole[]; + + /** + * A single AWS Identity and Access Management (IAM) role to be used as the default role for the cluster. + * The default role must be included in the roles list. + * + * @default - No default role is specified for the cluster. + */ + readonly defaultRole?: iam.IRole; + + /** + * Name of a database which is automatically created inside the cluster + * + * @default - default_db + */ + readonly defaultDatabaseName?: string; + + /** + * Bucket details for log files to be sent to, including prefix. + * + * TERRACONSTRUCTS DEVIATION: synthesizes a standalone `aws_redshift_logging` resource (see the + * file-header SCOPE REDUCTION note) instead of the CFN `AWS::Redshift::Cluster.LoggingProperties` + * nested property -- provider 6.x moved Redshift audit logging off the cluster resource itself. + * + * @default - No logging bucket is used + */ + readonly loggingProperties?: LoggingProperties; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`, + // applied to the cluster, its auto-created subnet group, and its auto-created security group via + // `applyRemovalPolicy`) is CloudFormation's DeletionPolicy concept. `core.RemovalPolicy` is not + // ported anywhere in this repo (see the identical omission on `DatabaseClusterProps` in + // `../docdb/cluster.ts` / `../neptune/cluster.ts`). Terraform's `aws_redshift_cluster` exposes the + // cluster-level equivalent natively via `skipFinalSnapshot`/`finalSnapshotIdentifier` below -- the + // TERRACONSTRUCTS-native replacement -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L399-L403 + // 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 cluster. When `false` (the default) and `finalSnapshotIdentifier` is not set, + * `terraform destroy`/replace will FAIL at apply-time with an AWS API error (native + * `aws_redshift_cluster` behavior, not enforced here at synth time). Mirrors the identical + * `skipFinalSnapshot` prop on `../docdb/cluster.ts` / `../neptune/cluster.ts`. + * + * @default false (a final snapshot is taken on delete/replace, so `finalSnapshotIdentifier` should + * also be set) + */ + readonly skipFinalSnapshot?: boolean; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — see `skipFinalSnapshot` above. The identifier + * for the final DB cluster snapshot Terraform takes before destroying this cluster. Unlike + * CloudFormation (which auto-generates a snapshot name), Terraform requires this to be supplied + * explicitly. + * + * @default - no final snapshot identifier; required unless `skipFinalSnapshot` is `true` + */ + readonly finalSnapshotIdentifier?: string; + + /** + * Whether to make cluster publicly accessible. + * + * @default false + */ + readonly publiclyAccessible?: boolean; + + // TODO: omitted — upstream's `classicResizing?: boolean` (mapped onto + // `AWS::Redshift::Cluster.Classic`) has no Terraform-provider equivalent: the + // `aws_redshift_cluster` resource has no `classic`/`classic_resizing` argument at all (verified + // against the full config shape in + // `node_modules/@cdktn/provider-aws/lib/redshift-cluster/index.d.ts`). Classic vs. elastic resize + // is a one-time choice made by the Redshift `ModifyCluster`/`ResizeCluster` API call at resize + // time, not a persisted cluster attribute the Terraform AWS provider's `aws_redshift_cluster` + // resource models -- same root cause as the `resourceAction`/`ResourceAction` omission above — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L410-L421 + // readonly classicResizing?: boolean; + + /** + * The Elastic IP (EIP) address for the cluster. + * + * @see https://docs.aws.amazon.com/redshift/latest/mgmt/managing-clusters-vpc.html + * + * @default - No Elastic IP + */ + readonly elasticIp?: string; + + // TODO(scope-reduction): omitted — upstream's `rebootForParameterChanges?: boolean` triggers + // `enableRebootForParameterChanges()` (see the omission note on that method further down this + // file) from the constructor. Since the method it drives is fully commented out, the prop that + // triggers it is dropped alongside it — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L432-L436 + // readonly rebootForParameterChanges?: boolean; + + /** + * If this flag is set, Amazon Redshift forces all COPY and UNLOAD traffic between your cluster and your data repositories through your virtual private cloud (VPC). + * + * @see https://docs.aws.amazon.com/redshift/latest/mgmt/enhanced-vpc-routing.html + * + * @default - false + */ + readonly enhancedVpcRouting?: boolean; + + /** + * Indicating whether Amazon Redshift should deploy the cluster in two Availability Zones. + * + * @default - false + */ + readonly multiAz?: boolean; + + // TODO: omitted — upstream's `resourceAction?: ResourceAction`. See the `ResourceAction` enum + // omission note above for the full rationale. + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L454-L459 + // readonly resourceAction?: ResourceAction; + + /** + * Whether to enable relocation for an Amazon Redshift cluster between Availability Zones after the cluster is created. + * + * @see https://docs.aws.amazon.com/redshift/latest/mgmt/managing-cluster-recovery.html + * + * @default - false + */ + readonly availabilityZoneRelocation?: boolean; + + /** + * The maintenance track name for the cluster. + * + * @see https://docs.aws.amazon.com/redshift/latest/mgmt/managing-cluster-considerations.html#rs-mgmt-maintenance-tracks + * + * @default undefined - Redshift default is current + */ + readonly maintenanceTrackName?: MaintenanceTrackName; +} + +/** + * A new or imported clustered database. + */ +abstract class ClusterBase extends AwsConstructBase implements ICluster { + /** + * Name of the cluster + */ + public abstract readonly clusterName: string; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterEndpoint: Endpoint; + + /** + * Access to the network connections + */ + public abstract readonly connections: ec2.Connections; + + /** + * Renders the secret attachment target specifications. + * + * TERRACONSTRUCTS DEVIATION: mirrors `DatabaseClusterBase.asSecretAttachmentTarget()` in + * `../docdb/cluster.ts` — upstream returns only `{ targetId, targetType }` because + * CloudFormation's `AWS::SecretsManager::SecretTargetAttachment` resolves engine/host/port + * server-side from those fields. The Terraform AWS provider has no such server-side merge, so + * `connectionFields` is supplied here too, using `dbClusterIdentifier`/`engine: "redshift"` as + * documented for the Secrets Manager Redshift rotation templates — + * https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_secret_json_structure.html#reference_secret_json_structure_RS + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + return { + targetId: this.clusterName, + targetType: secretsmanager.AttachmentTargetType.REDSHIFT_CLUSTER, + connectionFields: { + dbClusterIdentifier: this.clusterName, + engine: "redshift", + host: this.clusterEndpoint.hostname, + // NUMBER-typed port token, stringified for embedding in the connection fields map. + port: Tokenization.stringifyNumber(this.clusterEndpoint.port), + }, + }; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `../docdb/cluster.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + identifier: this.clusterName, + endpointAddress: this.clusterEndpoint.hostname, + endpointPort: Tokenization.stringifyNumber(this.clusterEndpoint.port), + }; + } +} + +/** + * Create a Redshift cluster a given number of nodes. + * + * @resource aws_redshift_cluster + */ +export class Cluster extends ClusterBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.redshift.Cluster"; + + /** + * Import an existing DatabaseCluster from properties + */ + public static fromClusterAttributes( + scope: Construct, + id: string, + attrs: ClusterAttributes, + ): ICluster { + class Import extends ClusterBase { + public readonly connections = new ec2.Connections({ + securityGroups: attrs.securityGroups, + defaultPort: ec2.Port.tcp(attrs.clusterEndpointPort), + }); + public readonly clusterName = attrs.clusterName; + public readonly clusterEndpoint = new Endpoint( + attrs.clusterEndpointAddress, + attrs.clusterEndpointPort, + ); + } + return new Import(scope, id, {}); + } + + /** + * Identifier of the cluster + */ + public readonly clusterName: string; + + /** + * The endpoint to use for read/write operations + */ + public readonly clusterEndpoint: Endpoint; + + /** + * Access to the network connections + */ + public readonly connections: ec2.Connections; + + /** + * The secret attached to this cluster + */ + public readonly secret?: secretsmanager.ISecret; + + /** + * The VPC where the DB subnet group is created. + */ + private readonly vpc: ec2.IVpc; + + /** + * The subnets used by the DB subnet group. + */ + private readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * The underlying `aws_redshift_cluster` L1. + */ + private readonly resource: redshiftCluster.RedshiftCluster; + + /** + * The `clusterIdentifier` argument this construct passed to the underlying L1 (the literal + * name, or an unresolved Token for a cross-stack/parameterized name). + * + * TERRACONSTRUCTS DEVIATION: not present upstream. Upstream's `addToParameterGroup()` reads the + * identifier back via `this.cluster.clusterIdentifier` (a CFN L1 property getter, which simply + * echoes back the literal input value). The `aws_redshift_cluster` L1's `clusterIdentifier` + * GETTER instead always returns the synth-time-unresolved `cluster_identifier` COMPUTED + * attribute reference (`this.getStringAttribute('cluster_identifier')`), not the raw input -- + * embedding that reference inside a human-readable description string (see + * `addToParameterGroup()` below) would produce an opaque token marker instead of the literal + * name. This field captures the value actually passed in instead. + */ + private readonly clusterIdentifierInput: string; + + /** + * The cluster's parameter group + */ + protected parameterGroup?: IClusterParameterGroup; + + /** + * The IAM roles attached to the cluster. + * + * TERRACONSTRUCTS DEVIATION: upstream defers `iamRoles` to synth time via a lazy, mutable + * `IArrayBox` (`aws-cdk-lib/core/lib/helpers-internal`, CDK-internal and not ported here) so that + * roles added later via `addIamRole()`/`addDefaultIamRole()` are still reflected when the + * underlying `CfnCluster.iamRoles` token resolves. A plain mutable array plus `Lazy.listValue()` + * (public cdktn API) gives the same synth-time-deferred behavior: `this.roles` is captured by + * reference in the closure below, so mutations from `addIamRole()` before synth are picked up + * identically -- mirrors `UserGroup._users`/`Lazy.listValue()` in `../elasticache/user-group.ts`. + * + * **NOTE** Please do not push directly to this array; use `addIamRole()` instead. + */ + private readonly roles: iam.IRole[]; + + constructor(scope: Construct, id: string, props: ClusterProps) { + super(scope, id, props); + + this.vpc = props.vpc; + this.vpcSubnets = props.vpcSubnets ?? { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }; + this.parameterGroup = props.parameterGroup; + this.roles = [...(props.roles ?? [])]; + + const subnetGroup = + props.subnetGroup ?? + new ClusterSubnetGroup(this, "Subnets", { + description: `Subnets for ${id} Redshift cluster`, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + }); + + const securityGroups = props.securityGroups ?? [ + new ec2.SecurityGroup(this, "SecurityGroup", { + description: "Redshift security group", + vpc: this.vpc, + }), + ]; + + const securityGroupIds = securityGroups.map((sg) => sg.securityGroupId); + + // Create the secret manager secret if no password is specified + let secret: DatabaseSecret | undefined; + if (!props.masterUser.masterPassword) { + secret = new DatabaseSecret(this, "Secret", { + username: props.masterUser.masterUsername, + encryptionKey: props.masterUser.encryptionKey, + excludeCharacters: props.masterUser.excludeCharacters, + }); + } + + const clusterType = props.clusterType || ClusterType.MULTI_NODE; + const nodeCount = this.validateNodeCount(clusterType, props.numberOfNodes); + + if (props.encrypted === false && props.encryptionKey !== undefined) { + throw new ValidationError( + "Cannot set property encryptionKey without enabling encryption!", + this, + ); + } + + const nodeType = props.nodeType || NodeType.RA3_LARGE; + + if (props.multiAz) { + if (!nodeType.startsWith("ra3")) { + throw new ValidationError( + `Multi-AZ cluster is only supported for RA3 node types, got: ${props.nodeType}`, + this, + ); + } + if (clusterType === ClusterType.SINGLE_NODE) { + throw new ValidationError( + "Multi-AZ cluster is not supported for `clusterType` single-node", + this, + ); + } + } + + if (props.availabilityZoneRelocation && !nodeType.startsWith("ra3")) { + throw new ValidationError( + `Availability zone relocation is supported for only RA3 node types, got: ${props.nodeType}`, + this, + ); + } + + // TERRACONSTRUCTS DEVIATION: hoisted so it can be reused for both the cluster's + // `clusterIdentifier` argument and the `outputs`/logging-resource references below -- mirrors + // the identical `derivedClusterIdentifier` idiom in `../docdb/cluster.ts` / + // `../neptune/cluster.ts`. `ClusterIdentifier` allows up to 63 characters. + const clusterIdentifier = Token.isUnresolved(props.clusterName) + ? (props.clusterName as string) + : ( + props.clusterName ?? + this.stack.uniqueResourceName(this, { maxLength: 63 }) + ).toLowerCase(); + this.clusterIdentifierInput = clusterIdentifier; + + this.resource = new redshiftCluster.RedshiftCluster(this, "Resource", { + // Basic + allowVersionUpgrade: true, + maintenanceTrackName: props.maintenanceTrackName, + automatedSnapshotRetentionPeriod: 1, + clusterType, + clusterIdentifier, + clusterSubnetGroupName: subnetGroup.clusterSubnetGroupName, + vpcSecurityGroupIds: securityGroupIds, + port: props.port, + clusterParameterGroupName: + props.parameterGroup && props.parameterGroup.clusterParameterGroupName, + // Admin + masterUsername: props.masterUser.masterUsername, + masterPassword: secret + ? secret._generatedPassword + : props.masterUser.masterPassword, + preferredMaintenanceWindow: props.preferredMaintenanceWindow, + nodeType, + numberOfNodes: nodeCount, + iamRoles: Lazy.listValue({ + produce: () => this.roles.map((role) => role.roleArn), + }), + databaseName: props.defaultDatabaseName || "default_db", + publiclyAccessible: props.publiclyAccessible || false, + // Encryption + // ID-vs-ARN AUDIT: fed the KMS key ARN (not a bare key id) despite the Terraform argument's + // `kmsKeyId` name -- AWS always reports the ARN back on read for this field, so supplying the + // ARN up front avoids a perpetual "inconsistent result after apply" diff (the #151 kms + // lesson), mirrors `kmsKeyId: props.kmsKey?.keyArn` in `../docdb/cluster.ts`. + kmsKeyId: props.encryptionKey?.keyArn, + // TERRACONSTRUCTS DEVIATION: the `aws_redshift_cluster.encrypted` argument is typed `string` + // (not `bool`) in this provider binding (verified against + // `node_modules/@cdktn/provider-aws/lib/redshift-cluster/index.d.ts` and its + // `cdktn.stringToTerraform(this._encrypted)` synth path) -- unlike CloudFormation's boolean + // `Encrypted` property. The boolean is stringified before assignment. + encrypted: String(props.encrypted ?? true), + elasticIp: props.elasticIp, + enhancedVpcRouting: props.enhancedVpcRouting, + multiAz: props.multiAz, + availabilityZoneRelocationEnabled: props.availabilityZoneRelocation, + skipFinalSnapshot: props.skipFinalSnapshot, + finalSnapshotIdentifier: props.finalSnapshotIdentifier, + }); + + // TERRACONSTRUCTS DEVIATION: generated-password `ignore_changes` house pattern (see + // `DatabaseCluster`'s identical note in `../docdb/cluster.ts`) -- `secret` is only set here when + // a new `DatabaseSecret` was just generated for us, in which case `masterPassword` above is the + // SAME regenerating-on-every-plan `aws_secretsmanager_random_password` token stored in that + // secret. Without `ignore_changes`, every apply after the first would drift and REPLACE the live + // master password. + const ignoreChanges: string[] = []; + if (secret) { + ignoreChanges.push("master_password"); + } + if (ignoreChanges.length > 0) { + this.resource.addOverride("lifecycle.ignore_changes", ignoreChanges); + } + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `skipFinalSnapshot`/`finalSnapshotIdentifier` + // synth-time warning on `DatabaseCluster` in `../docdb/cluster.ts` / `../neptune/cluster.ts` — + // see that note for the full rationale. + if (props.skipFinalSnapshot !== true && !props.finalSnapshotIdentifier) { + Annotations.of(this).addWarning( + "Neither `skipFinalSnapshot` nor `finalSnapshotIdentifier` is set: `terraform destroy` (or any change that replaces this cluster) will FAIL at apply time because the AWS provider requires `finalSnapshotIdentifier` when `skipFinalSnapshot` is not `true`. Set `skipFinalSnapshot: true` to skip the final snapshot, or set `finalSnapshotIdentifier` to a snapshot name.", + ); + } + + this.clusterName = this.resource.clusterIdentifier; + + // TERRACONSTRUCTS DEVIATION: upstream converts `cluster.attrEndpointAddress`/ + // `cluster.attrEndpointPort` (separate CFN `Fn::GetAtt` attributes) into an `Endpoint` via + // `cdk.Token.asNumber(...)` for the port. The `aws_redshift_cluster` L1 has no split + // address/port endpoint attribute pair -- only a combined `endpoint` string (`host:port`) and a + // separate hostname-only `dns_name` attribute -- so `dnsName` (paired with the already + // number-typed `port` getter) is used here instead, mirroring the identical + // `cluster.endpoint`/`cluster.port` idiom in `../docdb/cluster.ts`. + this.clusterEndpoint = new Endpoint( + this.resource.dnsName, + this.resource.port, + ); + + if (secret) { + this.secret = secret.attach(this); + } + + const defaultPort = ec2.Port.tcp(this.clusterEndpoint.port); + this.connections = new ec2.Connections({ securityGroups, defaultPort }); + + // TERRACONSTRUCTS DEVIATION: see the file-header SCOPE REDUCTION note -- provider 6.x moved + // Redshift audit logging off the `aws_redshift_cluster` resource itself and onto a standalone + // `aws_redshift_logging` resource, which requires the cluster's `clusterIdentifier` and is + // therefore created after `this.resource` above. + if (props.loggingProperties) { + props.loggingProperties.loggingBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3:GetBucketAcl", "s3:PutObject"], + resources: [ + props.loggingProperties.loggingBucket.arnForObjects("*"), + props.loggingProperties.loggingBucket.bucketArn, + ], + principals: [new iam.ServicePrincipal("redshift.amazonaws.com")], + }), + ); + + new redshiftLogging.RedshiftLogging(this, "Logging", { + clusterIdentifier: this.clusterName, + logDestinationType: "s3", + bucketName: props.loggingProperties.loggingBucket.bucketName, + s3KeyPrefix: props.loggingProperties.loggingKeyPrefix, + }); + } + + // TODO(scope-reduction): omitted — upstream calls `this.enableRebootForParameterChanges()` here + // when `props.rebootForParameterChanges` is set. See the method's own omission note further + // down this file and the omission note on `ClusterProps.rebootForParameterChanges` above — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L727-L729 + // if (props.rebootForParameterChanges) { + // this.enableRebootForParameterChanges(); + // } + + // Add default role if specified and also available in the roles list + if (props.defaultRole) { + if (props.roles?.some((x) => x === props.defaultRole)) { + this.addDefaultIamRole(props.defaultRole); + } else { + throw new ValidationError( + "Default role must be included in role list.", + this, + ); + } + } + } + + public get outputs(): Record { + return { + ...super.outputs, + arn: this.resource.arn, + ...(this.secret && { secretArn: this.secret.secretArn }), + }; + } + + /** + * Adds the single user rotation of the master password to this cluster. + * + * @param [automaticallyAfter=Duration.days(30)] Specifies the number of days after the previous rotation + * before Secrets Manager triggers the next automatic rotation. + */ + public addRotationSingleUser( + automaticallyAfter?: Duration, + ): secretsmanager.SecretRotation { + if (!this.secret) { + throw new ValidationError( + "Cannot add single user rotation for a cluster without secret.", + this, + ); + } + + const id = "RotationSingleUser"; + const existing = this.node.tryFindChild(id); + if (existing) { + throw new ValidationError( + "A single user rotation was already added to this cluster.", + this, + ); + } + + return new secretsmanager.SecretRotation(this, id, { + secret: this.secret, + automaticallyAfter, + application: + secretsmanager.SecretRotationApplication.REDSHIFT_ROTATION_SINGLE_USER, + // TERRACONSTRUCTS DEVIATION: upstream redshift-alpha does NOT pass `excludeCharacters` here + // (it relies on the rotation Lambda's default exclusion set). Carried over from the + // `../docdb/cluster.ts` house pattern so rotated passwords honour the same exclusion set as + // the originally generated one. + excludeCharacters: (this.node.tryFindChild("Secret") as DatabaseSecret) + .excludeCharacters, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + target: this, + }); + } + + /** + * Adds the multi user rotation to this cluster. + */ + public addRotationMultiUser( + id: string, + options: RotationMultiUserOptions, + ): secretsmanager.SecretRotation { + if (!this.secret) { + throw new ValidationError( + "Cannot add multi user rotation for a cluster without secret.", + this, + ); + } + return new secretsmanager.SecretRotation(this, id, { + secret: options.secret, + masterSecret: this.secret, + automaticallyAfter: options.automaticallyAfter, + application: + secretsmanager.SecretRotationApplication.REDSHIFT_ROTATION_MULTI_USER, + // TERRACONSTRUCTS DEVIATION: upstream redshift-alpha does NOT pass `excludeCharacters` here + // — see the identical note in `addRotationSingleUser` above. + excludeCharacters: (this.node.tryFindChild("Secret") as DatabaseSecret) + .excludeCharacters, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + target: this, + }); + } + + private validateNodeCount( + clusterType: ClusterType, + numberOfNodes?: number, + ): number | undefined { + if (clusterType === ClusterType.SINGLE_NODE) { + // This property must not be set for single-node clusters; be generous and treat a value of 1 node as undefined. + if (numberOfNodes !== undefined && numberOfNodes !== 1) { + throw new ValidationError( + "Number of nodes must be not be supplied or be 1 for cluster type single-node", + this, + ); + } + return undefined; + } else { + if (Token.isUnresolved(numberOfNodes)) { + return numberOfNodes; + } + const nodeCount = numberOfNodes ?? 2; + if (nodeCount < 2 || nodeCount > 100) { + throw new ValidationError( + "Number of nodes for cluster type multi-node must be at least 2 and no more than 100", + this, + ); + } + return nodeCount; + } + } + + /** + * Adds a parameter to the Clusters' parameter group + * + * @param name the parameter name + * @param value the parameter name + */ + public addToParameterGroup(name: string, value: string): void { + if (!this.parameterGroup) { + const param: { [name: string]: string } = {}; + param[name] = value; + this.parameterGroup = new ClusterParameterGroup(this, "ParameterGroup", { + description: this.clusterIdentifierInput + ? `Parameter Group for the ${this.clusterIdentifierInput} Redshift cluster` + : "Cluster parameter group for family redshift-1.0", + parameters: param, + }); + this.resource.clusterParameterGroupName = + this.parameterGroup.clusterParameterGroupName; + } else if (this.parameterGroup instanceof ClusterParameterGroup) { + this.parameterGroup.addParameter(name, value); + } else { + throw new ValidationError( + "Cannot add a parameter to an imported parameter group.", + this, + ); + } + } + + // TODO(scope-reduction): omitted in this port. Upstream's `enableRebootForParameterChanges()` is + // backed entirely by a `Custom::RedshiftClusterRebooter` CloudFormation custom resource: a + // `lambda.SingletonFunction` (asset-bundled handler) invoked via a `cr.Provider`/`CustomResource` + // pair that calls the Redshift `RebootCluster` API whenever the cluster's parameter group content + // changes. TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s + // (Lambda-backed custom-resource lifecycle management with CREATE/UPDATE/DELETE event routing) in + // this repo yet, so this method is ported here verbatim but fully commented out, per the + // scope-reduction directive for this PR -- see the file-header SCOPE REDUCTION note and the + // `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION below for the sibling omission of the same root + // cause. Re-enabling this method is a de-commenting exercise once a custom-resource Lambda + // framework lands in this repo — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/cluster.ts#L829-L893 + // + // public enableRebootForParameterChanges(): void { + // if (this.node.tryFindChild('RedshiftClusterRebooterCustomResource')) { + // return; + // } + // const rebootFunction = new lambda.SingletonFunction(this, 'RedshiftClusterRebooterFunction', { + // uuid: '511e207f-13df-4b8b-b632-c32b30b65ac2', + // runtime: lambda.determineLatestNodeRuntime(this), + // code: lambda.Code.fromAsset(path.join(__dirname, '..', 'custom-resource-handlers', 'dist', 'aws-redshift-alpha', 'cluster-parameter-change-reboot-handler')), + // handler: 'index.handler', + // timeout: Duration.seconds(900), + // }); + // rebootFunction.addToRolePolicy(new iam.PolicyStatement({ + // actions: ['redshift:DescribeClusters'], + // resources: ['*'], + // })); + // rebootFunction.addToRolePolicy(new iam.PolicyStatement({ + // actions: ['redshift:RebootCluster'], + // resources: [ + // Stack.of(this).formatArn({ + // service: 'redshift', + // resource: 'cluster', + // resourceName: this.clusterName, + // arnFormat: ArnFormat.COLON_RESOURCE_NAME, + // }), + // ], + // })); + // const provider = new Provider(this, 'ResourceProvider', { + // onEventHandler: rebootFunction, + // }); + // const customResource = new CustomResource(this, 'RedshiftClusterRebooterCustomResource', { + // resourceType: 'Custom::RedshiftClusterRebooter', + // serviceToken: provider.serviceToken, + // properties: { + // ClusterId: this.clusterName, + // ParameterGroupName: Lazy.string({ + // produce: () => { + // if (!this.parameterGroup) { + // throw new ValidationError('Cannot enable reboot for parameter changes when there is no associated ClusterParameterGroup.', this); + // } + // return this.parameterGroup.clusterParameterGroupName; + // }, + // }), + // ParametersString: Lazy.string({ + // produce: () => { + // if (!(this.parameterGroup instanceof ClusterParameterGroup)) { + // throw new ValidationError('Cannot enable reboot for parameter changes when using an imported parameter group.', this); + // } + // return JSON.stringify(this.parameterGroup.parameters); + // }, + // }), + // }, + // }); + // Lazy.any({ + // produce: () => { + // if (!this.parameterGroup) { + // throw new ValidationError('Cannot enable reboot for parameter changes when there is no associated ClusterParameterGroup.', this); + // } + // customResource.node.addDependency(this, this.parameterGroup); + // }, + // }); + // } + + /** + * Adds default IAM role to cluster. The default IAM role must be already associated to the cluster to be added as the default role. + * + * TERRACONSTRUCTS DEVIATION: upstream shells out to the Redshift API via an `AwsCustomResource` + * (`modifyClusterIamRoles`, on both CREATE/UPDATE and DELETE) to set the cluster's default IAM + * role -- there is no CloudFormation property for it. The `aws_redshift_cluster` Terraform + * resource exposes the identical capability NATIVELY as the `default_iam_role_arn` argument (a + * strict improvement: no Lambda-backed custom resource, no extra `grantPassRole` IAM wiring, and + * `terraform destroy` naturally clears it along with the cluster). This method keeps upstream's + * public API (including the "must already be in the roles list" validation) but implements it by + * mutating the L1 resource's `defaultIamRoleArn` property directly instead. + * + * @param defaultIamRole the IAM role to be set as the default role + */ + public addDefaultIamRole(defaultIamRole: iam.IRole): void { + // Check to see if default role is included in list of cluster IAM roles + if (!this.roles.includes(defaultIamRole)) { + throw new ValidationError( + "Default role must be associated to the Redshift cluster to be set as the default role.", + this, + ); + } + + this.resource.defaultIamRoleArn = defaultIamRole.roleArn; + } + + /** + * Adds a role to the cluster + * + * @param role the role to add + */ + public addIamRole(role: iam.IRole): void { + if (this.roles.includes(role)) { + throw new ValidationError( + `Role '${role.roleArn}' is already attached to the cluster`, + this, + ); + } + + this.roles.push(role); + } +} diff --git a/src/aws/storage/redshift/database-options.ts b/src/aws/storage/redshift/database-options.ts new file mode 100644 index 00000000..b90e9deb --- /dev/null +++ b/src/aws/storage/redshift/database-options.ts @@ -0,0 +1,28 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/database-options.ts + +import type { ICluster } from "./cluster"; +import type * as secretsmanager from "../../encryption"; + +/** + * Properties for accessing a Redshift database + */ +export interface DatabaseOptions { + /** + * The cluster containing the database. + */ + readonly cluster: ICluster; + + /** + * The name of the database. + */ + readonly databaseName: string; + + /** + * The secret containing credentials to a Redshift user with administrator privileges. + * + * Secret JSON schema: `{ username: string; password: string }`. + * + * @default - the admin secret is taken from the cluster + */ + readonly adminUser?: secretsmanager.ISecret; +} diff --git a/src/aws/storage/redshift/database-secret.ts b/src/aws/storage/redshift/database-secret.ts new file mode 100644 index 00000000..fbb84631 --- /dev/null +++ b/src/aws/storage/redshift/database-secret.ts @@ -0,0 +1,71 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/database-secret.ts + +import type { Construct } from "constructs"; +import { Duration } from "../../../duration"; +import * as secretsmanager from "../../encryption"; +import type { IKey } from "../../encryption"; + +/** + * Construction properties for a DatabaseSecret. + */ +export interface DatabaseSecretProps { + /** + * The username. + */ + readonly username: string; + + /** + * The KMS key to use to encrypt the secret. + * + * @default default master key + */ + readonly encryptionKey?: IKey; + + /** + * Characters to not include in the generated password. + * + * @default '"@/\\\ \'' + */ + readonly excludeCharacters?: string; + + /** + * The number of days that Secrets Manager waits before it can delete the secret. + * + * TERRACONSTRUCTS DEVIATION: not present upstream (CloudFormation deletes secrets immediately + * on stack delete; deletion recovery is a Terraform-provider concept, + * `recovery_window_in_days`). Exposed as a pass-through to `encryption.SecretProps.recoveryWindow` + * so deterministic secret names can be re-created promptly (e.g. integ fixtures use + * `Duration.days(0)`). Mirrors the identical deviation on `../rds/database-secret.ts` + * (`../docdb/database-secret.ts` does not expose it, since upstream aws-docdb's `DatabaseSecret` + * has no `replaceOnPasswordCriteriaChanges`-adjacent lifecycle concerns tying into it either -- + * same as here). + * + * @default - AWS default of 30 days + */ + readonly recoveryWindow?: Duration; +} + +/** + * A database secret. + * + * @resource aws_secretsmanager_secret + */ +export class DatabaseSecret extends secretsmanager.Secret { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.redshift.DatabaseSecret"; + + constructor(scope: Construct, id: string, props: DatabaseSecretProps) { + super(scope, id, { + encryptionKey: props.encryptionKey, + generateSecretString: { + passwordLength: 30, // Redshift password could be up to 64 characters + secretStringTemplate: JSON.stringify({ username: props.username }), + generateStringKey: "password", + excludeCharacters: props.excludeCharacters ?? "\"@/\\ '", + }, + // TERRACONSTRUCTS DEVIATION: see the `recoveryWindow` prop note above. + recoveryWindow: props.recoveryWindow, + }); + } +} diff --git a/src/aws/storage/redshift/endpoint.ts b/src/aws/storage/redshift/endpoint.ts new file mode 100644 index 00000000..fdec90aa --- /dev/null +++ b/src/aws/storage/redshift/endpoint.ts @@ -0,0 +1,41 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/endpoint.ts + +import { Token } from "cdktn"; + +/** + * Connection endpoint of a redshift cluster + * + * Consists of a combination of hostname and port. + * + * TERRACONSTRUCTS DEVIATION: upstream memoizes `socketAddress` via the internal + * `@memoizedGetter` decorator (`aws-cdk-lib/core/lib/helpers-internal`), which this repo does not + * port (identical omission to every other `Endpoint` port in this repo, e.g. `../rds/endpoint.ts`, + * `../docdb/endpoint.ts`, and `../neptune/endpoint.ts`) -- `socketAddress` below is a plain + * (unmemoized) getter instead. + */ +export class Endpoint { + /** + * The hostname of the endpoint + */ + public readonly hostname: string; + + /** + * The port of the endpoint + */ + public readonly port: number; + + constructor(address: string, port: number) { + this.hostname = address; + this.port = port; + } + + /** + * The combination of "HOSTNAME:PORT" for this endpoint + */ + public get socketAddress(): string { + const portDesc = Token.isUnresolved(this.port) + ? Token.asString(this.port) + : this.port; + return `${this.hostname}:${portDesc}`; + } +} diff --git a/src/aws/storage/redshift/index.ts b/src/aws/storage/redshift/index.ts new file mode 100644 index 00000000..7cafb0a7 --- /dev/null +++ b/src/aws/storage/redshift/index.ts @@ -0,0 +1,32 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/index.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-redshift-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +export * from "./cluster"; +export * from "./parameter-group"; +export * from "./database-options"; +export * from "./database-secret"; +export * from "./endpoint"; +export * from "./subnet-group"; + +// TODO(scope-reduction): omitted — `./table` and `./user` (upstream's `Table`/`ITable`, +// `User`/`IUser`, and their shared `private/` custom-resource machinery) are ported in this repo +// as FULLY COMMENTED-OUT files, not re-exported here. They are backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource (a Lambda function under +// `private/database-query-provider/`) that TerraConstructs has no framework equivalent for yet. +// See the leading TODO block in `./table.ts` / `./user.ts` / `./private/database-query.ts` for the +// full rationale and permalinks; re-enabling these exports is a de-commenting exercise once a +// custom-resource Lambda framework lands in this repo — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/index.ts#L7-L8 +// export * from "./table"; +// export * from "./user"; + +// TODO: omitted — upstream also re-exports the generated CFN L1 (`./redshift.generated`, i.e. +// `CfnCluster`/`CfnClusterParameterGroup`/`CfnClusterSubnetGroup`/`CfnEndpointAccess`/ +// `CfnEndpointAuthorization`/`CfnScheduledAction`). This repo has no CloudFormation-generated L1 +// layer to re-export (Terraform L1s come from `@cdktn/provider-aws` instead, already consumed +// directly by `./cluster.ts`/`./parameter-group.ts`/`./subnet-group.ts`) — identical omission to +// every other ported module in this repo (e.g. `../neptune/index.ts`, `../docdb/index.ts`) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/index.ts diff --git a/src/aws/storage/redshift/parameter-group.ts b/src/aws/storage/redshift/parameter-group.ts new file mode 100644 index 00000000..b8614643 --- /dev/null +++ b/src/aws/storage/redshift/parameter-group.ts @@ -0,0 +1,186 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/parameter-group.ts + +import { redshiftParameterGroup } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import { ValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; + +/** + * A parameter group + * + * TODO: omitted — upstream also extends `aws_redshift.IClusterParameterGroupRef`, a + * CloudFormation cross-stack "Reference" marker interface generated from the CFN resource spec. + * TerraConstructs has no equivalent generated-reference layer (identical omission to + * `IClusterParameterGroup` in `../docdb/parameter-group.ts`), so it is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/parameter-group.ts#L6-L13 + */ +export interface IClusterParameterGroup extends IAwsConstruct { + /** + * The name of this parameter group + */ + readonly clusterParameterGroupName: string; +} + +/** + * A new cluster or instance parameter group + */ +abstract class ClusterParameterGroupBase + extends AwsConstructBase + implements IClusterParameterGroup +{ + /** + * The name of the parameter group + */ + public abstract readonly clusterParameterGroupName: string; +} + +/** + * Properties for a parameter group + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `ClusterParameterGroupProps` does not — matching the base-idiom used + * throughout this repo (e.g. `ClusterParameterGroupProps` in `../docdb/parameter-group.ts`) for + * cross-account/-region construct placement. + */ +export interface ClusterParameterGroupProps extends AwsConstructProps { + /** + * Description for this parameter group + * + * @default a CDK generated description + */ + readonly description?: string; + + /** + * The name of the cluster parameter group + * + * TERRACONSTRUCTS DEVIATION: not present upstream (CloudFormation always generates the + * physical name from the logical id for `AWS::Redshift::ClusterParameterGroup`, which has no + * `ParameterGroupName` property to accept an explicit name). The underlying + * `aws_redshift_parameter_group` Terraform resource requires a `name` argument, so this repo + * exposes it as an optional prop (mirroring `ClusterParameterGroupProps.dbClusterParameterGroupName` + * in `../docdb/parameter-group.ts`) with a gridUUID-scoped generated default. + * + * @default - a gridUUID-scoped generated name + */ + readonly clusterParameterGroupName?: string; + + /** + * The parameters in this parameter group + */ + readonly parameters: { [name: string]: string }; +} + +/** + * A cluster parameter group + * + * @resource aws_redshift_parameter_group + */ +export class ClusterParameterGroup + extends ClusterParameterGroupBase + implements IClusterParameterGroup +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.redshift.ClusterParameterGroup"; + + /** + * Imports a parameter group + */ + public static fromClusterParameterGroupName( + scope: Construct, + id: string, + clusterParameterGroupName: string, + ): IClusterParameterGroup { + class Import extends AwsConstructBase implements IClusterParameterGroup { + public readonly clusterParameterGroupName = clusterParameterGroupName; + public get outputs(): Record { + return { name: this.clusterParameterGroupName }; + } + } + return new Import(scope, id); + } + + /** + * The name of the parameter group + */ + public readonly clusterParameterGroupName: string; + + /** + * The parameters in the parameter group + */ + public readonly parameters: { [name: string]: string }; + + /** + * The underlying RedshiftParameterGroup + */ + private readonly resource: redshiftParameterGroup.RedshiftParameterGroup; + + constructor(scope: Construct, id: string, props: ClusterParameterGroupProps) { + super(scope, id, props); + + this.parameters = props.parameters; + this.resource = new redshiftParameterGroup.RedshiftParameterGroup( + this, + "Resource", + { + // TERRACONSTRUCTS DEVIATION: see the naming-default note on + // `ClusterParameterGroupProps.clusterParameterGroupName` above. Names are stored by + // Redshift lowercased on the server side, so caller-supplied names are lowercased too + // (guarded for unresolved tokens) — same shape as `ClusterSubnetGroup` in + // `./subnet-group.ts` and `Cluster` in `./cluster.ts`. + name: Token.isUnresolved(props.clusterParameterGroupName) + ? (props.clusterParameterGroupName as string) + : ( + props.clusterParameterGroupName ?? + this.stack.uniqueResourceName(this) + ).toLowerCase(), + description: + props.description || + "Cluster parameter group for family redshift-1.0", + family: "redshift-1.0", + parameter: this.parseParameters(), + }, + ); + + this.clusterParameterGroupName = this.resource.name; + } + + private parseParameters(): { name: string; value: string }[] { + return Object.entries(this.parameters).map(([name, value]) => { + return { name, value }; + }); + } + + /** + * Adds a parameter to the parameter group + * + * @param name the parameter name + * @param value the parameter name + */ + public addParameter(name: string, value: string): void { + const existingValue = Object.entries(this.parameters).find( + ([key, _]) => key === name, + )?.[1]; + if (existingValue === undefined) { + this.parameters[name] = value; + this.resource.putParameter(this.parseParameters()); + } else if (existingValue !== value) { + throw new ValidationError( + `The parameter group already contains the parameter "${name}", but with a different value (Given: ${value}, Existing: ${existingValue}).`, + this, + ); + } + } + + public get outputs(): Record { + return { + name: this.clusterParameterGroupName, + arn: this.resource.arn, + }; + } +} diff --git a/src/aws/storage/redshift/private/database-query-provider/escape.ts b/src/aws/storage/redshift/private/database-query-provider/escape.ts new file mode 100644 index 00000000..61d4c042 --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/escape.ts @@ -0,0 +1,50 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/escape.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/escape.ts -- +// +// /** A valid bare identifier (letter/underscore then letters, digits, `_`, or `$`); needs no quoting. */ +// const BARE_IDENTIFIER = /^[\p{L}_][\p{L}\p{N}_$]*$/u; +// +// /** Returns a bare-safe identifier unchanged; otherwise double-quotes it, doubling embedded double quotes. */ +// export function quoteIdentifier(identifier: string): string { +// if (BARE_IDENTIFIER.test(identifier)) { +// return identifier; +// } +// return `"${identifier.replace(/"/g, '""')}"`; +// } +// +// /** Applies {@link quoteIdentifier} to each `.`-separated component, keeping the dot separator. */ +// export function quoteQualifiedIdentifier(qualified: string): string { +// return qualified.split('.').map(quoteIdentifier).join('.'); +// } +// +// /** Quotes a SQL string literal, doubling any embedded single quote. */ +// export function quoteLiteral(literal: string): string { +// return `'${literal.replace(/'/g, "''")}'`; +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/escape.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/handler-name.ts b/src/aws/storage/redshift/private/database-query-provider/handler-name.ts new file mode 100644 index 00000000..aaefd89d --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/handler-name.ts @@ -0,0 +1,35 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/handler-name.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/handler-name.ts -- +// +// export enum HandlerName { +// User = 'user', +// Table = 'table', +// UserTablePrivileges = 'user-table-privileges', +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/handler-name.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/index.ts b/src/aws/storage/redshift/private/database-query-provider/index.ts new file mode 100644 index 00000000..adf75b20 --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/index.ts @@ -0,0 +1,50 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/index.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/index.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// import { HandlerName } from './handler-name'; +// import { handler as managePrivileges } from './privileges'; +// import { handler as manageTable } from './table'; +// import { handler as manageUser } from './user'; +// +// const HANDLERS: { [key in HandlerName]: ((props: any, event: AWSLambda.CloudFormationCustomResourceEvent) => Promise) } = { +// [HandlerName.Table]: manageTable, +// [HandlerName.User]: manageUser, +// [HandlerName.UserTablePrivileges]: managePrivileges, +// }; +// +// export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) { +// const subHandler = HANDLERS[event.ResourceProperties.handler as HandlerName]; +// if (!subHandler) { +// throw new Error(`Requested handler ${event.ResourceProperties.handler} is not in supported set: ${JSON.stringify(Object.keys(HANDLERS))}`); +// } +// return subHandler(event.ResourceProperties, event); +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/index.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/privileges.ts b/src/aws/storage/redshift/private/database-query-provider/privileges.ts new file mode 100644 index 00000000..a4b1b20e --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/privileges.ts @@ -0,0 +1,154 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/privileges.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/privileges.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// import type { TablePrivilege, UserTablePrivilegesHandlerProps } from '../handler-props'; +// import { quoteIdentifier, quoteQualifiedIdentifier } from './escape'; +// import { executeStatement } from './redshift-data'; +// import type { ClusterProps } from './types'; +// import { makePhysicalId } from './util'; +// +// export async function handler(props: UserTablePrivilegesHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) { +// const username = props.username; +// const tablePrivileges = props.tablePrivileges; +// const clusterProps = props; +// +// if (event.RequestType === 'Create') { +// await grantPrivileges(username, tablePrivileges, clusterProps, event.StackId); +// return { PhysicalResourceId: makePhysicalId(username, clusterProps, event.RequestId) }; +// } else if (event.RequestType === 'Delete') { +// await revokePrivileges(username, tablePrivileges, clusterProps, event.StackId); +// return; +// } else if (event.RequestType === 'Update') { +// const { replace } = await updatePrivileges( +// username, +// tablePrivileges, +// clusterProps, +// event.OldResourceProperties as unknown as UserTablePrivilegesHandlerProps & ClusterProps, +// event.StackId, +// ); +// const physicalId = replace ? makePhysicalId(username, clusterProps, event.RequestId) : event.PhysicalResourceId; +// return { PhysicalResourceId: physicalId }; +// } else { +// /* eslint-disable-next-line dot-notation */ +// throw new Error(`Unrecognized event type: ${event['RequestType']}`); +// } +// } +// +// async function revokePrivileges( +// username: string, +// tablePrivileges: TablePrivilege[], +// clusterProps: ClusterProps, +// stackId: string, +// ) { +// // Limited by human input +// // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism +// await Promise.all(tablePrivileges.map(({ tableName, actions }) => { +// return executeStatement( +// `REVOKE ${actions.join(', ')} ON ${quoteQualifiedIdentifier(normalizedTableName(tableName, stackId))} FROM ${quoteIdentifier(username)}`, +// clusterProps, +// ); +// })); +// } +// +// async function grantPrivileges( +// username: string, +// tablePrivileges: TablePrivilege[], +// clusterProps: ClusterProps, +// stackId: string, +// ) { +// // Limited by human input +// // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism +// await Promise.all(tablePrivileges.map(({ tableName, actions }) => { +// return executeStatement( +// `GRANT ${actions.join(', ')} ON ${quoteQualifiedIdentifier(normalizedTableName(tableName, stackId))} TO ${quoteIdentifier(username)}`, +// clusterProps, +// ); +// })); +// } +// +// async function updatePrivileges( +// username: string, +// tablePrivileges: TablePrivilege[], +// clusterProps: ClusterProps, +// oldResourceProperties: UserTablePrivilegesHandlerProps & ClusterProps, +// stackId: string, +// ): Promise<{ replace: boolean }> { +// const oldClusterProps = oldResourceProperties; +// if (clusterProps.clusterName !== oldClusterProps.clusterName || clusterProps.databaseName !== oldClusterProps.databaseName) { +// await grantPrivileges(username, tablePrivileges, clusterProps, stackId); +// return { replace: true }; +// } +// +// const oldUsername = oldResourceProperties.username; +// if (oldUsername !== username) { +// await grantPrivileges(username, tablePrivileges, clusterProps, stackId); +// return { replace: true }; +// } +// +// const oldTablePrivileges = oldResourceProperties.tablePrivileges; +// const tablesToRevoke = oldTablePrivileges.filter(({ tableId, actions }) => ( +// tablePrivileges.find(({ tableId: otherTableId, actions: otherActions }) => ( +// tableId === otherTableId && actions.some(action => !otherActions.includes(action)) +// )) +// )); +// if (tablesToRevoke.length > 0) { +// await revokePrivileges(username, tablesToRevoke, clusterProps, stackId); +// } +// +// const tablesToGrant = tablePrivileges.filter(({ tableId, tableName, actions }) => { +// const tableAdded = !oldTablePrivileges.find(({ tableId: otherTableId, tableName: otherTableName }) => ( +// tableId === otherTableId && tableName === otherTableName +// )); +// const actionsAdded = oldTablePrivileges.find(({ tableId: otherTableId, actions: otherActions }) => ( +// tableId === otherTableId && otherActions.some(action => !actions.includes(action)) +// )); +// return tableAdded || actionsAdded; +// }); +// if (tablesToGrant.length > 0) { +// await grantPrivileges(username, tablesToGrant, clusterProps, stackId); +// } +// +// return { replace: false }; +// } +// +// /** +// * We need this normalization logic because some of the `TableName` values +// * are physical IDs generated in the {@link makePhysicalId} function. +// * */ +// const normalizedTableName = (tableName: string, stackId: string): string => { +// const segments = tableName.split(':'); +// const suffix = segments.slice(-1); +// if (suffix != null && stackId.endsWith(suffix[0])) { +// return segments.slice(-2)[0] ?? tableName; +// } +// return tableName; +// }; +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/privileges.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/redshift-data.ts b/src/aws/storage/redshift/private/database-query-provider/redshift-data.ts new file mode 100644 index 00000000..16390be3 --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/redshift-data.ts @@ -0,0 +1,64 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/redshift-data.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/redshift-data.ts -- +// +// /* eslint-disable-next-line import/no-extraneous-dependencies */ +// import { RedshiftData } from '@aws-sdk/client-redshift-data'; +// import type { ClusterProps } from './types'; +// +// const redshiftData = new RedshiftData({}); +// +// export async function executeStatement(statement: string, clusterProps: ClusterProps): Promise { +// const executeStatementProps = { +// ClusterIdentifier: clusterProps.clusterName, +// Database: clusterProps.databaseName, +// SecretArn: clusterProps.adminUserArn, +// Sql: statement, +// }; +// const executedStatement = await redshiftData.executeStatement(executeStatementProps); +// if (!executedStatement.Id) { +// throw new Error('Service error: Statement execution did not return a statement ID'); +// } +// await waitForStatementComplete(executedStatement.Id); +// } +// +// const waitTimeout = 100; +// async function waitForStatementComplete(statementId: string): Promise { +// await new Promise((resolve: (value: void) => void) => { +// setTimeout(() => resolve(), waitTimeout); +// }); +// const statement = await redshiftData.describeStatement({ Id: statementId }); +// if (statement.Status !== 'FINISHED' && statement.Status !== 'FAILED' && statement.Status !== 'ABORTED') { +// return waitForStatementComplete(statementId); +// } else if (statement.Status === 'FINISHED') { +// return; +// } else { +// throw new Error(`Statement status was ${statement.Status}: ${statement.Error}`); +// } +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/redshift-data.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/table.ts b/src/aws/storage/redshift/private/database-query-provider/table.ts new file mode 100644 index 00000000..ce8f9fbf --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/table.ts @@ -0,0 +1,270 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/table.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/table.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// import { quoteIdentifier, quoteLiteral } from './escape'; +// import { executeStatement } from './redshift-data'; +// import type { ClusterProps, TableAndClusterProps } from './types'; +// import { TableSortStyle } from './types'; +// import { areColumnsEqual, getDistKeyColumn, getSortKeyColumns, makePhysicalId } from './util'; +// import type { Column } from '../../table'; +// +// export async function handler(props: TableAndClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) { +// const tableNamePrefix = props.tableName.prefix; +// const getTableNameSuffix = (generateSuffix: string) => generateSuffix === 'true' ? `${event.StackId.substring(event.StackId.length - 12)}` : ''; +// const tableColumns = props.tableColumns; +// const tableAndClusterProps = props; +// const useColumnIds = props.useColumnIds; +// let tableName = tableNamePrefix + getTableNameSuffix(props.tableName.generateSuffix); +// +// if (event.RequestType === 'Create') { +// tableName = await createTable(tableNamePrefix, getTableNameSuffix(props.tableName.generateSuffix), tableColumns, tableAndClusterProps); +// return { PhysicalResourceId: makePhysicalId(tableName, tableAndClusterProps, event.StackId.substring(event.StackId.length - 12)) }; +// } else if (event.RequestType === 'Delete') { +// await dropTable( +// event.PhysicalResourceId.includes(event.StackId.substring(event.StackId.length - 12)) ? tableName : event.PhysicalResourceId, +// tableAndClusterProps, +// ); +// return; +// } else if (event.RequestType === 'Update') { +// const isTableV2 = event.PhysicalResourceId.includes(event.StackId.substring(event.StackId.length - 12)); +// const oldTableName = event.OldResourceProperties.tableName.prefix + getTableNameSuffix(event.OldResourceProperties.tableName.generateSuffix); +// tableName = await updateTable( +// isTableV2 ? oldTableName : event.PhysicalResourceId, +// tableNamePrefix, +// getTableNameSuffix(props.tableName.generateSuffix), +// tableColumns, +// useColumnIds, +// tableAndClusterProps, +// event.OldResourceProperties as unknown as TableAndClusterProps, +// isTableV2, +// ); +// return { PhysicalResourceId: event.PhysicalResourceId }; +// } else { +// /* eslint-disable-next-line dot-notation */ +// throw new Error(`Unrecognized event type: ${event['RequestType']}`); +// } +// } +// +// async function createTable( +// tableNamePrefix: string, +// tableNameSuffix: string, +// tableColumns: Column[], +// tableAndClusterProps: TableAndClusterProps, +// ): Promise { +// const tableName = tableNamePrefix + tableNameSuffix; +// const tableColumnsString = tableColumns.map(column => `${quoteIdentifier(column.name)} ${column.dataType}${getEncodingColumnString(column)}`).join(); +// +// let statement = `CREATE TABLE ${quoteIdentifier(tableName)} (${tableColumnsString})`; +// +// if (tableAndClusterProps.distStyle) { +// statement += ` DISTSTYLE ${tableAndClusterProps.distStyle}`; +// } +// +// const distKeyColumn = getDistKeyColumn(tableColumns); +// if (distKeyColumn) { +// statement += ` DISTKEY(${quoteIdentifier(distKeyColumn.name)})`; +// } +// +// const sortKeyColumns = getSortKeyColumns(tableColumns); +// if (sortKeyColumns.length > 0) { +// const sortKeyColumnsString = getSortKeyColumnsString(sortKeyColumns); +// statement += ` ${tableAndClusterProps.sortStyle} SORTKEY(${sortKeyColumnsString})`; +// } +// +// await executeStatement(statement, tableAndClusterProps); +// +// for (const column of tableColumns) { +// if (column.comment) { +// await executeStatement(`COMMENT ON COLUMN ${quoteIdentifier(tableName)}.${quoteIdentifier(column.name)} IS ${quoteLiteral(column.comment)}`, tableAndClusterProps); +// } +// } +// if (tableAndClusterProps.tableComment) { +// await executeStatement(`COMMENT ON TABLE ${quoteIdentifier(tableName)} IS ${quoteLiteral(tableAndClusterProps.tableComment)}`, tableAndClusterProps); +// } +// +// return tableName; +// } +// +// async function dropTable(tableName: string, clusterProps: ClusterProps) { +// await executeStatement(`DROP TABLE ${quoteIdentifier(tableName)}`, clusterProps); +// } +// +// async function updateTable( +// tableName: string, +// tableNamePrefix: string, +// tableNameSuffix: string, +// tableColumns: Column[], +// useColumnIds: boolean, +// tableAndClusterProps: TableAndClusterProps, +// oldResourceProperties: TableAndClusterProps, +// isTableV2: boolean, +// ): Promise { +// const alterationStatements: string[] = []; +// const newTableName = tableNamePrefix + tableNameSuffix; +// +// const oldClusterProps = oldResourceProperties; +// if (tableAndClusterProps.clusterName !== oldClusterProps.clusterName || tableAndClusterProps.databaseName !== oldClusterProps.databaseName) { +// return createTable(tableNamePrefix, tableNameSuffix, tableColumns, tableAndClusterProps); +// } +// +// const oldTableColumns = oldResourceProperties.tableColumns; +// const columnDeletions = oldTableColumns.filter(oldColumn => ( +// tableColumns.every(column => { +// if (useColumnIds) { +// return oldColumn.id ? oldColumn.id !== column.id : oldColumn.name !== column.name; +// } +// return oldColumn.name !== column.name; +// }) +// )); +// if (columnDeletions.length > 0) { +// alterationStatements.push(...columnDeletions.map(column => `ALTER TABLE ${quoteIdentifier(tableName)} DROP COLUMN ${quoteIdentifier(column.name)}`)); +// } +// +// const columnAdditions = tableColumns.filter(column => { +// return !oldTableColumns.some(oldColumn => { +// if (useColumnIds) { +// return oldColumn.id ? oldColumn.id === column.id : oldColumn.name === column.name; +// } +// return oldColumn.name === column.name; +// }); +// }).map(column => `ADD ${quoteIdentifier(column.name)} ${column.dataType}`); +// if (columnAdditions.length > 0) { +// alterationStatements.push(...columnAdditions.map(addition => `ALTER TABLE ${quoteIdentifier(tableName)} ${addition}`)); +// } +// +// const columnEncoding = tableColumns.filter(column => { +// return oldTableColumns.some(oldColumn => column.name === oldColumn.name && column.encoding !== oldColumn.encoding); +// }).map(column => `ALTER COLUMN ${quoteIdentifier(column.name)} ENCODE ${column.encoding || 'AUTO'}`); +// if (columnEncoding.length > 0) { +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ${columnEncoding.join(', ')}`); +// } +// +// const columnComments = tableColumns.filter(column => { +// return oldTableColumns.some(oldColumn => column.name === oldColumn.name && column.comment !== oldColumn.comment); +// }).map(column => `COMMENT ON COLUMN ${quoteIdentifier(tableName)}.${quoteIdentifier(column.name)} IS ${column.comment ? quoteLiteral(column.comment) : 'NULL'}`); +// if (columnComments.length > 0) { +// alterationStatements.push(...columnComments); +// } +// +// if (useColumnIds) { +// const columnNameUpdates = tableColumns.reduce((updates, column) => { +// const oldColumn = oldTableColumns.find(oldCol => oldCol.id && oldCol.id === column.id); +// if (oldColumn && oldColumn.name !== column.name) { +// updates[oldColumn.name] = column.name; +// } +// return updates; +// }, {} as Record); +// if (Object.keys(columnNameUpdates).length > 0) { +// alterationStatements.push(...Object.entries(columnNameUpdates).map(([oldName, newName]) => ( +// `ALTER TABLE ${quoteIdentifier(tableName)} RENAME COLUMN ${quoteIdentifier(oldName)} TO ${quoteIdentifier(newName)}` +// ))); +// } +// } +// +// const oldDistStyle = oldResourceProperties.distStyle; +// if ((!oldDistStyle && tableAndClusterProps.distStyle) || +// (oldDistStyle && !tableAndClusterProps.distStyle)) { +// return createTable(tableNamePrefix, tableNameSuffix, tableColumns, tableAndClusterProps); +// } else if (oldDistStyle !== tableAndClusterProps.distStyle) { +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER DISTSTYLE ${tableAndClusterProps.distStyle}`); +// } +// +// const oldDistKey = getDistKeyColumn(oldTableColumns)?.name; +// const newDistKey = getDistKeyColumn(tableColumns)?.name; +// if (!oldDistKey && newDistKey) { +// // Table has no existing distribution key, add a new one +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER DISTSTYLE KEY DISTKEY ${quoteIdentifier(newDistKey)}`); +// } else if (oldDistKey && !newDistKey) { +// // Table has a distribution key, remove and set to AUTO +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER DISTSTYLE AUTO`); +// } else if (oldDistKey !== newDistKey) { +// // Table has an existing distribution key, change it +// // (both keys are defined here; the undefined cases are handled by the branches above) +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER DISTKEY ${quoteIdentifier(newDistKey!)}`); +// } +// +// const oldSortKeyColumns = getSortKeyColumns(oldTableColumns); +// const newSortKeyColumns = getSortKeyColumns(tableColumns); +// const oldSortStyle = oldResourceProperties.sortStyle; +// const newSortStyle = tableAndClusterProps.sortStyle; +// if ((oldSortStyle === newSortStyle && !areColumnsEqual(oldSortKeyColumns, newSortKeyColumns)) +// || (oldSortStyle !== newSortStyle)) { +// switch (newSortStyle) { +// case TableSortStyle.INTERLEAVED: +// // INTERLEAVED sort key addition requires replacement. +// // https://docs.aws.amazon.com/redshift/latest/dg/r_ALTER_TABLE.html +// return createTable(tableNamePrefix, tableNameSuffix, tableColumns, tableAndClusterProps); +// +// case TableSortStyle.COMPOUND: { +// const sortKeyColumnsString = getSortKeyColumnsString(newSortKeyColumns); +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER ${newSortStyle} SORTKEY(${sortKeyColumnsString})`); +// break; +// } +// +// case TableSortStyle.AUTO: { +// alterationStatements.push(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER SORTKEY ${newSortStyle}`); +// break; +// } +// } +// } +// +// const oldComment = oldResourceProperties.tableComment; +// const newComment = tableAndClusterProps.tableComment; +// if (oldComment !== newComment) { +// alterationStatements.push(`COMMENT ON TABLE ${quoteIdentifier(tableName)} IS ${newComment ? quoteLiteral(newComment) : 'NULL'}`); +// } +// +// // Limited by human input +// // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism +// await Promise.all(alterationStatements.map(statement => executeStatement(statement, tableAndClusterProps))); +// +// if (isTableV2) { +// const oldTableNamePrefix = oldResourceProperties.tableName.prefix; +// if (tableNamePrefix !== oldTableNamePrefix) { +// await executeStatement(`ALTER TABLE ${quoteIdentifier(tableName)} RENAME TO ${quoteIdentifier(newTableName)}`, tableAndClusterProps); +// return tableNamePrefix + tableNameSuffix; +// } +// } +// +// return tableName; +// } +// +// function getSortKeyColumnsString(sortKeyColumns: Column[]) { +// return sortKeyColumns.map(column => quoteIdentifier(column.name)).join(); +// } +// +// function getEncodingColumnString(column: Column): string { +// if (column.encoding) { +// return ` ENCODE ${column.encoding}`; +// } +// return ''; +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/table.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/types.ts b/src/aws/storage/redshift/private/database-query-provider/types.ts new file mode 100644 index 00000000..f7032962 --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/types.ts @@ -0,0 +1,56 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/types.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/types.ts -- +// +// import type { DatabaseQueryHandlerProps, TableHandlerProps } from '../handler-props'; +// +// export type ClusterProps = Omit; +// export type TableAndClusterProps = TableHandlerProps & ClusterProps; +// +// /** +// * The sort style of a table. +// * This has been duplicated here to exporting private types. +// */ +// export enum TableSortStyle { +// /** +// * Amazon Redshift assigns an optimal sort key based on the table data. +// */ +// AUTO = 'AUTO', +// +// /** +// * Specifies that the data is sorted using a compound key made up of all of the listed columns, +// * in the order they are listed. +// */ +// COMPOUND = 'COMPOUND', +// +// /** +// * Specifies that the data is sorted using an interleaved sort key. +// */ +// INTERLEAVED = 'INTERLEAVED', +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/types.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/user.ts b/src/aws/storage/redshift/private/database-query-provider/user.ts new file mode 100644 index 00000000..9e0f4dd1 --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/user.ts @@ -0,0 +1,120 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/user.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/user.ts -- +// +// /* eslint-disable import/no-extraneous-dependencies */ +// +// import { SecretsManager } from '@aws-sdk/client-secrets-manager'; +// import type * as AWSLambda from 'aws-lambda'; +// +// import { quoteIdentifier, quoteLiteral } from './escape'; +// import { executeStatement } from './redshift-data'; +// import type { ClusterProps } from './types'; +// import { makePhysicalId } from './util'; +// import type { UserHandlerProps } from '../handler-props'; +// +// const secretsManager = new SecretsManager({}); +// +// export async function handler(props: UserHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) { +// const username = props.username; +// const passwordSecretArn = props.passwordSecretArn; +// const clusterProps = props; +// +// if (event.RequestType === 'Create') { +// await createUser(username, passwordSecretArn, clusterProps); +// return { PhysicalResourceId: makePhysicalId(username, clusterProps, event.RequestId), Data: { username: username } }; +// } else if (event.RequestType === 'Delete') { +// await dropUser(username, clusterProps); +// return; +// } else if (event.RequestType === 'Update') { +// const { replace } = await updateUser( +// username, +// passwordSecretArn, +// clusterProps, +// event.OldResourceProperties as unknown as UserHandlerProps & ClusterProps); +// const physicalId = replace ? makePhysicalId(username, clusterProps, event.RequestId) : event.PhysicalResourceId; +// return { PhysicalResourceId: physicalId, Data: { username: username } }; +// } else { +// /* eslint-disable-next-line dot-notation */ +// throw new Error(`Unrecognized event type: ${event['RequestType']}`); +// } +// } +// +// async function dropUser(username: string, clusterProps: ClusterProps) { +// await executeStatement(`DROP USER ${quoteIdentifier(username)}`, clusterProps); +// } +// +// async function createUser(username: string, passwordSecretArn: string, clusterProps: ClusterProps) { +// const password = await getPasswordFromSecret(passwordSecretArn); +// +// await executeStatement(`CREATE USER ${quoteIdentifier(username)} PASSWORD ${quoteLiteral(password)}`, clusterProps); +// } +// +// async function updateUser( +// username: string, +// passwordSecretArn: string, +// clusterProps: ClusterProps, +// oldResourceProperties: UserHandlerProps & ClusterProps, +// ): Promise<{ replace: boolean }> { +// const oldClusterProps = oldResourceProperties; +// if (clusterProps.clusterName !== oldClusterProps.clusterName || clusterProps.databaseName !== oldClusterProps.databaseName) { +// await createUser(username, passwordSecretArn, clusterProps); +// return { replace: true }; +// } +// +// const oldUsername = oldResourceProperties.username; +// const oldPasswordSecretArn = oldResourceProperties.passwordSecretArn; +// const oldPassword = await getPasswordFromSecret(oldPasswordSecretArn); +// const password = await getPasswordFromSecret(passwordSecretArn); +// +// if (username !== oldUsername) { +// await createUser(username, passwordSecretArn, clusterProps); +// return { replace: true }; +// } +// +// if (password !== oldPassword) { +// await executeStatement(`ALTER USER ${quoteIdentifier(username)} PASSWORD ${quoteLiteral(password)}`, clusterProps); +// return { replace: false }; +// } +// +// return { replace: false }; +// } +// +// async function getPasswordFromSecret(passwordSecretArn: string): Promise { +// const secretValue = await secretsManager.getSecretValue({ +// SecretId: passwordSecretArn, +// }); +// const secretString = secretValue.SecretString; +// if (!secretString) { +// throw new Error(`Secret string for ${passwordSecretArn} was empty`); +// } +// const { password } = JSON.parse(secretString); +// +// return password; +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/user.ts -- diff --git a/src/aws/storage/redshift/private/database-query-provider/util.ts b/src/aws/storage/redshift/private/database-query-provider/util.ts new file mode 100644 index 00000000..573cd5fa --- /dev/null +++ b/src/aws/storage/redshift/private/database-query-provider/util.ts @@ -0,0 +1,63 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/util.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query-provider/util.ts -- +// +// import type { ClusterProps } from './types'; +// import type { Column } from '../../table'; +// +// export function makePhysicalId(resourceName: string, clusterProps: ClusterProps, requestId: string): string { +// return `${clusterProps.clusterName}:${clusterProps.databaseName}:${resourceName}:${requestId}`; +// } +// +// export function getDistKeyColumn(columns: Column[]): Column | undefined { +// // string comparison is required for custom resource since everything is passed as string +// const distKeyColumns = columns.filter(column => column.distKey === true || (column.distKey as unknown as string) === 'true'); +// +// if (distKeyColumns.length === 0) { +// return undefined; +// } else if (distKeyColumns.length > 1) { +// throw new Error('Multiple dist key columns found'); +// } +// +// return distKeyColumns[0]; +// } +// +// export function getSortKeyColumns(columns: Column[]): Column[] { +// // string comparison is required for custom resource since everything is passed as string +// return columns.filter(column => column.sortKey === true || (column.sortKey as unknown as string) === 'true'); +// } +// +// export function areColumnsEqual(columnsA: Column[], columnsB: Column[]): boolean { +// if (columnsA.length !== columnsB.length) { +// return false; +// } +// return columnsA.every(columnA => { +// return columnsB.find(column => column.name === columnA.name && column.dataType === columnA.dataType); +// }); +// } +// +// -- END fully commented-out upstream port of lib/private/database-query-provider/util.ts -- diff --git a/src/aws/storage/redshift/private/database-query.ts b/src/aws/storage/redshift/private/database-query.ts new file mode 100644 index 00000000..1b691c15 --- /dev/null +++ b/src/aws/storage/redshift/private/database-query.ts @@ -0,0 +1,170 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/database-query.ts -- +// +// import * as path from 'path'; +// import * as iam from 'aws-cdk-lib/aws-iam'; +// import * as lambda from 'aws-cdk-lib/aws-lambda'; +// import type * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +// import * as cdk from 'aws-cdk-lib/core'; +// import { lit } from 'aws-cdk-lib/core/lib/helpers-internal'; +// import * as customresources from 'aws-cdk-lib/custom-resources'; +// import { Construct } from 'constructs'; +// import type { DatabaseQueryHandlerProps } from './handler-props'; +// import { Cluster } from '../cluster'; +// import type { DatabaseOptions } from '../database-options'; +// +// export interface DatabaseQueryProps extends DatabaseOptions { +// readonly handler: string; +// readonly properties: HandlerProps; +// /** +// * The policy to apply when this resource is removed from the application. +// * +// * @default cdk.RemovalPolicy.Destroy +// */ +// readonly removalPolicy?: cdk.RemovalPolicy; +// +// /** +// * The handler timeout duration +// * +// * @default cdk.Duration.minutes(1) +// */ +// readonly timeout?: cdk.Duration; +// } +// +// export class DatabaseQuery extends Construct implements iam.IGrantable { +// readonly grantPrincipal: iam.IPrincipal; +// readonly ref: string; +// +// private readonly resource: cdk.CustomResource; +// +// constructor(scope: Construct, id: string, props: DatabaseQueryProps) { +// super(scope, id); +// +// if (props.timeout && !cdk.Token.isUnresolved(props.timeout)) { +// if (props.timeout.toMilliseconds() < cdk.Duration.seconds(1).toMilliseconds()) { +// throw new cdk.ValidationError(lit`TimeoutTooShort`, `The timeout for the handler must be BETWEEN 1 second and 15 minutes, got ${props.timeout.toMilliseconds()} milliseconds.`, this); +// } +// if (props.timeout.toSeconds() > cdk.Duration.minutes(15).toSeconds()) { +// throw new cdk.ValidationError(lit`TimeoutTooLong`, `The timeout for the handler must be between 1 second and 15 minutes, got ${props.timeout.toSeconds()} seconds.`, this); +// } +// } +// +// const adminUser = this.getAdminUser(props); +// const handler = new lambda.SingletonFunction(this, 'Handler', { +// code: lambda.Code.fromAsset(path.join(__dirname, 'database-query-provider'), { +// exclude: ['*.ts'], +// }), +// runtime: lambda.determineLatestNodeRuntime(this), +// handler: 'index.handler', +// timeout: props.timeout ?? cdk.Duration.minutes(1), +// uuid: '3de5bea7-27da-4796-8662-5efb56431b5f', +// lambdaPurpose: 'Query Redshift Database', +// }); +// handler.addToRolePolicy(new iam.PolicyStatement({ +// actions: ['redshift-data:DescribeStatement', 'redshift-data:ExecuteStatement'], +// resources: ['*'], +// })); +// adminUser.grantRead(handler); +// +// const provider = new customresources.Provider(this, 'Provider', { +// onEventHandler: handler, +// role: this.getOrCreateInvokerRole(handler), +// }); +// +// const queryHandlerProps: DatabaseQueryHandlerProps & HandlerProps = { +// handler: props.handler, +// clusterName: props.cluster.clusterName, +// adminUserArn: adminUser.secretArn, +// databaseName: props.databaseName, +// ...props.properties, +// }; +// this.resource = new cdk.CustomResource(this, 'Resource', { +// resourceType: 'Custom::RedshiftDatabaseQuery', +// serviceToken: provider.serviceToken, +// removalPolicy: props.removalPolicy, +// properties: queryHandlerProps, +// }); +// +// this.grantPrincipal = handler.grantPrincipal; +// this.ref = this.resource.ref; +// } +// +// public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { +// this.resource.applyRemovalPolicy(policy); +// } +// +// public getAtt(attributeName: string): cdk.Reference { +// return this.resource.getAtt(attributeName); +// } +// +// public getAttString(attributeName: string): string { +// return this.resource.getAttString(attributeName); +// } +// +// private getAdminUser(props: DatabaseOptions): secretsmanager.ISecret { +// const cluster = props.cluster; +// let adminUser = props.adminUser; +// if (!adminUser) { +// if (cluster instanceof Cluster) { +// if (cluster.secret) { +// adminUser = cluster.secret; +// } else { +// throw new cdk.ValidationError( +// lit`AdminUserSecretNotAvailable`, +// 'Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster did not generate admin user credentials (they were provided explicitly)', +// this, +// ); +// } +// } else { +// throw new cdk.ValidationError( +// lit`AdminUserSecretNotProvided`, +// 'Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster was imported', +// this, +// ); +// } +// } +// return adminUser; +// } +// +// /** +// * Get or create the IAM role for the singleton lambda function. +// * We only need one function since it's just acting as an invoker. +// * */ +// private getOrCreateInvokerRole(handler: lambda.SingletonFunction): iam.IRole { +// const id = handler.constructName + 'InvokerRole'; +// const existing = cdk.Stack.of(this).node.tryFindChild(id); +// return existing != null +// ? existing as iam.Role +// : new iam.Role(cdk.Stack.of(this), id, { +// assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), +// managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], +// }); +// } +// } +// +// -- END fully commented-out upstream port of lib/private/database-query.ts -- diff --git a/src/aws/storage/redshift/private/handler-props.ts b/src/aws/storage/redshift/private/handler-props.ts new file mode 100644 index 00000000..767be04b --- /dev/null +++ b/src/aws/storage/redshift/private/handler-props.ts @@ -0,0 +1,66 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/handler-props.ts -- +// +// import type { Column, TableDistStyle, TableSortStyle } from '../table'; +// +// export interface DatabaseQueryHandlerProps { +// readonly handler: string; +// readonly clusterName: string; +// readonly adminUserArn: string; +// readonly databaseName: string; +// } +// +// export interface UserHandlerProps { +// readonly username: string; +// readonly passwordSecretArn: string; +// } +// +// export interface TableHandlerProps { +// readonly tableName: { +// readonly prefix: string; +// readonly generateSuffix: string; +// }; +// readonly tableColumns: Column[]; +// readonly distStyle?: TableDistStyle; +// readonly sortStyle: TableSortStyle; +// readonly tableComment?: string; +// readonly useColumnIds: boolean; +// } +// +// export interface TablePrivilege { +// readonly tableId: string; +// readonly tableName: string; +// readonly actions: string[]; +// } +// +// export interface UserTablePrivilegesHandlerProps { +// readonly username: string; +// readonly tablePrivileges: TablePrivilege[]; +// } +// +// -- END fully commented-out upstream port of lib/private/handler-props.ts -- diff --git a/src/aws/storage/redshift/private/privileges.ts b/src/aws/storage/redshift/private/privileges.ts new file mode 100644 index 00000000..054dfbab --- /dev/null +++ b/src/aws/storage/redshift/private/privileges.ts @@ -0,0 +1,141 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/private/privileges.ts -- +// +// import type { IArrayBox } from 'aws-cdk-lib/core/lib/helpers-internal'; +// import { Box, noBoxStackTraces } from 'aws-cdk-lib/core/lib/helpers-internal'; +// import { Construct } from 'constructs'; +// import type { DatabaseOptions } from '../database-options'; +// import type { ITable } from '../table'; +// import { TableAction } from '../table'; +// import type { IUser } from '../user'; +// import { DatabaseQuery } from './database-query'; +// import { HandlerName } from './database-query-provider/handler-name'; +// import type { UserTablePrivilegesHandlerProps } from './handler-props'; +// +// /** +// * The Redshift table and action that make up a privilege that can be granted to a Redshift user. +// */ +// export interface TablePrivilege { +// /** +// * The table on which privileges will be granted. +// */ +// readonly table: ITable; +// +// /** +// * The actions that will be granted. +// */ +// readonly actions: TableAction[]; +// } +// +// /** +// * Properties for specifying privileges granted to a Redshift user on Redshift tables. +// */ +// export interface UserTablePrivilegesProps extends DatabaseOptions { +// /** +// * The user to which privileges will be granted. +// */ +// readonly user: IUser; +// +// /** +// * The privileges to be granted. +// * +// * @default [] - use `addPrivileges` to grant privileges after construction +// */ +// readonly privileges?: TablePrivilege[]; +// } +// +// /** +// * Privileges granted to a Redshift user on Redshift tables. +// * +// * This construct is located in the `private` directory to ensure that it is not exported for direct public use. This +// * means that user privileges must be managed through the `Table.grant` method or the `User.addTablePrivileges` +// * method. Thus, each `User` will have at most one `UserTablePrivileges` construct to manage its privileges. For details +// * on why this is a Good Thing, see the README, under "Granting Privileges". +// */ +// @noBoxStackTraces +// export class UserTablePrivileges extends Construct { +// private privileges: IArrayBox; +// +// constructor(scope: Construct, id: string, props: UserTablePrivilegesProps) { +// super(scope, id); +// +// this.privileges = Box.fromArray(props.privileges ?? [], { omitEmpty: false }); +// +// new DatabaseQuery(this, 'Resource', { +// ...props, +// handler: HandlerName.UserTablePrivileges, +// properties: { +// username: props.user.username, +// tablePrivileges: this.privileges.derive(privs => +// Object.entries(groupPrivilegesByTable(privs)) +// .map(([tableId, tablePrivileges]) => ({ +// tableId, +// // The first element always exists since the groupBy element is at least a singleton. +// tableName: tablePrivileges[0]!.table.tableName, +// actions: unifyTableActions(tablePrivileges).map(action => TableAction[action]), +// })), +// ) as any, +// }, +// }); +// } +// +// /** +// * Grant this user additional privileges. +// */ +// addPrivileges(table: ITable, ...actions: TableAction[]): void { +// this.privileges.push({ table, actions }); +// } +// } +// +// const unifyTableActions = (tablePrivileges: TablePrivilege[]): TableAction[] => { +// const set = new Set(tablePrivileges.flatMap(x => x.actions)); +// +// if (set.has(TableAction.ALL)) { +// return [TableAction.ALL]; +// } +// +// if (set.has(TableAction.UPDATE) || set.has(TableAction.DELETE)) { +// set.add(TableAction.SELECT); +// } +// +// return [...set]; +// }; +// +// const groupPrivilegesByTable = (privileges: readonly TablePrivilege[]): Record => { +// return privileges.reduce((grouped, privilege) => { +// const { table } = privilege; +// const tableId = table.node.id; +// const tablePrivileges = grouped[tableId] ?? []; +// return { +// ...grouped, +// [tableId]: [...tablePrivileges, privilege], +// }; +// }, {} as Record); +// }; +// +// -- END fully commented-out upstream port of lib/private/privileges.ts -- diff --git a/src/aws/storage/redshift/subnet-group.ts b/src/aws/storage/redshift/subnet-group.ts new file mode 100644 index 00000000..33d97689 --- /dev/null +++ b/src/aws/storage/redshift/subnet-group.ts @@ -0,0 +1,152 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/subnet-group.ts + +import { redshiftSubnetGroup } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; + +/** + * Interface for a cluster subnet group. + * + * TODO: omitted — upstream also extends `aws_redshift.IClusterSubnetGroupRef`, a CloudFormation + * cross-stack "Reference" marker interface generated from the CFN resource spec. TerraConstructs + * has no equivalent generated-reference layer (identical omission to `ISubnetGroup` in + * `../rds/subnet-group.ts`), so it is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/subnet-group.ts#L9-L16 + */ +export interface IClusterSubnetGroup extends IAwsConstruct { + /** + * The name of the cluster subnet group. + */ + readonly clusterSubnetGroupName: string; +} + +/** + * Properties for creating a ClusterSubnetGroup. + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `ClusterSubnetGroupProps` does not — matching the base-idiom used throughout + * this repo (e.g. `SubnetGroupProps` in `../rds/subnet-group.ts`) for cross-account/-region + * construct placement. + */ +export interface ClusterSubnetGroupProps extends AwsConstructProps { + /** + * Description of the subnet group. + */ + readonly description: string; + + /** + * The VPC to place the subnet group in. + */ + readonly vpc: ec2.IVpc; + + /** + * The name of the cluster subnet group. + * + * TERRACONSTRUCTS DEVIATION: not present upstream (CloudFormation always generates the + * physical name from the logical id for `AWS::Redshift::ClusterSubnetGroup`). The underlying + * `aws_redshift_subnet_group` Terraform resource requires a `name` argument, so this repo + * exposes it as an optional prop (mirroring `SubnetGroup` in `../rds/subnet-group.ts` and + * `../neptune/subnet-group.ts`) with a gridUUID-scoped generated default. + * + * @default - a gridUUID-scoped generated name + */ + readonly clusterSubnetGroupName?: string; + + /** + * Which subnets within the VPC to associate with this group. + * + * @default - private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + // TERRACONSTRUCTS DEVIATION: upstream also exposes `removalPolicy` here (default + // RemovalPolicy.RETAIN, mapped onto the CfnClusterSubnetGroup's DeletionPolicy/UpdateReplacePolicy). + // `core.RemovalPolicy` is not ported in this repo (see the identical omission on + // `SubnetGroupProps` in `../rds/subnet-group.ts` / `../neptune/subnet-group.ts`), and the + // `aws_redshift_subnet_group` Terraform resource has no `skip_destroy` (or equivalent) argument + // to honestly map RETAIN onto, so it is dropped entirely rather than partially wired up — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/subnet-group.ts#L35-L41 + // readonly removalPolicy?: RemovalPolicy; +} + +/** + * Class for creating a Redshift cluster subnet group + * + * @resource aws_redshift_subnet_group + */ +export class ClusterSubnetGroup + extends AwsConstructBase + implements IClusterSubnetGroup +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.redshift.ClusterSubnetGroup"; + + /** + * Imports an existing subnet group by name. + */ + public static fromClusterSubnetGroupName( + scope: Construct, + id: string, + clusterSubnetGroupName: string, + ): IClusterSubnetGroup { + class Import extends AwsConstructBase implements IClusterSubnetGroup { + public readonly clusterSubnetGroupName = clusterSubnetGroupName; + public get outputs(): Record { + return { name: this.clusterSubnetGroupName }; + } + } + return new Import(scope, id); + } + + public readonly clusterSubnetGroupName: string; + + private readonly resource: redshiftSubnetGroup.RedshiftSubnetGroup; + + constructor(scope: Construct, id: string, props: ClusterSubnetGroupProps) { + super(scope, id, props); + + const { subnetIds } = props.vpc.selectSubnets( + props.vpcSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + ); + + this.resource = new redshiftSubnetGroup.RedshiftSubnetGroup( + this, + "Default", + { + description: props.description, + // names are actually stored by Redshift changed to lowercase on the server side, and not + // lowercasing them means things like { Ref } do not work correctly. + // TERRACONSTRUCTS DEVIATION: when unnamed, upstream lets CloudFormation generate a name + // from the logical id; the repo invariant is a gridUUID-scoped `uniqueResourceName` + // default instead (mirroring `SubnetGroup` in `../rds/subnet-group.ts`), lowercased to + // match the Redshift/RDS-family server-side storage convention. + name: Token.isUnresolved(props.clusterSubnetGroupName) + ? (props.clusterSubnetGroupName as string) + : ( + props.clusterSubnetGroupName ?? + this.stack.uniqueResourceName(this) + ).toLowerCase(), + subnetIds, + }, + ); + + // TERRACONSTRUCTS DEVIATION: no `removalPolicy` to apply here — see + // `ClusterSubnetGroupProps` note above. + + this.clusterSubnetGroupName = this.resource.name; + } + + public get outputs(): Record { + return { + name: this.clusterSubnetGroupName, + arn: this.resource.arn, + }; + } +} diff --git a/src/aws/storage/redshift/table.ts b/src/aws/storage/redshift/table.ts new file mode 100644 index 00000000..72d97492 --- /dev/null +++ b/src/aws/storage/redshift/table.ts @@ -0,0 +1,545 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/table.ts -- +// +// +// import * as cdk from 'aws-cdk-lib/core'; +// import { lit } from 'aws-cdk-lib/core/lib/helpers-internal'; +// import { REDSHIFT_COLUMN_ID } from 'aws-cdk-lib/cx-api'; +// import type { IConstruct } from 'constructs'; +// import { Construct } from 'constructs'; +// import type { ICluster } from './cluster'; +// import type { DatabaseOptions } from './database-options'; +// import { DatabaseQuery } from './private/database-query'; +// import { HandlerName } from './private/database-query-provider/handler-name'; +// import { getDistKeyColumn, getSortKeyColumns } from './private/database-query-provider/util'; +// import type { TableHandlerProps } from './private/handler-props'; +// import type { IUser } from './user'; +// +// /** +// * An action that a Redshift user can be granted privilege to perform on a table. +// */ +// export enum TableAction { +// /** +// * Grants privilege to select data from a table or view using a SELECT statement. +// */ +// SELECT, +// +// /** +// * Grants privilege to load data into a table using an INSERT statement or a COPY statement. +// */ +// INSERT, +// +// /** +// * Grants privilege to update a table column using an UPDATE statement. +// */ +// UPDATE, +// +// /** +// * Grants privilege to delete a data row from a table. +// */ +// DELETE, +// +// /** +// * Grants privilege to drop a table. +// */ +// DROP, +// +// /** +// * Grants privilege to create a foreign key constraint. +// * +// * You need to grant this privilege on both the referenced table and the referencing table; otherwise, the user can't create the constraint. +// */ +// REFERENCES, +// +// /** +// * Grants all available privileges at once to the specified user or user group. +// */ +// ALL, +// } +// +// /** +// * A column in a Redshift table. +// */ +// export interface Column { +// /** +// * The unique identifier of the column. +// * +// * This is not the name of the column, and renaming this identifier will cause a new column to be created and the old column to be dropped. +// * +// * **NOTE** - This field will be set, however, only by setting the `@aws-cdk/aws-redshift:columnId` feature flag will this field be used. +// * +// * @default - the column name is used as the identifier +// */ +// readonly id?: string; +// +// /** +// * The name of the column. This will appear on Amazon Redshift. +// */ +// readonly name: string; +// +// /** +// * The data type of the column. +// */ +// readonly dataType: string; +// +// /** +// * Boolean value that indicates whether the column is to be configured as DISTKEY. +// * +// * @default - column is not DISTKEY +// */ +// readonly distKey?: boolean; +// +// /** +// * Boolean value that indicates whether the column is to be configured as SORTKEY. +// * +// * @default - column is not a SORTKEY +// */ +// readonly sortKey?: boolean; +// +// /** +// * The encoding to use for the column. +// * +// * @default - Amazon Redshift determines the encoding based on the data type. +// */ +// readonly encoding?: ColumnEncoding; +// +// /** +// * A comment to attach to the column. +// * +// * @default - no comment +// */ +// readonly comment?: string; +// } +// +// /** +// * Properties for configuring a Redshift table. +// */ +// export interface TableProps extends DatabaseOptions { +// /** +// * The name of the table. +// * +// * @default - a name is generated +// */ +// readonly tableName?: string; +// +// /** +// * The columns of the table. +// */ +// readonly tableColumns: Column[]; +// +// /** +// * The distribution style of the table. +// * +// * @default TableDistStyle.AUTO +// */ +// readonly distStyle?: TableDistStyle; +// +// /** +// * The sort style of the table. +// * +// * @default TableSortStyle.AUTO if no sort key is specified, TableSortStyle.COMPOUND if a sort key is specified +// */ +// readonly sortStyle?: TableSortStyle; +// +// /** +// * The policy to apply when this resource is removed from the application. +// * +// * @default cdk.RemovalPolicy.Retain +// */ +// readonly removalPolicy?: cdk.RemovalPolicy; +// +// /** +// * A comment to attach to the table. +// * +// * @default - no comment +// */ +// readonly tableComment?: string; +// +// /** +// * Handler timeout duration. +// * +// * Valid values are between 1 second and 15 minutes. +// * +// * @default - 1 minute +// */ +// readonly timeout?: cdk.Duration; +// } +// +// /** +// * Represents a table in a Redshift database. +// */ +// export interface ITable extends IConstruct { +// /** +// * Name of the table. +// */ +// readonly tableName: string; +// +// /** +// * The columns of the table. +// */ +// readonly tableColumns: Column[]; +// +// /** +// * The cluster where the table is located. +// */ +// readonly cluster: ICluster; +// +// /** +// * The name of the database where the table is located. +// */ +// readonly databaseName: string; +// +// /** +// * Grant a user privilege to access this table. +// */ +// grant(user: IUser, ...actions: TableAction[]): void; +// } +// +// /** +// * A full specification of a Redshift table that can be used to import it fluently into the CDK application. +// */ +// export interface TableAttributes { +// /** +// * Name of the table. +// */ +// readonly tableName: string; +// +// /** +// * The columns of the table. +// */ +// readonly tableColumns: Column[]; +// +// /** +// * The cluster where the table is located. +// */ +// readonly cluster: ICluster; +// +// /** +// * The name of the database where the table is located. +// */ +// readonly databaseName: string; +// } +// +// abstract class TableBase extends Construct implements ITable { +// abstract readonly tableName: string; +// abstract readonly tableColumns: Column[]; +// abstract readonly cluster: ICluster; +// abstract readonly databaseName: string; +// grant(user: IUser, ...actions: TableAction[]) { +// user.addTablePrivileges(this, ...actions); +// } +// } +// +// /** +// * A table in a Redshift cluster. +// */ +// export class Table extends TableBase { +// /** +// * Specify a Redshift table using a table name and schema that already exists. +// */ +// static fromTableAttributes(scope: Construct, id: string, attrs: TableAttributes): ITable { +// return new class extends TableBase { +// readonly tableName = attrs.tableName; +// readonly tableColumns = attrs.tableColumns; +// readonly cluster = attrs.cluster; +// readonly databaseName = attrs.databaseName; +// }(scope, id); +// } +// +// readonly tableName: string; +// readonly tableColumns: Column[]; +// readonly cluster: ICluster; +// readonly databaseName: string; +// +// private resource: DatabaseQuery; +// +// constructor(scope: Construct, id: string, props: TableProps) { +// super(scope, id); +// +// this.validateDistKeyColumns(props.tableColumns); +// if (props.distStyle) { +// this.validateDistStyle(props.distStyle, props.tableColumns); +// } +// if (props.sortStyle) { +// this.validateSortStyle(props.sortStyle, props.tableColumns); +// } +// +// this.tableColumns = this.configureTableColumns(props.tableColumns); +// this.cluster = props.cluster; +// this.databaseName = props.databaseName; +// +// const useColumnIds = !!cdk.FeatureFlags.of(this).isEnabled(REDSHIFT_COLUMN_ID); +// +// this.resource = new DatabaseQuery(this, 'Resource', { +// removalPolicy: cdk.RemovalPolicy.RETAIN, +// ...props, +// handler: HandlerName.Table, +// properties: { +// tableName: { +// prefix: props.tableName ?? cdk.Names.uniqueId(this), +// generateSuffix: (props.tableName == null).toString(), +// }, +// tableColumns: this.tableColumns, +// distStyle: props.distStyle, +// sortStyle: props.sortStyle ?? this.getDefaultSortStyle(props.tableColumns), +// tableComment: props.tableComment, +// useColumnIds, +// }, +// }); +// +// this.tableName = props.tableName ?? this.resource.ref; +// } +// +// /** +// * Apply the given removal policy to this resource +// * +// * The Removal Policy controls what happens to this resource when it stops +// * being managed by CloudFormation, either because you've removed it from the +// * CDK application or because you've made a change that requires the resource +// * to be replaced. +// * +// * The resource can be destroyed (`RemovalPolicy.DESTROY`), or left in your AWS +// * account for data recovery and cleanup later (`RemovalPolicy.RETAIN`). +// * +// * This resource is retained by default. +// */ +// public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { +// this.resource.applyRemovalPolicy(policy); +// } +// +// private validateDistKeyColumns(columns: Column[]): void { +// try { +// getDistKeyColumn(columns); +// } catch { +// throw new cdk.ValidationError(lit`MultipleDistKeys`, 'Only one column can be configured as distKey.', this); +// } +// } +// +// private validateDistStyle(distStyle: TableDistStyle, columns: Column[]): void { +// const distKeyColumn = getDistKeyColumn(columns); +// if (distKeyColumn && distStyle !== TableDistStyle.KEY) { +// throw new cdk.ValidationError(lit`DistStyleMustBeKey`, `Only 'TableDistStyle.KEY' can be configured when distKey is also configured. Found ${distStyle}`, this); +// } +// if (!distKeyColumn && distStyle === TableDistStyle.KEY) { +// throw new cdk.ValidationError(lit`DistKeyRequiredForKeyStyle`, 'distStyle of "TableDistStyle.KEY" can only be configured when distKey is also configured.', this); +// } +// } +// +// private validateSortStyle(sortStyle: TableSortStyle, columns: Column[]): void { +// const sortKeyColumns = getSortKeyColumns(columns); +// if (sortKeyColumns.length === 0 && sortStyle !== TableSortStyle.AUTO) { +// throw new cdk.ValidationError(lit`SortKeyRequiredForSortStyle`, `sortStyle of '${sortStyle}' can only be configured when sortKey is also configured.`, this); +// } +// if (sortKeyColumns.length > 0 && sortStyle === TableSortStyle.AUTO) { +// throw new cdk.ValidationError(lit`AutoSortStyleConflictsWithSortKey`, `sortStyle of '${TableSortStyle.AUTO}' cannot be configured when sortKey is also configured.`, this); +// } +// } +// +// private getDefaultSortStyle(columns: Column[]): TableSortStyle { +// const sortKeyColumns = getSortKeyColumns(columns); +// return (sortKeyColumns.length === 0) ? TableSortStyle.AUTO : TableSortStyle.COMPOUND; +// } +// +// private configureTableColumns(columns: Column[]): Column[] { +// const newColumns = [...columns]; +// const columnIds = new Set(); +// for (let i = 0; i < columns.length; i++) { +// const column = newColumns[i]; +// if (column.id) { +// if (columnIds.has(column.id)) { +// throw new cdk.ValidationError(lit`DuplicateColumnId`, `Column id '${column.id}' is not unique.`, this); +// } +// columnIds.add(column.id); +// } else { +// if (columnIds.has(column.name)) { +// throw new cdk.ValidationError(lit`DuplicateColumnName`, `Column name '${column.name}' is not unique amongst the column ids.`, this); +// } +// newColumns[i] = { ...column, id: column.name }; +// columnIds.add(column.name); +// } +// } +// return newColumns; +// } +// } +// +// /** +// * The data distribution style of a table. +// */ +// export enum TableDistStyle { +// /** +// * Amazon Redshift assigns an optimal distribution style based on the table data +// */ +// AUTO = 'AUTO', +// +// /** +// * The data in the table is spread evenly across the nodes in a cluster in a round-robin distribution. +// */ +// EVEN = 'EVEN', +// +// /** +// * The data is distributed by the values in the DISTKEY column. +// */ +// KEY = 'KEY', +// +// /** +// * A copy of the entire table is distributed to every node. +// */ +// ALL = 'ALL', +// } +// +// /** +// * The sort style of a table. +// */ +// export enum TableSortStyle { +// /** +// * Amazon Redshift assigns an optimal sort key based on the table data. +// */ +// AUTO = 'AUTO', +// +// /** +// * Specifies that the data is sorted using a compound key made up of all of the listed columns, +// * in the order they are listed. +// */ +// COMPOUND = 'COMPOUND', +// +// /** +// * Specifies that the data is sorted using an interleaved sort key. +// */ +// INTERLEAVED = 'INTERLEAVED', +// } +// +// /** +// * The compression encoding of a column. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Compression_encodings.html +// */ +// export enum ColumnEncoding { +// /** +// * Amazon Redshift assigns an optimal encoding based on the column data. +// * This is the default. +// */ +// AUTO = 'AUTO', +// +// /** +// * The column is not compressed. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Raw_encoding.html +// */ +// RAW = 'RAW', +// +// /** +// * The column is compressed using the AZ64 algorithm. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/az64-encoding.html +// */ +// AZ64 = 'AZ64', +// +// /** +// * The column is compressed using a separate dictionary for each block column value on disk. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Byte_dictionary_encoding.html +// */ +// BYTEDICT = 'BYTEDICT', +// +// /** +// * The column is compressed based on the difference between values in the column. +// * This records differences as 1-byte values. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Delta_encoding.html +// */ +// DELTA = 'DELTA', +// +// /** +// * The column is compressed based on the difference between values in the column. +// * This records differences as 2-byte values. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Delta_encoding.html +// */ +// DELTA32K = 'DELTA32K', +// +// /** +// * The column is compressed using the LZO algorithm. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/lzo-encoding.html +// */ +// LZO = 'LZO', +// +// /** +// * The column is compressed to a smaller storage size than the original data type. +// * The compressed storage size is 1 byte. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_MostlyN_encoding.html +// */ +// MOSTLY8 = 'MOSTLY8', +// +// /** +// * The column is compressed to a smaller storage size than the original data type. +// * The compressed storage size is 2 bytes. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_MostlyN_encoding.html +// */ +// MOSTLY16 = 'MOSTLY16', +// +// /** +// * The column is compressed to a smaller storage size than the original data type. +// * The compressed storage size is 4 bytes. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_MostlyN_encoding.html +// */ +// MOSTLY32 = 'MOSTLY32', +// +// /** +// * The column is compressed by recording the number of occurrences of each value in the column. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Runlength_encoding.html +// */ +// RUNLENGTH = 'RUNLENGTH', +// +// /** +// * The column is compressed by recording the first 245 unique words and then using a 1-byte index to represent each word. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Text255_encoding.html +// */ +// TEXT255 = 'TEXT255', +// +// /** +// * The column is compressed by recording the first 32K unique words and then using a 2-byte index to represent each word. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/c_Text255_encoding.html +// */ +// TEXT32K = 'TEXT32K', +// +// /** +// * The column is compressed using the ZSTD algorithm. +// * +// * @see https://docs.aws.amazon.com/redshift/latest/dg/zstd-encoding.html +// */ +// ZSTD = 'ZSTD', +// } +// +// -- END fully commented-out upstream port of lib/table.ts -- diff --git a/src/aws/storage/redshift/user.ts b/src/aws/storage/redshift/user.ts new file mode 100644 index 00000000..34a46fe4 --- /dev/null +++ b/src/aws/storage/redshift/user.ts @@ -0,0 +1,232 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User surface (this file, +// plus its siblings table.ts, user.ts, private/database-query.ts, private/privileges.ts, +// private/handler-props.ts, and private/database-query-provider/**) is backed entirely by a +// `Custom::RedshiftDatabaseQuery` CloudFormation custom resource: a Lambda function +// (private/database-query-provider/) invoked via a `cdk.CustomResource`/`cr.Provider` pair +// that runs arbitrary SQL (CREATE/ALTER/DROP TABLE, CREATE/DROP USER, GRANT/REVOKE) against +// the cluster's database at deploy time, using Data API or direct client connections from +// inside the handler. TerraConstructs has no framework equivalent to CDK's +// `Provider`/`CustomResource` L2s (Lambda-backed custom-resource lifecycle management with +// CREATE/UPDATE/DELETE event routing) in this repo yet, so this entire file is ported here +// verbatim but fully commented out, per the scope-reduction directive for this PR -- see +// `../cluster.ts`'s `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION and +// `enableRebootForParameterChanges()` omission notes for the sibling omissions of the same +// root cause (upstream custom-resource dependency). Re-enabling this file is a de-commenting +// exercise once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// lib/table.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/table.ts +// lib/user.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/user.ts +// lib/private/database-query.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query.ts +// lib/private/handler-props.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts +// lib/private/privileges.ts: https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts +// lib/private/database-query-provider/: https://github.com/aws/aws-cdk/tree/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider +// +// -- BEGIN fully commented-out upstream port of lib/user.ts -- +// +// import type * as kms from 'aws-cdk-lib/aws-kms'; +// import type * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +// import * as cdk from 'aws-cdk-lib/core'; +// import type { IConstruct } from 'constructs'; +// import { Construct } from 'constructs'; +// import type { ICluster } from './cluster'; +// import type { DatabaseOptions } from './database-options'; +// import { DatabaseSecret } from './database-secret'; +// import { DatabaseQuery } from './private/database-query'; +// import { HandlerName } from './private/database-query-provider/handler-name'; +// import type { UserHandlerProps } from './private/handler-props'; +// import { UserTablePrivileges } from './private/privileges'; +// import type { ITable, TableAction } from './table'; +// +// /** +// * Properties for configuring a Redshift user. +// */ +// export interface UserProps extends DatabaseOptions { +// /** +// * The name of the user. +// * +// * For valid values, see: https://docs.aws.amazon.com/redshift/latest/dg/r_names.html +// * +// * @default - a name is generated +// */ +// readonly username?: string; +// +// /** +// * KMS key to encrypt the generated secret. +// * +// * @default - the default AWS managed key is used +// */ +// readonly encryptionKey?: kms.IKey; +// +// /** +// * Characters to not include in the generated password. +// * +// * @default '"@/\\\ \'' +// */ +// readonly excludeCharacters?: string; +// +// /** +// * The policy to apply when this resource is removed from the application. +// * +// * @default cdk.RemovalPolicy.Destroy +// */ +// readonly removalPolicy?: cdk.RemovalPolicy; +// } +// +// /** +// * Represents a user in a Redshift database. +// */ +// export interface IUser extends IConstruct { +// /** +// * The name of the user. +// */ +// readonly username: string; +// +// /** +// * The password of the user. +// */ +// readonly password: cdk.SecretValue; +// +// /** +// * The cluster where the table is located. +// */ +// readonly cluster: ICluster; +// +// /** +// * The name of the database where the table is located. +// */ +// readonly databaseName: string; +// +// /** +// * Grant this user privilege to access a table. +// */ +// addTablePrivileges(table: ITable, ...actions: TableAction[]): void; +// } +// +// /** +// * A full specification of a Redshift user that can be used to import it fluently into the CDK application. +// */ +// export interface UserAttributes extends DatabaseOptions { +// /** +// * The name of the user. +// */ +// readonly username: string; +// +// /** +// * The password of the user. +// * +// * Do not put passwords in CDK code directly. +// */ +// readonly password: cdk.SecretValue; +// } +// +// abstract class UserBase extends Construct implements IUser { +// abstract readonly username: string; +// abstract readonly password: cdk.SecretValue; +// abstract readonly cluster: ICluster; +// abstract readonly databaseName: string; +// +// /** +// * The tables that user will have access to +// */ +// private privileges?: UserTablePrivileges; +// +// protected abstract readonly databaseProps: DatabaseOptions; +// +// addTablePrivileges(table: ITable, ...actions: TableAction[]): void { +// if (!this.privileges) { +// this.privileges = new UserTablePrivileges(this, 'TablePrivileges', { +// ...this.databaseProps, +// user: this, +// }); +// +// // The privilege should be granted or revoked when the table exists. +// this.privileges.node.addDependency(table); +// } +// +// this.privileges.addPrivileges(table, ...actions); +// } +// } +// +// /** +// * A user in a Redshift cluster. +// */ +// export class User extends UserBase { +// /** +// * Specify a Redshift user using credentials that already exist. +// */ +// static fromUserAttributes(scope: Construct, id: string, attrs: UserAttributes): IUser { +// return new class extends UserBase { +// readonly username = attrs.username; +// readonly password = attrs.password; +// readonly cluster = attrs.cluster; +// readonly databaseName = attrs.databaseName; +// protected readonly databaseProps = attrs; +// }(scope, id); +// } +// +// readonly username: string; +// readonly password: cdk.SecretValue; +// readonly cluster: ICluster; +// readonly databaseName: string; +// protected databaseProps: DatabaseOptions; +// +// /** +// * The Secrets Manager secret of the user. +// * @attribute +// */ +// public readonly secret: secretsmanager.ISecret; +// +// private resource: DatabaseQuery; +// +// constructor(scope: Construct, id: string, props: UserProps) { +// super(scope, id); +// +// this.databaseProps = props; +// this.cluster = props.cluster; +// this.databaseName = props.databaseName; +// +// const username = props.username ?? cdk.Names.uniqueId(this).toLowerCase(); +// const secret = new DatabaseSecret(this, 'Secret', { +// username, +// encryptionKey: props.encryptionKey, +// excludeCharacters: props.excludeCharacters, +// }); +// const attachedSecret = secret.attach(props.cluster); +// this.password = attachedSecret.secretValueFromJson('password'); +// +// this.resource = new DatabaseQuery(this, 'Resource', { +// ...this.databaseProps, +// handler: HandlerName.User, +// properties: { +// username, +// passwordSecretArn: attachedSecret.secretArn, +// }, +// }); +// attachedSecret.grantRead(this.resource); +// +// this.username = this.resource.getAttString('username'); +// this.secret = secret; +// } +// +// /** +// * Apply the given removal policy to this resource +// * +// * The Removal Policy controls what happens to this resource when it stops +// * being managed by CloudFormation, either because you've removed it from the +// * CDK application or because you've made a change that requires the resource +// * to be replaced. +// * +// * The resource can be destroyed (`RemovalPolicy.DESTROY`), or left in your AWS +// * account for data recovery and cleanup later (`RemovalPolicy.RETAIN`). +// * +// * This resource is destroyed by default. +// */ +// public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { +// this.resource.applyRemovalPolicy(policy); +// } +// } +// +// -- END fully commented-out upstream port of lib/user.ts -- diff --git a/test/aws/storage/redshift/__snapshots__/cluster.test.ts.snap b/test/aws/storage/redshift/__snapshots__/cluster.test.ts.snap new file mode 100644 index 00000000..95e27126 --- /dev/null +++ b/test/aws/storage/redshift/__snapshots__/cluster.test.ts.snap @@ -0,0 +1,405 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`check that instantiation works 1`] = ` +"{ + "data": { + "aws_availability_zones": { + "AvailabilityZones": { + "provider": "aws" + } + }, + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_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" + } + }, + "VPC_PublicSubnet3_EIP_AD4BC883": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet3", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway": { + "VPC_IGW_B7E252D3": { + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway_attachment": { + "VPC_VPCGW_99B986DC": { + "internet_gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_nat_gateway": { + "VPC_PublicSubnet1_NATGateway_E0556630": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet1_EIP_6AD938E8.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet1_RouteTableAssociation_0B0896DC", + "aws_route.VPC_PublicSubnet1_DefaultRoute_91CEF279" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_NATGateway_3C070193": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet2_EIP_4947BC00.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet2_RouteTableAssociation_5A808732", + "aws_route.VPC_PublicSubnet2_DefaultRoute_B7481BBA" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet3_NATGateway_D3048F5C": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet3_EIP_AD4BC883.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet3_RouteTableAssociation_427FE0C6", + "aws_route.VPC_PublicSubnet3_DefaultRoute_A0D29D46" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet3_7031327B.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet3", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_redshift_cluster": { + "Redshift_8A119849": { + "allow_version_upgrade": true, + "automated_snapshot_retention_period": 1, + "cluster_identifier": "mystackredshift69520ca3", + "cluster_subnet_group_name": "\${aws_redshift_subnet_group.Redshift_Subnets_DFE70E0A.name}", + "cluster_type": "multi-node", + "database_name": "default_db", + "encrypted": "true", + "iam_roles": [], + "master_password": "tooshort", + "master_username": "admin", + "node_type": "ra3.large", + "number_of_nodes": 2, + "publicly_accessible": false, + "tags": { + "Name": "Test-Redshift", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_security_group_ids": [ + "\${aws_security_group.Redshift_SecurityGroup_796D74A7.id}" + ] + } + }, + "aws_redshift_subnet_group": { + "Redshift_Subnets_DFE70E0A": { + "description": "Subnets for Redshift Redshift cluster", + "name": "mystackredshiftsubnets37b2b87c", + "subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}", + "\${aws_subnet.VPC_PrivateSubnet3_EAEE5839.id}" + ], + "tags": { + "Name": "Test-Redshift", + "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_PrivateSubnet3_DefaultRoute_27F311AE": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet3_NATGateway_D3048F5C.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet3_RouteTable_192186F8.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}" + }, + "VPC_PublicSubnet3_DefaultRoute_A0D29D46": { + "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_PublicSubnet3_RouteTable_98AE0E14.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_PrivateSubnet3_RouteTable_192186F8": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet3", + "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}" + }, + "VPC_PublicSubnet3_RouteTable_98AE0E14": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet3", + "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_PrivateSubnet3_RouteTableAssociation_C28D144E": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet3_RouteTable_192186F8.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet3_EAEE5839.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}" + }, + "VPC_PublicSubnet3_RouteTableAssociation_427FE0C6": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet3_RouteTable_98AE0E14.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet3_7031327B.id}" + } + }, + "aws_security_group": { + "Redshift_SecurityGroup_796D74A7": { + "description": "Redshift security group", + "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-12d3MyStackRedshiftSecurityGroup89C9D3BD", + "tags": { + "Name": "Test-Redshift", + "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.96.0/19", + "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.128.0/19", + "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_PrivateSubnet3_EAEE5839": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 2)}", + "cidr_block": "10.0.160.0/19", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet3", + "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/19", + "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.32.0/19", + "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}" + }, + "VPC_PublicSubnet3_7031327B": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 2)}", + "cidr_block": "10.0.64.0/19", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet3", + "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/redshift/__snapshots__/database-secret.test.ts.snap b/test/aws/storage/redshift/__snapshots__/database-secret.test.ts.snap new file mode 100644 index 00000000..8c2f2455 --- /dev/null +++ b/test/aws/storage/redshift/__snapshots__/database-secret.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`database secret create a database secret 1`] = ` +"{ + "data": { + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + }, + "aws_secretsmanager_random_password": { + "Secret_RandomPassword_01FCD53F": { + "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 + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_secretsmanager_secret": { + "Secret_A720EF05": { + "name": "MyStackSecret03F6D6E7", + "tags": { + "Name": "Test-Secret", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_secretsmanager_secret_version": { + "Secret_SecretVersion_1CCEE6BD": { + "lifecycle": { + "ignore_changes": [ + "secret_string" + ] + }, + "secret_id": "\${aws_secretsmanager_secret.Secret_A720EF05.arn}", + "secret_string": "\${jsonencode({\\"username\\" = \\"admin-username\\", \\"password\\" = data.aws_secretsmanager_random_password.Secret_RandomPassword_01FCD53F.random_password})}" + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/redshift/__snapshots__/parameter-group.test.ts.snap b/test/aws/storage/redshift/__snapshots__/parameter-group.test.ts.snap new file mode 100644 index 00000000..9fc13fb0 --- /dev/null +++ b/test/aws/storage/redshift/__snapshots__/parameter-group.test.ts.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`create a cluster parameter group 1`] = ` +"{ + "data": { + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_redshift_parameter_group": { + "Params_A8366201": { + "description": "desc", + "family": "redshift-1.0", + "name": "mystackparams9fd42da1", + "parameter": [ + { + "name": "param", + "value": "value" + } + ], + "tags": { + "Name": "Test-Params", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/redshift/__snapshots__/subnet-group.test.ts.snap b/test/aws/storage/redshift/__snapshots__/subnet-group.test.ts.snap new file mode 100644 index 00000000..65b46e1d --- /dev/null +++ b/test/aws/storage/redshift/__snapshots__/subnet-group.test.ts.snap @@ -0,0 +1,268 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`creates a subnet group from minimal properties 1`] = ` +"{ + "data": { + "aws_availability_zones": { + "AvailabilityZones": { + "provider": "aws" + } + }, + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_eip": { + "VPC_PublicSubnet1_EIP_6AD938E8": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_EIP_4947BC00": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway": { + "VPC_IGW_B7E252D3": { + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway_attachment": { + "VPC_VPCGW_99B986DC": { + "internet_gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_nat_gateway": { + "VPC_PublicSubnet1_NATGateway_E0556630": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet1_EIP_6AD938E8.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet1_RouteTableAssociation_0B0896DC", + "aws_route.VPC_PublicSubnet1_DefaultRoute_91CEF279" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_NATGateway_3C070193": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet2_EIP_4947BC00.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet2_RouteTableAssociation_5A808732", + "aws_route.VPC_PublicSubnet2_DefaultRoute_B7481BBA" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_redshift_subnet_group": { + "Group": { + "description": "MyGroup", + "name": "mystackgroupa31af148", + "subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ], + "tags": { + "Name": "Test-Group", + "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_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/redshift/cluster.test.ts b/test/aws/storage/redshift/cluster.test.ts new file mode 100644 index 00000000..a0674dff --- /dev/null +++ b/test/aws/storage/redshift/cluster.test.ts @@ -0,0 +1,1003 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/cluster.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { + dataAwsSecretsmanagerRandomPassword, + redshiftCluster, + redshiftLogging, + redshiftParameterGroup, + redshiftSubnetGroup, + secretsmanagerSecret, + vpcSecurityGroupEgressRule, +} from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as iam from "../../../../src/aws/iam"; +import { Bucket } from "../../../../src/aws/storage/bucket"; +import * as redshift from "../../../../src/aws/storage/redshift"; +import { Annotations, Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +// TERRACONSTRUCTS DEVIATION: filter out the unrelated `skipFinalSnapshot`/`finalSnapshotIdentifier` +// synth-time warning (emitted whenever neither is set, which every test in this file triggers +// incidentally) rather than asserting zero warnings overall -- mirrors the identical adaptation in +// `../docdb/cluster.test.ts` / `../rds/cluster.test.ts`. +function nonSkipFinalSnapshotWarnings(stack: AwsStack) { + return Annotations.fromStack(stack).warnings.filter( + (w) => !w.message.toString().includes("skipFinalSnapshot"), + ); +} + +let stack: AwsStack; +let vpc: compute.IVpc; + +beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); +}); + +test("check that instantiation works", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + allow_version_upgrade: true, + master_username: "admin", + master_password: "tooshort", + cluster_type: "multi-node", + automated_snapshot_retention_period: 1, + encrypted: "true", + number_of_nodes: 2, + node_type: "ra3.large", + database_name: "default_db", + publicly_accessible: false, + }); + t.expect.toHaveResourceWithProperties( + redshiftSubnetGroup.RedshiftSubnetGroup, + { + description: "Subnets for Redshift Redshift cluster", + }, + ); + const [subnetGroupResource] = t.resourceTypeArray( + redshiftSubnetGroup.RedshiftSubnetGroup, + ) as any[]; + expect(subnetGroupResource.subnet_ids).toHaveLength(3); +}); + +test("specify maintenance track name", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + maintenanceTrackName: redshift.MaintenanceTrackName.TRAILING, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + maintenance_track_name: "trailing", + }); +}); + +test("can create a cluster with an externally supplied security group", () => { + // GIVEN + // TERRACONSTRUCTS DEVIATION: upstream also imports the VPC via `ec2.Vpc.fromLookup` (a + // context-lookup mechanism requiring pre-seeded `cdk.context.json`-style values). That machinery + // is orthogonal to `Cluster`'s own behavior under test here (wiring an externally-supplied + // security group), so a normally-constructed VPC is used instead. + const sg = compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "SecurityGroupId12345", + ); + + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + securityGroups: [sg], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + master_username: "admin", + master_password: "tooshort", + vpc_security_group_ids: ["SecurityGroupId12345"], + }); +}); + +test("creates a secret when master credentials are not specified", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: mirrors `Cluster`'s generated-password house pattern (see + // `../rds/cluster.test.ts`'s equivalent test) rather than upstream's CFN dynamic-reference + // (`{{resolve:secretsmanager:...}}`) syntax -- the generated password is an + // `aws_secretsmanager_random_password` data-source token, stored verbatim (and `ignore_changes`'d) + // on the `aws_redshift_cluster.master_password` argument. + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: "\"@/\\ '", + password_length: 30, + }, + ); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.master_username).toEqual("admin"); + expect(clusterResource.master_password).toBeDefined(); + // TERRACONSTRUCTS DEVIATION: generated-password `ignore_changes` house pattern (see the + // `ignore_changes`/password-drift note in `Cluster`'s constructor) -- without it, every apply + // after the first would drift and REPLACE the live master password. + expect(clusterResource.lifecycle).toEqual({ + ignore_changes: ["master_password"], + }); +}); + +test("creates a secret with a custom excludeCharacters", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + excludeCharacters: "\"@/\\ '`", + }, + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: "\"@/\\ '`", + password_length: 30, + }, + ); +}); + +describe("node count", () => { + test("Single Node Clusters do not define node count", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + clusterType: redshift.ClusterType.SINGLE_NODE, + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.cluster_type).toEqual("single-node"); + expect(clusterResource.number_of_nodes).toBeUndefined(); + }); + + test("Single Node Clusters treat 1 node as undefined", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + clusterType: redshift.ClusterType.SINGLE_NODE, + numberOfNodes: 1, + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.cluster_type).toEqual("single-node"); + expect(clusterResource.number_of_nodes).toBeUndefined(); + }); + + test("Single Node Clusters throw if any other node count is specified", () => { + expect(() => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + clusterType: redshift.ClusterType.SINGLE_NODE, + numberOfNodes: 2, + }); + }).toThrow( + /Number of nodes must be not be supplied or be 1 for cluster type single-node/, + ); + }); + + test("Multi-Node Clusters default to 2 nodes", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + clusterType: redshift.ClusterType.MULTI_NODE, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + cluster_type: "multi-node", + number_of_nodes: 2, + }); + }); + + test.each([0, 1, -1, 101])( + "Multi-Node Clusters throw with %s nodes", + (numberOfNodes: number) => { + expect(() => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + clusterType: redshift.ClusterType.MULTI_NODE, + numberOfNodes, + }); + }).toThrow( + /Number of nodes for cluster type multi-node must be at least 2 and no more than 100/, + ); + }, + ); + + test("Multi-Node Clusters should allow input parameter for number of nodes", () => { + // GIVEN + // TERRACONSTRUCTS DEVIATION: upstream uses `cdk.CfnParameter`, which has no CDKTF equivalent; + // `TerraformVariable` (used the same way -- an unresolved Token at synth time) is the + // TerraConstructs-native stand-in (mirrors `../rds/cluster.test.ts`'s equivalent adaptation). + const numberOfNodesParam = new TerraformVariable(stack, "numberOfNodes", { + type: "number", + }); + + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + clusterType: redshift.ClusterType.MULTI_NODE, + numberOfNodes: numberOfNodesParam.numberValue as unknown as number, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + cluster_type: "multi-node", + number_of_nodes: stack.resolve(numberOfNodesParam.numberValue), + }); + }); +}); + +test("create an encrypted cluster with custom KMS key", () => { + // WHEN + const key = new encryption.Key(stack, "Key"); + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + encryptionKey: key, + vpc, + }); + + // THEN: assert the exact ARN value — `kms_key_id` must carry the key ARN, not the bare key + // id, per the ID-vs-ARN AUDIT note in `cluster.ts` (a keyId regression would otherwise leave + // the field defined and this test green while causing a perpetual post-apply diff). + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + kms_key_id: stack.resolve(key.keyArn), + }); +}); + +describe("parameter group", () => { + test("cluster instantiated with parameter group", () => { + // WHEN + const group = new redshift.ClusterParameterGroup(stack, "Params", { + description: "bye", + parameters: { + param: "value", + }, + }); + + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + parameterGroup: group, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + cluster_parameter_group_name: stack.resolve( + group.clusterParameterGroupName, + ), + }); + }); + + test("Adding to the cluster parameter group on a cluster not instantiated with a parameter group", () => { + // WHEN + const cluster = new redshift.Cluster(stack, "Redshift", { + clusterName: "foobar", + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + cluster.addToParameterGroup("foo", "bar"); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + description: "Parameter Group for the foobar Redshift cluster", + family: "redshift-1.0", + parameter: [ + { + name: "foo", + value: "bar", + }, + ], + }, + ); + }); + + test("Adding to the cluster parameter group on a cluster instantiated with a parameter group", () => { + // WHEN + const group = new redshift.ClusterParameterGroup(stack, "Params", { + description: "lorem ipsum", + parameters: { + param: "value", + }, + }); + + const cluster = new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + parameterGroup: group, + }); + cluster.addToParameterGroup("foo", "bar"); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + description: "lorem ipsum", + family: "redshift-1.0", + parameter: [ + { + name: "param", + value: "value", + }, + { + name: "foo", + value: "bar", + }, + ], + }, + ); + }); + + test("Adding a parameter to an IClusterParameterGroup", () => { + // GIVEN + const cluster = new redshift.Cluster(stack, "Redshift", { + clusterName: "foobar", + parameterGroup: + redshift.ClusterParameterGroup.fromClusterParameterGroupName( + stack, + "Params", + "foo", + ), + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + // WHEN + expect(() => cluster.addToParameterGroup("param", "value2")) + // THEN + .toThrow("Cannot add a parameter to an imported parameter group"); + }); +}); + +test("publicly accessible cluster", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + publiclyAccessible: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + publicly_accessible: true, + }); +}); + +test("availability zone relocation enabled", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + availabilityZoneRelocation: true, + nodeType: redshift.NodeType.RA3_XLPLUS, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + availability_zone_relocation_enabled: true, + }); +}); + +test.each([redshift.NodeType.DC1_8XLARGE, redshift.NodeType.DC2_LARGE])( + "throw error when availability zone relocation is enabled for invalid node type %s", + (nodeType) => { + expect(() => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + availabilityZoneRelocation: true, + nodeType, + }); + }).toThrow( + `Availability zone relocation is supported for only RA3 node types, got: ${nodeType}`, + ); + }, +); + +test("imported cluster with imported security group honors allowAllOutbound", () => { + // GIVEN + const cluster = redshift.Cluster.fromClusterAttributes(stack, "Database", { + clusterEndpointAddress: "addr", + clusterName: "identifier", + clusterEndpointPort: 3306, + securityGroups: [ + compute.SecurityGroup.fromSecurityGroupId(stack, "SG", "sg-123456789", { + allowAllOutbound: false, + }), + ], + }); + + // WHEN + cluster.connections.allowToAnyIpv4(compute.Port.tcp(443)); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupEgressRule.VpcSecurityGroupEgressRule, + { + security_group_id: "sg-123456789", + }, + ); +}); + +test("can create a cluster with logging enabled", () => { + // GIVEN + const bucket = Bucket.fromBucketName(stack, "bucket", "logging-bucket"); + + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + loggingProperties: { + loggingBucket: bucket, + loggingKeyPrefix: "prefix", + }, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: see the file-header SCOPE REDUCTION note on `../../../../src/aws/ + // storage/redshift/cluster.ts` -- provider 6.x moved Redshift audit logging off the + // `aws_redshift_cluster` resource itself and onto a standalone `aws_redshift_logging` resource. + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftLogging.RedshiftLogging, { + bucket_name: "logging-bucket", + s3_key_prefix: "prefix", + log_destination_type: "s3", + }); +}); + +// TODO: omitted — upstream's `ResourceAction`-driven tests (`specify resource action %s` / +// `throw error for failover primary compute action with single AZ cluster`). See the +// `ResourceAction` omission note on `../../../../src/aws/storage/redshift/cluster.ts` -- the +// `aws_redshift_cluster` Terraform resource has no equivalent argument at all — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/cluster.test.ts#L529-L557 + +test("throws when trying to add rotation to a cluster without secret", () => { + // WHEN + const cluster = new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + }); + + // THEN + expect(() => { + cluster.addRotationSingleUser(); + }).toThrow(); +}); + +test("throws validation error when trying to set encryptionKey without enabling encryption", () => { + // GIVEN + const key = new encryption.Key(stack, "kms-key"); + + // WHEN + const props = { + encrypted: false, + encryptionKey: key, + masterUser: { + masterUsername: "admin", + }, + vpc, + }; + + // THEN + expect(() => { + new redshift.Cluster(stack, "Redshift", props); + }).toThrow(); +}); + +test("throws when trying to add single user rotation multiple times", () => { + // GIVEN + const cluster = new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + // WHEN + cluster.addRotationSingleUser(); + + // THEN + expect(() => { + cluster.addRotationSingleUser(); + }).toThrow(); +}); + +test("can use existing cluster subnet group", () => { + // GIVEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + subnetGroup: redshift.ClusterSubnetGroup.fromClusterSubnetGroupName( + stack, + "Group", + "my-existing-cluster-subnet-group", + ), + }); + + const t = new Template(stack); + t.resourceCountIs(redshiftSubnetGroup.RedshiftSubnetGroup, 0); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + cluster_subnet_group_name: "my-existing-cluster-subnet-group", + }); +}); + +// TODO: omitted — upstream's `default child returns a CfnCluster` test asserts +// `cluster.node.defaultChild instanceof CfnCluster`. `resource` (the `aws_redshift_cluster` L1) is +// a private field on `Cluster` in this port (mirroring `ClusterParameterGroup`/`ClusterSubnetGroup` +// in this same package), not registered as `node.defaultChild` -- there is no TerraConstructs +// equivalent of CDK's `defaultChild` convention in this repo — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/cluster.test.ts#L629-L638 + +// TODO: omitted — upstream's `resize type (%s)` / `resize type not set` tests exercise +// `classicResizing` (CFN's `Classic` property). See the `classicResizing` omission note on +// `../../../../src/aws/storage/redshift/cluster.ts` -- the `aws_redshift_cluster` Terraform +// resource has no equivalent argument at all — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/cluster.test.ts#L640-L705 + +test("elastic ip address", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + elasticIp: "1.3.3.7", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + elastic_ip: "1.3.3.7", + }); +}); + +describe("multi AZ cluster", () => { + test("create a multi AZ cluster", () => { + // WHEN + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + nodeType: redshift.NodeType.RA3_LARGE, + multiAz: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + multi_az: true, + }); + }); + + test("throw error for invalid node type", () => { + expect(() => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + nodeType: redshift.NodeType.DS2_XLARGE, + multiAz: true, + }); + }).toThrow( + "Multi-AZ cluster is only supported for RA3 node types, got: ds2.xlarge", + ); + }); + + test("throw error for single node cluster", () => { + expect(() => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + nodeType: redshift.NodeType.RA3_XLPLUS, + multiAz: true, + clusterType: redshift.ClusterType.SINGLE_NODE, + }); + }).toThrow( + "Multi-AZ cluster is not supported for `clusterType` single-node", + ); + }); +}); + +// TODO(scope-reduction): omitted — upstream's `reboot for Parameter Changes` describe block +// exercises `enableRebootForParameterChanges()`/`rebootForParameterChanges`, both fully commented +// out in this port (backed entirely by a `Custom::RedshiftClusterRebooter` Lambda-backed custom +// resource this repo has no framework for). See the omission notes on +// `Cluster.enableRebootForParameterChanges()` and `ClusterProps.rebootForParameterChanges` in +// `../../../../src/aws/storage/redshift/cluster.ts` — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/cluster.test.ts#L801-L929 + +describe("default IAM role", () => { + test("Default role not in role list", () => { + // GIVEN + const clusterRole1 = new iam.Role(stack, "clusterRole1", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + const defaultRole1 = new iam.Role(stack, "defaultRole1", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + + expect(() => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + roles: [clusterRole1], + defaultRole: defaultRole1, + }); + }).toThrow(/Default role must be included in role list./); + }); + + test("throws error when default role not attached to cluster when adding default role post creation", () => { + const defaultRole1 = new iam.Role(stack, "defaultRole1", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + const cluster = new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + expect(() => { + cluster.addDefaultIamRole(defaultRole1); + }).toThrow( + /Default role must be associated to the Redshift cluster to be set as the default role./, + ); + }); + + test("sets the native default_iam_role_arn attribute", () => { + // TERRACONSTRUCTS DEVIATION: see the `addDefaultIamRole()` TERRACONSTRUCTS DEVIATION note on + // `../../../../src/aws/storage/redshift/cluster.ts` -- upstream shells out to the Redshift API + // via an `AwsCustomResource`, with no synth-time-visible assertion available. The native + // `aws_redshift_cluster.default_iam_role_arn` argument this port uses instead IS visible at + // synth time, so this test asserts it directly (a strict improvement in testability, not just + // implementation). + const defaultRole = new iam.Role(stack, "defaultRole", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + roles: [defaultRole], + defaultRole, + }); + + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.default_iam_role_arn).toBeDefined(); + }); +}); + +describe("IAM role", () => { + test("roles can be directly attached to cluster during declaration", () => { + // GIVEN + const role = new iam.Role(stack, "Role", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + roles: [role], + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.iam_roles).toHaveLength(1); + }); + + test("roles can be attached to cluster after declaration", () => { + // GIVEN + const role = new iam.Role(stack, "Role", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + const cluster = new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + // WHEN + cluster.addIamRole(role); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.iam_roles).toHaveLength(1); + }); + + test("roles can be attached to cluster in another stack", () => { + // GIVEN + // TERRACONSTRUCTS DEVIATION: upstream nests a second `cdk.Stack` inside `stack` and asserts the + // resulting `Fn::ImportValue` cross-stack reference syntax. CDKTF/cdktn stacks are top-level + // siblings under ONE shared `App` — only then does cdktn's cross-stack-reference machinery + // (`terraform_remote_state`) engage — mirroring the single-`Testing.app()` pattern in + // `test/aws/compute/ecs/ec2/cross-stack.test.ts`. + const app = Testing.app(); + const clusterStack = testStack(app, "ClusterStack"); + const clusterVpc = new compute.Vpc(clusterStack, "VPC"); + const cluster = new redshift.Cluster(clusterStack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc: clusterVpc, + }); + + const newTestStack = testStack(app, "NewTestStack"); + const role = new iam.Role(newTestStack, "Role", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + + // WHEN + cluster.addIamRole(role); + + // THEN: the emitted `iam_roles` entry is a resolvable `terraform_remote_state` reference to + // the role ARN produced by NewTestStack (not just any array entry). The construct-id hash + // suffix in the remote-state output key is synth-derived, so it is matched loosely. + const t = new Template(clusterStack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.iam_roles).toHaveLength(1); + expect(clusterResource.iam_roles[0]).toMatch( + /^\$\{data\.terraform_remote_state\.cross-stack-reference-input-NewTestStack\.outputs\.cross-stack-output-aws_iam_role.*arn\}$/, + ); + }); + + test("throws when adding role that is already in cluster", () => { + // GIVEN + const role = new iam.Role(stack, "Role", { + assumedBy: new iam.ServicePrincipal("redshift.amazonaws.com"), + }); + const cluster = new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + roles: [role], + }); + + expect(() => + // WHEN + cluster.addIamRole(role), + ).toThrow(`Role '${role.roleArn}' is already attached to the cluster`); + }); +}); + +// TERRACONSTRUCTS DEVIATION: not present upstream -- see the `skipFinalSnapshot`/ +// `finalSnapshotIdentifier` TERRACONSTRUCTS DEVIATION on `ClusterProps` in +// `../../../../src/aws/storage/redshift/cluster.ts`. Mirrors the identical dedicated test block in +// `../docdb/cluster.test.ts` / `../neptune/cluster.test.ts`. +describe("removal policy replacement props", () => { + test("skipFinalSnapshot and finalSnapshotIdentifier are rendered when set", () => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + skipFinalSnapshot: false, + finalSnapshotIdentifier: "my-final-snapshot", + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(redshiftCluster.RedshiftCluster, { + skip_final_snapshot: false, + final_snapshot_identifier: "my-final-snapshot", + }); + }); + + test("skipFinalSnapshot true omits finalSnapshotIdentifier", () => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + skipFinalSnapshot: true, + }); + + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.skip_final_snapshot).toEqual(true); + expect(clusterResource.final_snapshot_identifier).toBeUndefined(); + }); + + test("skipFinalSnapshot and finalSnapshotIdentifier are absent when unset", () => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + redshiftCluster.RedshiftCluster, + ) as any[]; + expect(clusterResource.skip_final_snapshot).toBeUndefined(); + expect(clusterResource.final_snapshot_identifier).toBeUndefined(); + }); + + test("warns when neither skipFinalSnapshot nor finalSnapshotIdentifier is set", () => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + }); + + const warnings = Annotations.fromStack(stack).warnings; + expect( + warnings.some((w) => w.message.toString().includes("skipFinalSnapshot")), + ).toEqual(true); + }); + + test("does not warn when skipFinalSnapshot is true", () => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + }, + vpc, + skipFinalSnapshot: true, + }); + + expect(nonSkipFinalSnapshotWarnings(stack)).toHaveLength(0); + }); +}); + +test("secret has no resources when the master password is provided", () => { + new redshift.Cluster(stack, "Redshift", { + masterUser: { + masterUsername: "admin", + masterPassword: "tooshort", + }, + vpc, + skipFinalSnapshot: true, + }); + + const t = new Template(stack); + t.resourceCountIs(secretsmanagerSecret.SecretsmanagerSecret, 0); +}); diff --git a/test/aws/storage/redshift/database-query-provider/escape.test.ts b/test/aws/storage/redshift/database-query-provider/escape.test.ts new file mode 100644 index 00000000..30bad959 --- /dev/null +++ b/test/aws/storage/redshift/database-query-provider/escape.test.ts @@ -0,0 +1,109 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/database-query-provider/escape.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/database-query-provider/escape.test.ts -- +// +// import { quoteIdentifier, quoteLiteral, quoteQualifiedIdentifier } from '../../lib/private/database-query-provider/escape'; +// +// describe('quoteIdentifier', () => { +// test('returns a plain lowercase identifier unchanged', () => { +// expect(quoteIdentifier('users')).toEqual('users'); +// }); +// +// test('returns a mixed-case identifier unchanged (Redshift folds it, matching prior behaviour)', () => { +// expect(quoteIdentifier('MyUser')).toEqual('MyUser'); +// }); +// +// test('returns an identifier with digits, underscores, and dollar signs unchanged', () => { +// expect(quoteIdentifier('etl_user_2$')).toEqual('etl_user_2$'); +// }); +// +// test('returns a non-ASCII (multibyte) identifier unchanged', () => { +// expect(quoteIdentifier('café')).toEqual('café'); +// }); +// +// test('delimits an identifier containing a space', () => { +// expect(quoteIdentifier('a b')).toEqual('"a b"'); +// }); +// +// test('delimits an identifier starting with a digit', () => { +// expect(quoteIdentifier('1table')).toEqual('"1table"'); +// }); +// +// test('delimits an identifier containing a double quote and doubles it', () => { +// expect(quoteIdentifier('a"b')).toEqual('"a""b"'); +// }); +// +// test('delimits a name that would otherwise break out of the statement', () => { +// expect(quoteIdentifier("evil PASSWORD 'x' CREATEUSER --")).toEqual('"evil PASSWORD \'x\' CREATEUSER --"'); +// }); +// +// test('delimits an empty identifier', () => { +// expect(quoteIdentifier('')).toEqual('""'); +// }); +// }); +// +// describe('quoteLiteral', () => { +// test('wraps a plain value in single quotes', () => { +// expect(quoteLiteral('a')).toEqual("'a'"); +// }); +// +// test('doubles an embedded single quote character', () => { +// expect(quoteLiteral("a'b")).toEqual("'a''b'"); +// }); +// +// test('wraps an empty value in single quotes', () => { +// expect(quoteLiteral('')).toEqual("''"); +// }); +// }); +// +// describe('quoteQualifiedIdentifier', () => { +// test('returns each bare-safe component unchanged, keeping the dot separator', () => { +// expect(quoteQualifiedIdentifier('public.users')).toEqual('public.users'); +// }); +// +// test('returns a single bare-safe name unchanged', () => { +// expect(quoteQualifiedIdentifier('users')).toEqual('users'); +// }); +// +// test('delimits only the component that needs it', () => { +// expect(quoteQualifiedIdentifier('public.us ers')).toEqual('public."us ers"'); +// }); +// +// test('doubles an embedded double quote character within a component', () => { +// expect(quoteQualifiedIdentifier('public.us"ers')).toEqual('public."us""ers"'); +// }); +// +// test('delimits an empty name', () => { +// expect(quoteQualifiedIdentifier('')).toEqual('""'); +// }); +// }); +// +// -- END fully commented-out upstream port of test/database-query-provider/escape.test.ts -- diff --git a/test/aws/storage/redshift/database-query-provider/index.test.ts b/test/aws/storage/redshift/database-query-provider/index.test.ts new file mode 100644 index 00000000..6e1e6f8c --- /dev/null +++ b/test/aws/storage/redshift/database-query-provider/index.test.ts @@ -0,0 +1,84 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/database-query-provider/index.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/database-query-provider/index.test.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// +// const resourceProperties = { +// handler: 'table', +// ServiceToken: '', +// }; +// const requestId = 'requestId'; +// const baseEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ResourceProperties: resourceProperties, +// RequestType: 'Create', +// ServiceToken: '', +// ResponseURL: '', +// StackId: '', +// RequestId: requestId, +// LogicalResourceId: '', +// ResourceType: '', +// }; +// +// const mockSubHandler = jest.fn(); +// jest.mock('../../lib/private/database-query-provider/table', () => ({ +// __esModule: true, +// handler: mockSubHandler, +// })); +// import { handler } from '../../lib/private/database-query-provider/index'; +// +// beforeEach(() => { +// jest.clearAllMocks(); +// }); +// +// test('calls sub handler', async () => { +// const event = baseEvent; +// +// await handler(event); +// +// expect(mockSubHandler).toHaveBeenCalled(); +// }); +// +// test('throws with unregistered subhandler', async () => { +// const event = { +// ...baseEvent, +// ResourceProperties: { +// ...resourceProperties, +// handler: 'unregistered', +// }, +// }; +// +// await expect(handler(event)).rejects.toThrow(/Requested handler unregistered is not in supported set/); +// expect(mockSubHandler).not.toHaveBeenCalled(); +// }); +// +// -- END fully commented-out upstream port of test/database-query-provider/index.test.ts -- diff --git a/test/aws/storage/redshift/database-query-provider/privileges.test.ts b/test/aws/storage/redshift/database-query-provider/privileges.test.ts new file mode 100644 index 00000000..7218f228 --- /dev/null +++ b/test/aws/storage/redshift/database-query-provider/privileges.test.ts @@ -0,0 +1,474 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/database-query-provider/privileges.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/database-query-provider/privileges.test.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// +// const username = 'username'; +// const tableName = 'tableName'; +// const tableId = 'tableId'; +// const actions = ['INSERT', 'SELECT']; +// const tablePrivileges = [{ tableId, tableName, actions }]; +// const clusterName = 'clusterName'; +// const adminUserArn = 'adminUserArn'; +// const databaseName = 'databaseName'; +// const physicalResourceId = 'PhysicalResourceId'; +// const resourceProperties = { +// username, +// tablePrivileges, +// clusterName, +// adminUserArn, +// databaseName, +// ServiceToken: '', +// }; +// const requestId = 'requestId'; +// const genericEvent: AWSLambda.CloudFormationCustomResourceEventCommon = { +// ResourceProperties: resourceProperties, +// ServiceToken: '', +// ResponseURL: '', +// StackId: '', +// RequestId: requestId, +// LogicalResourceId: '', +// ResourceType: '', +// }; +// +// const mockExecuteStatement = jest.fn(async () => ({ Id: 'statementId' })); +// jest.mock('@aws-sdk/client-redshift-data', () => { +// return { +// RedshiftData: class { +// executeStatement = mockExecuteStatement; +// describeStatement = jest.fn(async () => ({ Status: 'FINISHED' })); +// }, +// }; +// }); +// +// import { handler as managePrivileges } from '../../lib/private/database-query-provider/privileges'; +// import { makePhysicalId } from '../../lib/private/database-query-provider/util'; +// +// beforeEach(() => { +// jest.clearAllMocks(); +// }); +// +// describe('create', () => { +// const baseEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// +// test('serializes properties in statement and creates physical resource ID', async () => { +// const event = baseEvent; +// +// await expect(managePrivileges(resourceProperties, event)).resolves.toEqual({ +// PhysicalResourceId: 'clusterName:databaseName:username:requestId', +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `GRANT INSERT, SELECT ON ${tableName} TO ${username}`, +// })); +// }); +// +// test('serializes properties in statement when tableName in physical resource ID', async () => { +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ +// tableId, +// tableName: `${makePhysicalId(tableName, resourceProperties, requestId)}`, +// actions, +// }], +// }; +// +// const event = { +// ...baseEvent, +// ResourceProperties: properties, +// StackId: 'xxxxx:' + requestId, +// }; +// +// await expect(managePrivileges(properties, event)).resolves.toEqual({ +// PhysicalResourceId: 'clusterName:databaseName:username:requestId', +// }); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `GRANT INSERT, SELECT ON ${tableName} TO ${username}`, +// })); +// }); +// }); +// +// describe('delete', () => { +// const baseEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('executes statement', async () => { +// const event = baseEvent; +// +// await managePrivileges(resourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`, +// })); +// }); +// +// test('serializes properties in statement when tableName in physical resource ID', async () => { +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ +// tableId, +// tableName: `${makePhysicalId(tableName, resourceProperties, requestId)}`, +// actions, +// }], +// }; +// +// const event = { +// ...baseEvent, +// ResourceProperties: properties, +// StackId: 'xxxxx:' + requestId, +// }; +// +// await managePrivileges(properties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`, +// })); +// }); +// }); +// +// describe('update', () => { +// const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { +// RequestType: 'Update', +// OldResourceProperties: resourceProperties, +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('replaces if cluster name changes', async () => { +// const newClusterName = 'newClusterName'; +// const newResourceProperties = { +// ...resourceProperties, +// clusterName: newClusterName, +// }; +// +// await expect(managePrivileges(newResourceProperties, event)).resolves.not.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// ClusterIdentifier: newClusterName, +// Sql: expect.stringMatching(/GRANT/), +// })); +// }); +// +// test('does not replace if admin user ARN changes', async () => { +// const newAdminUserArn = 'newAdminUserArn'; +// const newResourceProperties = { +// ...resourceProperties, +// adminUserArn: newAdminUserArn, +// }; +// +// await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).not.toHaveBeenCalled(); +// }); +// +// test('replaces if database name changes', async () => { +// const newDatabaseName = 'newDatabaseName'; +// const newResourceProperties = { +// ...resourceProperties, +// databaseName: newDatabaseName, +// }; +// +// await expect(managePrivileges(newResourceProperties, event)).resolves.not.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Database: newDatabaseName, +// Sql: expect.stringMatching(/GRANT/), +// })); +// }); +// +// test('replaces if user name changes', async () => { +// const newUsername = 'newUsername'; +// const newResourceProperties = { +// ...resourceProperties, +// username: newUsername, +// }; +// +// await expect(managePrivileges(newResourceProperties, event)).resolves.not.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`GRANT .* TO ${newUsername}`)), +// })); +// }); +// +// test('does not replace when table name is changed', async () => { +// const newTableName = 'newTableName'; +// const newTablePrivileges = [{ tableId, tableName: newTableName, actions }]; +// const newResourceProperties = { +// ...resourceProperties, +// tablePrivileges: newTablePrivileges, +// }; +// +// // Checking if the table resource has not been recreated +// await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// // Upon a table name change, Redshift maintains the same table priviliges as before. +// // The name of the table has changed, a new table has not been created. +// // Therefore 'REVOKE' statements should not be used. +// expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `REVOKE INSERT, SELECT ON ${newTableName} FROM ${username}`, +// })); +// // Likewise, here the table name has changed, so the current priviliges will still be intact. +// expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`.+ ON ${tableName} TO ${username}`)), +// })); +// }); +// +// test('does not replace when table actions are changed', async () => { +// const newTablePrivileges = [{ tableId, tableName, actions: ['DROP'] }]; +// const newResourceProperties = { +// ...resourceProperties, +// tablePrivileges: newTablePrivileges, +// }; +// +// // Checking if the table resource has not been recreated +// await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// // Old actions are REVOKED, as they are not included in the list +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`, +// })); +// // New actions are GRANTED, as they are included in the list +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `GRANT DROP ON ${tableName} TO ${username}`, +// })); +// }); +// +// test('does not replace when table id is changed', async () => { +// const newTableId = 'newTableId'; +// const newTablePrivileges = [{ tableId: newTableId, tableName, actions }]; +// const newResourceProperties = { +// ...resourceProperties, +// tablePrivileges: newTablePrivileges, +// }; +// +// // Checking if the table resource has not been recreated, we are not changing on table id either. +// // Due to the construct only needing to be changed on a new user, not a new table +// await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// // Upon removal of the old table, the priviliges will also be revoked automatically, +// // as the table no longer exists. +// // Calling REVOKE statments on a non-existing table will throw errors by Redshift +// expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`REVOKE .+ ON ${tableName} FROM ${username}`)), +// })); +// // Adds the permissions onto the newly created table +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`GRANT .+ ON ${tableName} TO ${username}`)), +// })); +// }); +// +// test('does not replace when table id is appended', async () => { +// const newTablePrivileges = [{ tableId: 'newTableId', tableName, actions }]; +// const newResourceProperties = { +// ...resourceProperties, +// tablePrivileges: newTablePrivileges, +// }; +// +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tablePrivileges: [{ tableName, actions }], +// }, +// }; +// +// // Checking if the table resource has not been recreated +// await expect(managePrivileges(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// // Upon initial deployment from non table id usage to table id usage, +// // permissions would not need to be granted/revoked, as the table should already exist +// expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`.+ ON ${tableName} FROM ${username}`)), +// })); +// }); +// +// test('serializes properties in grant statement when tableName in physical resource ID', async () => { +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ +// tableId, +// tableName: `${makePhysicalId(tableName, resourceProperties, requestId)}`, +// actions, +// }], +// }; +// +// const newEvent = { +// ...event, +// ResourceProperties: properties, +// StackId: 'xxxxx:' + requestId, +// }; +// +// await managePrivileges(properties, newEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `GRANT INSERT, SELECT ON ${tableName} TO ${username}`, +// })); +// }); +// +// test('serializes properties in drop statement when tableName in physical resource ID', async () => { +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ +// tableId, +// tableName: `${makePhysicalId(tableName, resourceProperties, requestId)}`, +// actions: ['DROP'], +// }], +// }; +// +// const newEvent = { +// ...event, +// ResourceProperties: properties, +// StackId: 'xxxxx:' + requestId, +// }; +// +// await managePrivileges(properties, newEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`, +// })); +// }); +// }); +// +// describe('special-character handling', () => { +// const specialUsername = 'gr"p'; +// +// test('quotes the user name and doubles embedded double quotes in GRANT', async () => { +// const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// const properties = { ...resourceProperties, username: specialUsername }; +// +// await managePrivileges(properties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('TO "gr""p"'), +// })); +// }); +// +// test('quotes the user name and doubles embedded double quotes in REVOKE', async () => { +// const event: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// const properties = { ...resourceProperties, username: specialUsername }; +// +// await managePrivileges(properties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('FROM "gr""p"'), +// })); +// }); +// +// test('quotes the table object name produced by normalizedTableName', async () => { +// const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ tableId, tableName: 'sales report', actions }], +// }; +// +// await managePrivileges(properties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('ON "sales report"'), +// })); +// }); +// +// test('keeps each bare-safe component of a schema-qualified table object unquoted in GRANT', async () => { +// const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ tableId, tableName: 'public.users', actions }], +// }; +// +// await managePrivileges(properties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('ON public.users TO username'), +// })); +// }); +// +// test('keeps each bare-safe component of a schema-qualified table object unquoted in REVOKE', async () => { +// const event: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// const properties = { +// ...resourceProperties, +// tablePrivileges: [{ tableId, tableName: 'public.users', actions }], +// }; +// +// await managePrivileges(properties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('ON public.users FROM username'), +// })); +// }); +// +// test('passes the action list through unchanged', async () => { +// const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// +// await managePrivileges(resourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(/^GRANT INSERT, SELECT ON /), +// })); +// }); +// }); +// +// -- END fully commented-out upstream port of test/database-query-provider/privileges.test.ts -- diff --git a/test/aws/storage/redshift/database-query-provider/table.test.ts b/test/aws/storage/redshift/database-query-provider/table.test.ts new file mode 100644 index 00000000..df8575bf --- /dev/null +++ b/test/aws/storage/redshift/database-query-provider/table.test.ts @@ -0,0 +1,930 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/database-query-provider/table.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/database-query-provider/table.test.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// +// const mockExecuteStatement = jest.fn(async () => ({ Id: 'statementId' })); +// jest.mock('@aws-sdk/client-redshift-data', () => ({ +// RedshiftData: class { +// executeStatement = mockExecuteStatement; +// describeStatement = jest.fn(async () => ({ Status: 'FINISHED' })); +// }, +// })); +// +// import type { Column } from '../../lib'; +// import { ColumnEncoding, TableDistStyle, TableSortStyle } from '../../lib'; +// import { handler as manageTable } from '../../lib/private/database-query-provider/table'; +// import type { TableAndClusterProps } from '../../lib/private/database-query-provider/types'; +// +// type ResourcePropertiesType = TableAndClusterProps & { ServiceToken: string }; +// +// const tableNamePrefix = 'tableNamePrefix'; +// const tableColumns = [{ name: 'col1', dataType: 'varchar(1)' }]; +// const clusterName = 'clusterName'; +// const adminUserArn = 'adminUserArn'; +// const databaseName = 'databaseName'; +// const physicalResourceId = 'clusterName:databaseName:tableNamePrefix:111111111111'; +// const stackId = 'arn:aws:cloudformation:us-east-1:788445345501:stack/aws-cdk-redshift-cluster-database/e782bf70-b8f4-11ed-8c6a-111111111111'; +// const stackIdTruncated = '111111111111'; +// const resourceProperties: ResourcePropertiesType = { +// useColumnIds: true, +// tableName: { +// prefix: tableNamePrefix, +// generateSuffix: 'true', +// }, +// tableColumns, +// sortStyle: TableSortStyle.AUTO, +// clusterName, +// adminUserArn, +// databaseName, +// ServiceToken: '', +// }; +// const requestId = 'requestId'; +// const genericEvent: AWSLambda.CloudFormationCustomResourceEventCommon = { +// ResourceProperties: resourceProperties, +// ServiceToken: '', +// ResponseURL: '', +// StackId: stackId, +// RequestId: requestId, +// LogicalResourceId: '', +// ResourceType: '', +// }; +// +// beforeEach(() => { +// jest.clearAllMocks(); +// }); +// +// describe('create', () => { +// const baseEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// +// test('serializes properties in statement and creates physical resource ID', async () => { +// const event = baseEvent; +// +// await expect(manageTable(resourceProperties, event)).resolves.toEqual({ +// PhysicalResourceId: 'clusterName:databaseName:tableNamePrefix111111111111:111111111111', +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1))`, +// })); +// }); +// +// test('does not modify table name if no suffix generation requested', async () => { +// const event = baseEvent; +// const newResourceProperties = { +// ...resourceProperties, +// tableName: { +// ...resourceProperties.tableName, +// generateSuffix: 'false', +// }, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toEqual({ +// PhysicalResourceId: 'clusterName:databaseName:tableNamePrefix:111111111111', +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix} (col1 varchar(1))`, +// })); +// }); +// +// test('serializes distKey and distStyle in statement', async () => { +// const event = baseEvent; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', distKey: true }], +// distStyle: TableDistStyle.KEY, +// }; +// +// await manageTable(newResourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1)) DISTSTYLE KEY DISTKEY(col1)`, +// })); +// }); +// +// test('serializes sortKeys and sortStyle in statement', async () => { +// const event = baseEvent; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [ +// { name: 'col1', dataType: 'varchar(1)', sortKey: true }, +// { name: 'col2', dataType: 'varchar(1)' }, +// { name: 'col3', dataType: 'varchar(1)', sortKey: true }, +// ], +// sortStyle: TableSortStyle.COMPOUND, +// }; +// +// await manageTable(newResourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1),col2 varchar(1),col3 varchar(1)) COMPOUND SORTKEY(col1,col3)`, +// })); +// }); +// +// test('serializes distKey and sortKeys as string booleans', async () => { +// const event = baseEvent; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [ +// { name: 'col1', dataType: 'varchar(4)', distKey: 'true' as unknown as boolean }, +// { name: 'col2', dataType: 'float', sortKey: 'true' as unknown as boolean }, +// { name: 'col3', dataType: 'float', sortKey: 'true' as unknown as boolean }, +// ], +// distStyle: TableDistStyle.KEY, +// sortStyle: TableSortStyle.COMPOUND, +// }; +// +// await manageTable(newResourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(4),col2 float,col3 float) DISTSTYLE KEY DISTKEY(col1) COMPOUND SORTKEY(col2,col3)`, +// })); +// }); +// +// test('serializes table comment in statement', async () => { +// const event = baseEvent; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableComment: 'table comment', +// }; +// +// await manageTable(newResourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `COMMENT ON TABLE ${tableNamePrefix}${stackIdTruncated} IS 'table comment'`, +// })); +// }); +// }); +// +// describe('delete', () => { +// const baseEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('executes statement', async () => { +// const event = baseEvent; +// +// await manageTable(resourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `DROP TABLE ${tableNamePrefix}${stackIdTruncated}`, +// })); +// }); +// }); +// +// describe('update', () => { +// const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { +// RequestType: 'Update', +// OldResourceProperties: resourceProperties, +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('replaces if cluster name changes', async () => { +// const newClusterName = 'newClusterName'; +// const newResourceProperties = { +// ...resourceProperties, +// clusterName: newClusterName, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// ClusterIdentifier: newClusterName, +// Sql: expect.stringMatching(new RegExp(`CREATE TABLE ${tableNamePrefix}${stackIdTruncated}`)), +// })); +// }); +// +// test('does not replace if admin user ARN changes', async () => { +// const newAdminUserArn = 'newAdminUserArn'; +// const newResourceProperties = { +// ...resourceProperties, +// adminUserArn: newAdminUserArn, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).not.toHaveBeenCalled(); +// }); +// +// test('replaces if database name changes', async () => { +// const newDatabaseName = 'newDatabaseName'; +// const newResourceProperties = { +// ...resourceProperties, +// databaseName: newDatabaseName, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Database: newDatabaseName, +// Sql: expect.stringMatching(new RegExp(`CREATE TABLE ${tableNamePrefix}${stackIdTruncated}`)), +// })); +// }); +// +// describe('table name', () => { +// test('does not replace if PhysicalResourceId is old format', async () => { +// const newResourceProperties = { +// ...resourceProperties, +// PhysicalResourceId: 'newTableName', +// tableName: { +// ...resourceProperties.tableName, +// prefix: 'newTableName', +// generateSuffix: 'false', +// }, +// }; +// +// const newEvent = { +// ...event, +// PhysicalResourceId: 'newTableName', +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableName: { +// ...event.OldResourceProperties.tableName, +// generateSuffix: 'false', +// }, +// }, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: 'newTableName', +// }); +// expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} RENAME TO newTableName`, +// })); +// }); +// +// test('does not replace if table name changes', async () => { +// const newResourceProperties = { +// ...resourceProperties, +// tableName: { +// ...resourceProperties.tableName, +// prefix: 'newTableName', +// generateSuffix: 'false', +// }, +// }; +// +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableName: { +// ...event.OldResourceProperties.tableName, +// generateSuffix: 'false', +// }, +// }, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix} RENAME TO newTableName`, +// })); +// }); +// +// test('does not replace if table name added', async () => { +// const newResourceProperties = { +// ...resourceProperties, +// tableName: { +// prefix: 'newTable', +// generateSuffix: 'false', +// }, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} RENAME TO newTable`, +// })); +// }); +// +// test('does not replace if table name removed', async () => { +// const newResourceProperties = { +// ...resourceProperties, +// tableName: { +// prefix: 'Table', +// generateSuffix: 'true', +// }, +// }; +// +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableName: { +// ...event.OldResourceProperties.tableName, +// generateSuffix: 'false', +// }, +// }, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix} RENAME TO Table${stackIdTruncated}`, +// })); +// }); +// }); +// +// test('does not replace if table columns removed', async () => { +// const newResourceProperties = { +// ...resourceProperties, +// tableColumns: [], +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`ALTER TABLE ${newResourceProperties.tableName.prefix}.+ DROP COLUMN col1`)), +// })); +// }); +// +// test('does not replace if table columns added', async () => { +// const newTableColumnName = 'col2'; +// const newTableColumnDataType = 'varchar(1)'; +// const newTableColumns = [{ name: 'col1', dataType: 'varchar(1)' }, { name: newTableColumnName, dataType: newTableColumnDataType }]; +// const newResourceProperties = { +// ...resourceProperties, +// tableColumns: newTableColumns, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ADD ${newTableColumnName} ${newTableColumnDataType}`, +// })); +// }); +// +// describe('column name', () => { +// test('does not replace if column name changed', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [ +// { id: 'col1', name: 'col1', dataType: 'varchar(1)' }, +// ], +// }, +// }; +// const newTableColumnName = 'col2'; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [ +// { id: 'col1', name: newTableColumnName, dataType: 'varchar(1)' }, +// ], +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} RENAME COLUMN col1 TO ${newTableColumnName}`, +// })); +// }); +// +// test('does not replace if column id assigned, from undefined', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [ +// { name: 'col1', dataType: 'varchar(1)' }, +// ], +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [ +// { id: 'col1', name: 'col1', dataType: 'varchar(1)' }, +// ], +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} RENAME COLUMN col1 TO col1`, +// })); +// }); +// }); +// +// describe('distStyle and distKey', () => { +// test('replaces if distStyle is added', async () => { +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// distStyle: TableDistStyle.EVEN, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1)) DISTSTYLE EVEN`, +// })); +// }); +// +// test('replaces if distStyle is removed', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// distStyle: TableDistStyle.EVEN, +// }, +// }; +// const newResourceProperties = { +// ...resourceProperties, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1))`, +// })); +// }); +// +// test('does not replace if distStyle is changed', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// distStyle: TableDistStyle.EVEN, +// }, +// }; +// const newDistStyle = TableDistStyle.ALL; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// distStyle: newDistStyle, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER DISTSTYLE ${newDistStyle}`, +// })); +// }); +// +// test('adds key without creating table if distKey is added', async () => { +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', distKey: true }], +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER DISTSTYLE KEY DISTKEY col1`, +// })); +// }); +// +// test('removes key without replacing table if distKey is removed', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', distKey: true }], +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER DISTSTYLE AUTO`, +// })); +// }); +// +// test('does not replace if distKey is changed', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [ +// { name: 'col1', dataType: 'varchar(1)', distKey: true }, +// { name: 'col2', dataType: 'varchar(1)' }, +// ], +// }, +// }; +// const newDistKey = 'col2'; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [ +// { name: 'col1', dataType: 'varchar(1)' }, +// { name: 'col2', dataType: 'varchar(1)', distKey: true }, +// ], +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER DISTKEY ${newDistKey}`, +// })); +// }); +// }); +// +// describe('sortStyle and sortKeys', () => { +// const oldTableColumnsWithSortKeys: Column[] = [ +// { name: 'col1', dataType: 'varchar(1)', sortKey: true }, +// { name: 'col2', dataType: 'varchar(1)' }, +// ]; +// const newTableColumnsWithSortKeys: Column[] = [ +// { name: 'col1', dataType: 'varchar(1)' }, +// { name: 'col2', dataType: 'varchar(1)', sortKey: true }, +// ]; +// +// test('replaces when same sortStyle, different sortKey columns: INTERLEAVED', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.INTERLEAVED, +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: newTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.INTERLEAVED, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1),col2 varchar(1)) INTERLEAVED SORTKEY(col2)`, +// })); +// }); +// +// test('replaces when different sortStyle: INTERLEAVED', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.AUTO, +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.INTERLEAVED, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE ${tableNamePrefix}${stackIdTruncated} (col1 varchar(1),col2 varchar(1)) INTERLEAVED SORTKEY(col1)`, +// })); +// }); +// +// test('does not replace when same sortStyle, different sortKey columns: COMPOUND', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.COMPOUND, +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: newTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.COMPOUND, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER COMPOUND SORTKEY(col2)`, +// })); +// }); +// +// test('does not replace when different sortStyle: COMPOUND', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.AUTO, +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.COMPOUND, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER COMPOUND SORTKEY(col1)`, +// })); +// }); +// +// test('does not replace when different sortStyle: AUTO', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.COMPOUND, +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: oldTableColumnsWithSortKeys, +// sortStyle: TableSortStyle.AUTO, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER SORTKEY AUTO`, +// })); +// }); +// }); +// +// describe('table comment', () => { +// test('does not replace if comment added on table', async () => { +// const newComment = 'newComment'; +// const newResourceProperties = { +// ...resourceProperties, +// tableComment: newComment, +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `COMMENT ON TABLE ${tableNamePrefix}${stackIdTruncated} IS '${newComment}'`, +// })); +// }); +// +// test('does not replace if comment removed on table', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableComment: 'oldComment', +// }, +// }; +// const newResourceProperties = { +// ...resourceProperties, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `COMMENT ON TABLE ${tableNamePrefix}${stackIdTruncated} IS NULL`, +// })); +// }); +// }); +// +// describe('column comment', () => { +// test('does not replace if comment added on column', async () => { +// const newComment = 'newComment'; +// const newResourceProperties = { +// ...resourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', comment: newComment }], +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `COMMENT ON COLUMN ${tableNamePrefix}${stackIdTruncated}.col1 IS '${newComment}'`, +// })); +// }); +// +// test('does not replace if comment removed on column', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', comment: 'oldComment' }], +// }, +// }; +// +// await expect(manageTable(resourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `COMMENT ON COLUMN ${tableNamePrefix}${stackIdTruncated}.col1 IS NULL`, +// })); +// }); +// }); +// +// describe('column encoding', () => { +// test('does not replace if encoding added on column', async () => { +// const newResourceProperties = { +// ...resourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', encoding: ColumnEncoding.RAW }], +// }; +// +// await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER COLUMN col1 ENCODE RAW`, +// })); +// }); +// +// test('does not replace if encoding removed on column', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', encoding: ColumnEncoding.RAW }], +// }, +// }; +// const newResourceProperties = { +// ...resourceProperties, +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER COLUMN col1 ENCODE AUTO`, +// })); +// }); +// +// test('adds a comma between multiple statements', async () => { +// const newEvent = { +// ...event, +// OldResourceProperties: { +// ...event.OldResourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)' }, { name: 'col2', dataType: 'varchar(1)' }], +// }, +// }; +// +// const newResourceProperties = { +// ...resourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', encoding: ColumnEncoding.RAW }, { name: 'col2', dataType: 'varchar(1)', encoding: ColumnEncoding.RAW }], +// }; +// +// await expect(manageTable(newResourceProperties, newEvent)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `ALTER TABLE ${tableNamePrefix}${stackIdTruncated} ALTER COLUMN col1 ENCODE RAW, ALTER COLUMN col2 ENCODE RAW`, +// })); +// }); +// }); +// }); +// +// describe('special-character handling', () => { +// const createEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// const updateEvent: AWSLambda.CloudFormationCustomResourceUpdateEvent = { +// RequestType: 'Update', +// OldResourceProperties: resourceProperties, +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('escapes single quotes in a table comment', async () => { +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableComment: "it's a table", +// }; +// +// await manageTable(newResourceProperties, createEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('IS \'it\'\'s a table\''), +// })); +// }); +// +// test('escapes single quotes in a column comment', async () => { +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [{ name: 'col1', dataType: 'varchar(1)', comment: "o'brien" }], +// }; +// +// await manageTable(newResourceProperties, createEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `COMMENT ON COLUMN ${tableNamePrefix}${stackIdTruncated}.col1 IS 'o''brien'`, +// })); +// }); +// +// test('quotes table and column identifiers and doubles embedded double quotes in CREATE TABLE', async () => { +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableName: { +// prefix: 'my table', +// generateSuffix: 'true', +// }, +// tableColumns: [{ name: 'we"ird', dataType: 'varchar(1)' }], +// }; +// +// await manageTable(newResourceProperties, createEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE TABLE "my table${stackIdTruncated}" ("we""ird" varchar(1))`, +// })); +// }); +// +// test('quotes identifiers and doubles embedded double quotes in RENAME COLUMN', async () => { +// const newEvent: AWSLambda.CloudFormationCustomResourceEvent = { +// ...updateEvent, +// OldResourceProperties: { +// ...updateEvent.OldResourceProperties, +// tableColumns: [ +// { id: 'colId', name: 'old"a', dataType: 'varchar(1)' }, +// ], +// }, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableColumns: [ +// { id: 'colId', name: 'new"b', dataType: 'varchar(1)' }, +// ], +// }; +// +// await manageTable(newResourceProperties, newEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringContaining('RENAME COLUMN "old""a" TO "new""b"'), +// })); +// }); +// +// test('quotes the table identifier in DROP TABLE', async () => { +// const deleteEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// const newResourceProperties: ResourcePropertiesType = { +// ...resourceProperties, +// tableName: { +// prefix: 'my table', +// generateSuffix: 'true', +// }, +// }; +// +// await manageTable(newResourceProperties, deleteEvent); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `DROP TABLE "my table${stackIdTruncated}"`, +// })); +// }); +// }); +// +// -- END fully commented-out upstream port of test/database-query-provider/table.test.ts -- diff --git a/test/aws/storage/redshift/database-query-provider/user.test.ts b/test/aws/storage/redshift/database-query-provider/user.test.ts new file mode 100644 index 00000000..194f3f5a --- /dev/null +++ b/test/aws/storage/redshift/database-query-provider/user.test.ts @@ -0,0 +1,272 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/database-query-provider/user.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/database-query-provider/user.test.ts -- +// +// +// import type * as AWSLambda from 'aws-lambda'; +// +// const password = 'password'; +// const username = 'username'; +// const passwordSecretArn = 'passwordSecretArn'; +// const clusterName = 'clusterName'; +// const adminUserArn = 'adminUserArn'; +// const databaseName = 'databaseName'; +// const physicalResourceId = 'PhysicalResourceId'; +// const resourceProperties = { +// username, +// passwordSecretArn, +// clusterName, +// adminUserArn, +// databaseName, +// ServiceToken: '', +// }; +// const requestId = 'requestId'; +// const genericEvent: AWSLambda.CloudFormationCustomResourceEventCommon = { +// ResourceProperties: resourceProperties, +// ServiceToken: '', +// ResponseURL: '', +// StackId: '', +// RequestId: requestId, +// LogicalResourceId: '', +// ResourceType: '', +// }; +// +// const mockExecuteStatement = jest.fn(async () => ({ Id: 'statementId' })); +// jest.mock('@aws-sdk/client-redshift-data', () => { +// return { +// RedshiftData: class { +// executeStatement = mockExecuteStatement; +// describeStatement = jest.fn(async () => ({ Status: 'FINISHED' })); +// }, +// }; +// }); +// +// const mockGetSecretValue = jest.fn(async () => ({ +// SecretString: JSON.stringify({ password }), +// })); +// jest.mock('@aws-sdk/client-secrets-manager', () => ({ +// SecretsManager: class { +// getSecretValue = mockGetSecretValue; +// }, +// })); +// +// import { handler as manageUser } from '../../lib/private/database-query-provider/user'; +// +// beforeEach(() => { +// jest.clearAllMocks(); +// }); +// +// describe('create', () => { +// const baseEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// +// test('serializes properties in statement and creates physical resource ID', async () => { +// const event = baseEvent; +// +// await expect(manageUser(resourceProperties, event)).resolves.toEqual({ +// PhysicalResourceId: 'clusterName:databaseName:username:requestId', +// Data: { +// username: username, +// }, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: `CREATE USER username PASSWORD '${password}'`, +// })); +// }); +// }); +// +// describe('delete', () => { +// const baseEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('executes statement', async () => { +// const event = baseEvent; +// +// await manageUser(resourceProperties, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: 'DROP USER username', +// })); +// }); +// }); +// +// describe('update', () => { +// const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { +// RequestType: 'Update', +// OldResourceProperties: resourceProperties, +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// test('replaces if cluster name changes', async () => { +// const newClusterName = 'newClusterName'; +// const newResourceProperties = { +// ...resourceProperties, +// clusterName: newClusterName, +// }; +// +// await expect(manageUser(newResourceProperties, event)).resolves.not.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// ClusterIdentifier: newClusterName, +// Sql: expect.stringMatching(/CREATE USER/), +// })); +// }); +// +// test('does not replace if admin user ARN changes', async () => { +// const newAdminUserArn = 'newAdminUserArn'; +// const newResourceProperties = { +// ...resourceProperties, +// adminUserArn: newAdminUserArn, +// }; +// +// await expect(manageUser(newResourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).not.toHaveBeenCalled(); +// }); +// +// test('replaces if database name changes', async () => { +// const newDatabaseName = 'newDatabaseName'; +// const newResourceProperties = { +// ...resourceProperties, +// databaseName: newDatabaseName, +// }; +// +// await expect(manageUser(newResourceProperties, event)).resolves.not.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Database: newDatabaseName, +// Sql: expect.stringMatching(/CREATE USER/), +// })); +// }); +// +// test('replaces if user name changes', async () => { +// const newUsername = 'newUsername'; +// const newResourceProperties = { +// ...resourceProperties, +// username: newUsername, +// }; +// +// await expect(manageUser(newResourceProperties, event)).resolves.not.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`CREATE USER ${newUsername}`)), +// })); +// }); +// +// test('does not replace if password changes', async () => { +// const newPassword = 'newPassword'; +// mockGetSecretValue.mockImplementationOnce(async () => ({ SecretString: JSON.stringify({ password: newPassword }) })); +// +// await expect(manageUser(resourceProperties, event)).resolves.toMatchObject({ +// PhysicalResourceId: physicalResourceId, +// }); +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: expect.stringMatching(new RegExp(`ALTER USER ${username} PASSWORD '${password}'`)), +// })); +// }); +// }); +// +// describe('special-character handling', () => { +// test('quotes the user name and doubles embedded double quotes in CREATE USER', async () => { +// const specialUsername = 'ab"c'; +// const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// mockGetSecretValue.mockImplementationOnce(async () => ({ SecretString: JSON.stringify({ password: 'pw' }) })); +// +// await manageUser({ ...resourceProperties, username: specialUsername }, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: 'CREATE USER "ab""c" PASSWORD \'pw\'', +// })); +// }); +// +// test('escapes single quotes in the password literal of CREATE USER', async () => { +// const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { +// RequestType: 'Create', +// ...genericEvent, +// }; +// mockGetSecretValue.mockImplementationOnce(async () => ({ SecretString: JSON.stringify({ password: "pa'ss" }) })); +// +// await manageUser({ ...resourceProperties, username: 'u' }, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: 'CREATE USER u PASSWORD \'pa\'\'ss\'', +// })); +// }); +// +// test('quotes the user name in DROP USER', async () => { +// const specialUsername = 'u; x'; +// const event: AWSLambda.CloudFormationCustomResourceDeleteEvent = { +// RequestType: 'Delete', +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// +// await manageUser({ ...resourceProperties, username: specialUsername }, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: 'DROP USER "u; x"', +// })); +// }); +// +// test('escapes single quotes in the password literal of ALTER USER', async () => { +// const newPassword = "p'q"; +// const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { +// RequestType: 'Update', +// OldResourceProperties: { ...resourceProperties, username: 'u' }, +// PhysicalResourceId: physicalResourceId, +// ...genericEvent, +// }; +// // First lookup resolves the old password, second resolves the new password. +// mockGetSecretValue.mockImplementationOnce(async () => ({ SecretString: JSON.stringify({ password: 'old' }) })); +// mockGetSecretValue.mockImplementationOnce(async () => ({ SecretString: JSON.stringify({ password: newPassword }) })); +// +// await manageUser({ ...resourceProperties, username: 'u' }, event); +// +// expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ +// Sql: 'ALTER USER u PASSWORD \'p\'\'q\'', +// })); +// }); +// }); +// +// -- END fully commented-out upstream port of test/database-query-provider/user.test.ts -- diff --git a/test/aws/storage/redshift/database-query.test.ts b/test/aws/storage/redshift/database-query.test.ts new file mode 100644 index 00000000..8a441e87 --- /dev/null +++ b/test/aws/storage/redshift/database-query.test.ts @@ -0,0 +1,288 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/database-query.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/database-query.test.ts -- +// +// import * as cdk from 'aws-cdk-lib'; +// import { Match, Template } from 'aws-cdk-lib/assertions'; +// import * as ec2 from 'aws-cdk-lib/aws-ec2'; +// import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +// import * as redshift from '../lib'; +// import type { DatabaseQueryProps } from '../lib/private/database-query'; +// import { DatabaseQuery } from '../lib/private/database-query'; +// +// describe('database query', () => { +// let stack: cdk.Stack; +// let vpc: ec2.Vpc; +// let cluster: redshift.ICluster; +// let minimalProps: DatabaseQueryProps; +// +// beforeEach(() => { +// stack = new cdk.Stack(); +// vpc = new ec2.Vpc(stack, 'VPC'); +// cluster = new redshift.Cluster(stack, 'Cluster', { +// vpc: vpc, +// masterUser: { +// masterUsername: 'admin', +// }, +// }); +// minimalProps = { +// cluster: cluster, +// databaseName: 'databaseName', +// handler: 'handler', +// properties: {}, +// }; +// }); +// +// describe('admin user', () => { +// it('takes from cluster by default', () => { +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// adminUserArn: { Ref: 'ClusterSecretAttachment769E6258' }, +// }); +// }); +// +// it('grants read permission to handler', () => { +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { +// PolicyDocument: { +// Statement: Match.arrayWith([{ +// Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], +// Effect: 'Allow', +// Resource: { Ref: 'ClusterSecretAttachment769E6258' }, +// }]), +// }, +// Roles: [{ Ref: 'QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717' }], +// }); +// }); +// +// it('uses admin user if provided', () => { +// cluster = new redshift.Cluster(stack, 'Cluster With Provided Admin Secret', { +// vpc, +// vpcSubnets: { +// subnetType: ec2.SubnetType.PUBLIC, +// }, +// masterUser: { +// masterUsername: 'admin', +// masterPassword: cdk.SecretValue.unsafePlainText('INSECURE_NOT_FOR_PRODUCTION'), +// }, +// publiclyAccessible: true, +// }); +// +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// adminUser: secretsmanager.Secret.fromSecretNameV2(stack, 'Imported Admin User', 'imported-admin-secret'), +// cluster, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// adminUserArn: { +// 'Fn::Join': [ +// '', +// [ +// 'arn:', +// { +// Ref: 'AWS::Partition', +// }, +// ':secretsmanager:', +// { +// Ref: 'AWS::Region', +// }, +// ':', +// { +// Ref: 'AWS::AccountId', +// }, +// ':secret:imported-admin-secret', +// ], +// ], +// }, +// }); +// }); +// +// it('throws error if admin user not provided and cluster was provided a admin password', () => { +// cluster = new redshift.Cluster(stack, 'Cluster With Provided Admin Secret', { +// vpc, +// vpcSubnets: { +// subnetType: ec2.SubnetType.PUBLIC, +// }, +// masterUser: { +// masterUsername: 'admin', +// masterPassword: cdk.SecretValue.unsafePlainText('INSECURE_NOT_FOR_PRODUCTION'), +// }, +// publiclyAccessible: true, +// }); +// +// expect(() => new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// cluster, +// })).toThrow('Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster did not generate admin user credentials (they were provided explicitly)'); +// }); +// +// it('throws error if admin user not provided and cluster was imported', () => { +// cluster = redshift.Cluster.fromClusterAttributes(stack, 'Imported Cluster', { +// clusterName: 'imported-cluster', +// clusterEndpointAddress: 'imported-cluster.abcdefghijk.xx-west-1.redshift.amazonaws.com', +// clusterEndpointPort: 5439, +// }); +// +// expect(() => new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// cluster, +// })).toThrow('Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster was imported'); +// }); +// }); +// +// it('provides database params to Lambda handler', () => { +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// clusterName: { +// Ref: 'ClusterEB0386A7', +// }, +// adminUserArn: { +// Ref: 'ClusterSecretAttachment769E6258', +// }, +// databaseName: 'databaseName', +// handler: 'handler', +// }); +// }); +// +// it('grants statement permissions to handler', () => { +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { +// PolicyDocument: { +// Statement: Match.arrayWith([{ +// Action: ['redshift-data:DescribeStatement', 'redshift-data:ExecuteStatement'], +// Effect: 'Allow', +// Resource: '*', +// }]), +// }, +// Roles: [{ Ref: 'QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717' }], +// }); +// }); +// +// describe('timeout', () => { +// it('passes timeout', () => { +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// timeout: cdk.Duration.minutes(5), +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { +// Timeout: 300, +// Role: { 'Fn::GetAtt': ['QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717', 'Arn'] }, +// Handler: 'index.handler', +// Code: { +// S3Bucket: { 'Fn::Sub': 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}' }, +// }, +// }); +// }); +// +// it('throw error for timeout being too short', () => { +// expect(() => new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// timeout: cdk.Duration.millis(999), +// })).toThrow('The timeout for the handler must be BETWEEN 1 second and 15 minutes, got 999 milliseconds.'); +// }); +// +// it('throw error for timeout being too long', () => { +// expect(() => new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// timeout: cdk.Duration.minutes(16), +// })).toThrow('The timeout for the handler must be between 1 second and 15 minutes, got 960 seconds.'); +// }); +// }); +// +// it('passes removal policy through', () => { +// new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// removalPolicy: cdk.RemovalPolicy.DESTROY, +// }); +// +// Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { +// DeletionPolicy: 'Delete', +// }); +// }); +// +// it('passes applyRemovalPolicy through', () => { +// const query = new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// }); +// +// query.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); +// +// Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { +// DeletionPolicy: 'Delete', +// }); +// }); +// +// it('passes gettAtt through', () => { +// const query = new DatabaseQuery(stack, 'Query', { +// ...minimalProps, +// }); +// +// expect(stack.resolve(query.getAtt('attribute'))).toStrictEqual({ 'Fn::GetAtt': ['Query435140A1', 'attribute'] }); +// expect(stack.resolve(query.getAttString('attribute'))).toStrictEqual({ 'Fn::GetAtt': ['Query435140A1', 'attribute'] }); +// }); +// +// it('creates at most one IAM invoker role for handler', () => { +// new DatabaseQuery(stack, 'Query0', { +// ...minimalProps, +// }); +// +// new DatabaseQuery(stack, 'Query1', { +// ...minimalProps, +// }); +// +// new DatabaseQuery(stack, 'Query2', { +// ...minimalProps, +// }); +// +// const template = Template.fromStack(stack).toJSON(); +// const iamRoles = Object.entries(template.Resources) +// .map(([k, v]) => [k, Object.getOwnPropertyDescriptor(v, 'Type')?.value]) +// .filter(([k, v]) => v === 'AWS::IAM::Role' && k.toString().includes('InvokerRole')); +// +// expect(iamRoles.length === 1); +// }); +// }); +// +// -- END fully commented-out upstream port of test/database-query.test.ts -- diff --git a/test/aws/storage/redshift/database-secret.test.ts b/test/aws/storage/redshift/database-secret.test.ts new file mode 100644 index 00000000..23b55d03 --- /dev/null +++ b/test/aws/storage/redshift/database-secret.test.ts @@ -0,0 +1,109 @@ +// upstream @aws-cdk/aws-redshift-alpha has no dedicated `test/database-secret.test.ts` (its +// `DatabaseSecret` is exercised only indirectly, via cluster.test.ts fixtures). This file mirrors +// the sibling `../rds/database-secret.test.ts` port style (itself from +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/database-secret.test.ts), +// scoped down to the props `redshift.DatabaseSecret` actually accepts (username, encryptionKey, +// excludeCharacters, recoveryWindow — no dbname/secretName/masterSecret/ +// replaceOnPasswordCriteriaChanges, none of which exist on upstream redshift-alpha's +// `DatabaseSecretProps`). + +import { + dataAwsSecretsmanagerRandomPassword, + secretsmanagerSecret, + secretsmanagerSecretVersion, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import { DatabaseSecret } from "../../../../src/aws/storage/redshift"; +import { Duration } from "../../../../src/duration"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +}); + +describe("database secret", () => { + test("create a database secret", () => { + // WHEN + new DatabaseSecret(stack, "Secret", { + username: "admin-username", + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + {}, + ); + // TERRACONSTRUCTS DEVIATION: the Terraform `aws_secretsmanager_secret` + // resource has no CFN-style `GenerateSecretString` block -- the + // generated password comes from a `data.aws_secretsmanager_random_password` + // data source instead, merged into the secret's `secret_string` via + // `jsonencode()`. See `src/aws/encryption/secret.ts`. + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + password_length: 30, + exclude_characters: "\"@/\\ '", + }, + ); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecretVersion.SecretsmanagerSecretVersion, + { + secret_string: expect.stringMatching( + /^\$\{jsonencode\(\{"username" = "admin-username", "password" = data\.aws_secretsmanager_random_password\.Secret_RandomPassword_[0-9A-F]+\.random_password\}\)\}$/, + ), + }, + ); + }); + + test("custom excludeCharacters", () => { + // WHEN + new DatabaseSecret(stack, "Secret", { + username: "admin-username", + excludeCharacters: '"@/\\', + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: '"@/\\', + }, + ); + }); + + test("recoveryWindow is passed through", () => { + // WHEN + new DatabaseSecret(stack, "Secret", { + username: "admin-username", + recoveryWindow: Duration.days(0), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + recovery_window_in_days: 0, + }, + ); + }); +}); diff --git a/test/aws/storage/redshift/endpoint.test.ts b/test/aws/storage/redshift/endpoint.test.ts new file mode 100644 index 00000000..bb99ad7b --- /dev/null +++ b/test/aws/storage/redshift/endpoint.test.ts @@ -0,0 +1,59 @@ +// upstream @aws-cdk/aws-redshift-alpha has no dedicated `test/endpoint.test.ts` (Endpoint is only +// exercised indirectly via cluster.test.ts). This file mirrors the sibling +// `../neptune/endpoint.test.ts` port (itself from +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/endpoint.test.ts), +// since `redshift.Endpoint` is structurally identical to `neptune.Endpoint` (hostname/port/socketAddress). + +import { Token } from "cdktn"; +import { Endpoint } from "../../../../src/aws/storage/redshift"; + +const CDK_NUMERIC_TOKEN = Token.asNumber({ Ref: "abc" }); + +describe("Endpoint", () => { + test("accepts tokens for the port value", () => { + // GIVEN + const token = CDK_NUMERIC_TOKEN; + + // WHEN + const endpoint = new Endpoint("127.0.0.1", token); + + // THEN + expect(endpoint.port).toBe(token); + }); + + test("accepts valid port string numbers", () => { + // GIVEN + for (const port of [1, 50, 65535]) { + // WHEN + const endpoint = new Endpoint("127.0.0.1", port); + + // THEN + expect(endpoint.port).toBe(port); + } + }); + + describe(".socketAddress", () => { + test("combines hostname and port", () => { + // GIVEN + const endpoint = new Endpoint("127.0.0.1", 1500); + + // THEN + expect(endpoint.socketAddress).toBe("127.0.0.1:1500"); + }); + + test("stringifies port tokens", () => { + // GIVEN + const port = CDK_NUMERIC_TOKEN; + const endpoint = new Endpoint("127.0.0.1", port); + + // WHEN + const result = endpoint.socketAddress; + + // THEN + // Should embed a string token (not just the raw numeric token's own + // string representation). + expect(Token.isUnresolved(result)).toBeTruthy(); + expect(result).not.toBe(`127.0.0.1:${port.toString()}`); + }); + }); +}); diff --git a/test/aws/storage/redshift/parameter-group.test.ts b/test/aws/storage/redshift/parameter-group.test.ts new file mode 100644 index 00000000..e588e883 --- /dev/null +++ b/test/aws/storage/redshift/parameter-group.test.ts @@ -0,0 +1,188 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/parameter-group.test.ts + +import { redshiftParameterGroup } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as redshift from "../../../../src/aws/storage/redshift"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +}); + +test("create a cluster parameter group", () => { + // WHEN + new redshift.ClusterParameterGroup(stack, "Params", { + description: "desc", + parameters: { + param: "value", + }, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + description: "desc", + family: "redshift-1.0", + parameter: [ + { + name: "param", + value: "value", + }, + ], + }, + ); +}); + +test("check automatically generated descriptions", () => { + // WHEN + new redshift.ClusterParameterGroup(stack, "Params", { + parameters: { + param: "value", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + description: "Cluster parameter group for family redshift-1.0", + family: "redshift-1.0", + }, + ); +}); + +test("check that name defaults to a gridUUID-scoped generated name", () => { + // WHEN + new redshift.ClusterParameterGroup(stack, "Params", { + parameters: {}, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + redshiftParameterGroup.RedshiftParameterGroup, + ) as any[]; + expect(resource.name).toEqual(expect.any(String)); + expect(resource.name).toEqual(resource.name.toLowerCase()); +}); + +test("check that an explicit name is honored", () => { + // WHEN + new redshift.ClusterParameterGroup(stack, "Params", { + clusterParameterGroupName: "my-group", + parameters: {}, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + name: "my-group", + }, + ); +}); + +test("check that fromClusterParameterGroupName imports by name", () => { + // WHEN + const group = redshift.ClusterParameterGroup.fromClusterParameterGroupName( + stack, + "Imported", + "my-existing-group", + ); + + // THEN + expect(group.clusterParameterGroupName).toEqual("my-existing-group"); + const t = new Template(stack); + t.resourceCountIs(redshiftParameterGroup.RedshiftParameterGroup, 0); +}); + +describe("Adding parameters to an existing group", () => { + test("Adding a new parameter", () => { + // GIVEN + const params = new redshift.ClusterParameterGroup(stack, "Params", { + description: "desc", + parameters: { + param: "value", + }, + }); + + // WHEN + params.addParameter("param2", "value2"); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + description: "desc", + family: "redshift-1.0", + parameter: [ + { name: "param", value: "value" }, + { name: "param2", value: "value2" }, + ], + }, + ); + }); + + test("Adding an existing named parameter with the same value", () => { + // GIVEN + const params = new redshift.ClusterParameterGroup(stack, "Params", { + description: "desc", + parameters: { + param: "value", + }, + }); + + // WHEN + params.addParameter("param", "value"); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftParameterGroup.RedshiftParameterGroup, + { + description: "desc", + family: "redshift-1.0", + parameter: [{ name: "param", value: "value" }], + }, + ); + }); + + test("Adding an existing named parameter with a different value", () => { + // GIVEN + const params = new redshift.ClusterParameterGroup(stack, "Params", { + description: "desc", + parameters: { + param: "value", + }, + }); + + // WHEN + expect(() => params.addParameter("param", "value2")) + // THEN + .toThrow("The parameter group already contains the parameter"); + }); +}); diff --git a/test/aws/storage/redshift/privileges.test.ts b/test/aws/storage/redshift/privileges.test.ts new file mode 100644 index 00000000..5dc6b8a1 --- /dev/null +++ b/test/aws/storage/redshift/privileges.test.ts @@ -0,0 +1,147 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/privileges.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/privileges.test.ts -- +// +// import * as cdk from 'aws-cdk-lib'; +// import { Template } from 'aws-cdk-lib/assertions'; +// import * as ec2 from 'aws-cdk-lib/aws-ec2'; +// import * as redshift from '../lib'; +// +// describe('table privileges', () => { +// let stack: cdk.Stack; +// let vpc: ec2.Vpc; +// let cluster: redshift.ICluster; +// const databaseName = 'databaseName'; +// let databaseOptions: redshift.DatabaseOptions; +// const tableColumns = [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }]; +// let table: redshift.ITable; +// let table2: redshift.ITable; +// +// beforeEach(() => { +// stack = new cdk.Stack(); +// vpc = new ec2.Vpc(stack, 'VPC'); +// cluster = new redshift.Cluster(stack, 'Cluster', { +// vpc: vpc, +// vpcSubnets: { +// subnetType: ec2.SubnetType.PUBLIC, +// }, +// masterUser: { +// masterUsername: 'admin', +// }, +// publiclyAccessible: true, +// }); +// databaseOptions = { +// cluster, +// databaseName, +// }; +// table = redshift.Table.fromTableAttributes(stack, 'Table', { +// tableName: 'tableName', +// tableColumns, +// cluster, +// databaseName, +// }); +// table2 = redshift.Table.fromTableAttributes(stack, 'Table 2', { +// tableName: 'tableName2', +// tableColumns, +// cluster, +// databaseName, +// }); +// }); +// +// it('adding table privilege creates custom resource', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// user.addTablePrivileges(table, redshift.TableAction.INSERT); +// user.addTablePrivileges(table2, redshift.TableAction.SELECT, redshift.TableAction.DROP); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// username: { +// 'Fn::GetAtt': [ +// 'UserFDDCDD17', +// 'username', +// ], +// }, +// tablePrivileges: [{ tableName: 'tableName', actions: ['INSERT'] }, { tableName: 'tableName2', actions: ['SELECT', 'DROP'] }], +// }); +// }); +// +// it('table privileges are deduplicated', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// user.addTablePrivileges(table, redshift.TableAction.INSERT, redshift.TableAction.INSERT, redshift.TableAction.DELETE); +// user.addTablePrivileges(table, redshift.TableAction.SELECT, redshift.TableAction.DELETE); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// username: { +// 'Fn::GetAtt': [ +// 'UserFDDCDD17', +// 'username', +// ], +// }, +// tablePrivileges: [{ tableName: 'tableName', actions: ['INSERT', 'DELETE', 'SELECT'] }], +// }); +// }); +// +// it('table privileges are removed when ALL specified', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// user.addTablePrivileges(table, redshift.TableAction.ALL, redshift.TableAction.INSERT); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// username: { +// 'Fn::GetAtt': [ +// 'UserFDDCDD17', +// 'username', +// ], +// }, +// tablePrivileges: [{ tableName: 'tableName', actions: ['ALL'] }], +// }); +// }); +// +// it('SELECT table privilege is added when UPDATE or DELETE is specified', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// user.addTablePrivileges(table, redshift.TableAction.UPDATE); +// user.addTablePrivileges(table2, redshift.TableAction.DELETE); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// username: { +// 'Fn::GetAtt': [ +// 'UserFDDCDD17', +// 'username', +// ], +// }, +// tablePrivileges: [{ tableName: 'tableName', actions: ['UPDATE', 'SELECT'] }, { tableName: 'tableName2', actions: ['DELETE', 'SELECT'] }], +// }); +// }); +// }); +// +// -- END fully commented-out upstream port of test/privileges.test.ts -- diff --git a/test/aws/storage/redshift/subnet-group.test.ts b/test/aws/storage/redshift/subnet-group.test.ts new file mode 100644 index 00000000..0a56b0c0 --- /dev/null +++ b/test/aws/storage/redshift/subnet-group.test.ts @@ -0,0 +1,143 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/subnet-group.test.ts + +import { redshiftSubnetGroup } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as redshift from "../../../../src/aws/storage/redshift"; +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, + }); + // TERRACONSTRUCTS DEVIATION: upstream's `new ec2.Vpc(stack, 'VPC')` picks up 2 AZs from the + // CDK test app's agnostic environment; base's `AwsStack` availability-zone lookup defaults to + // 3 AZs, so `maxAzs: 2` is pinned here to keep the subnet-count assertions below matching + // upstream 1:1 (mirrors `../rds/subnet-group.test.ts` and `../neptune/subnet-group.test.ts`'s + // identical adaptation) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/subnet-group.test.ts#L11 + vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); +}); + +test("creates a subnet group from minimal properties", () => { + new redshift.ClusterSubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + }); + + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + redshiftSubnetGroup.RedshiftSubnetGroup, + { + description: "MyGroup", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }, + ); +}); + +describe("subnet selection", () => { + test("defaults to private subnets", () => { + new redshift.ClusterSubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + }); + + const t = new Template(stack); + t.resourceCountIs(redshiftSubnetGroup.RedshiftSubnetGroup, 1); + t.expect.toHaveResourceWithProperties( + redshiftSubnetGroup.RedshiftSubnetGroup, + { + description: "MyGroup", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }, + ); + }); + + test("can specify subnet type", () => { + new redshift.ClusterSubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + vpcSubnets: { subnetType: compute.SubnetType.PUBLIC }, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftSubnetGroup.RedshiftSubnetGroup, + { + description: "MyGroup", + subnet_ids: [ + stack.resolve(vpc.publicSubnets[0].subnetId), + stack.resolve(vpc.publicSubnets[1].subnetId), + ], + }, + ); + }); +}); + +test("check that name defaults to a gridUUID-scoped generated (lowercased) name", () => { + new redshift.ClusterSubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + }); + + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + redshiftSubnetGroup.RedshiftSubnetGroup, + ) as any[]; + expect(resource.name).toEqual(expect.any(String)); + expect(resource.name).toEqual(resource.name.toLowerCase()); +}); + +test("check that an explicit name is honored (lowercased)", () => { + new redshift.ClusterSubnetGroup(stack, "Group", { + description: "My Shared Group", + clusterSubnetGroupName: "SharedGroup", + vpc, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + redshiftSubnetGroup.RedshiftSubnetGroup, + { + description: "My Shared Group", + // TERRACONSTRUCTS DEVIATION: Redshift stores subnet group names lowercase server-side (see + // `ClusterSubnetGroup`'s naming note in + // `../../../../src/aws/storage/redshift/subnet-group.ts`). + name: "sharedgroup", + }, + ); +}); + +test("import group by name", () => { + const subnetGroup = redshift.ClusterSubnetGroup.fromClusterSubnetGroupName( + stack, + "Group", + "my-subnet-group", + ); + + expect(subnetGroup.clusterSubnetGroupName).toEqual("my-subnet-group"); +}); diff --git a/test/aws/storage/redshift/table.test.ts b/test/aws/storage/redshift/table.test.ts new file mode 100644 index 00000000..da04a7bc --- /dev/null +++ b/test/aws/storage/redshift/table.test.ts @@ -0,0 +1,394 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/table.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/table.test.ts -- +// +// import * as cdk from 'aws-cdk-lib'; +// import { Template } from 'aws-cdk-lib/assertions'; +// import * as ec2 from 'aws-cdk-lib/aws-ec2'; +// +// import { REDSHIFT_COLUMN_ID } from 'aws-cdk-lib/cx-api'; +// import * as redshift from '../lib'; +// +// describe('cluster table', () => { +// const tableName = 'tableName'; +// const tableColumns: redshift.Column[] = [ +// { name: 'col1', dataType: 'varchar(4)' }, +// { name: 'col2', dataType: 'float' }, +// ]; +// +// let stack: cdk.Stack; +// let vpc: ec2.Vpc; +// let cluster: redshift.ICluster; +// let databaseOptions: redshift.DatabaseOptions; +// +// beforeEach(() => { +// stack = new cdk.Stack(); +// vpc = new ec2.Vpc(stack, 'VPC'); +// cluster = new redshift.Cluster(stack, 'Cluster', { +// vpc: vpc, +// vpcSubnets: { +// subnetType: ec2.SubnetType.PUBLIC, +// }, +// masterUser: { +// masterUsername: 'admin', +// }, +// publiclyAccessible: true, +// }); +// databaseOptions = { +// cluster: cluster, +// databaseName: 'databaseName', +// }; +// }); +// +// it('creates using custom resource', () => { +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// tableName: { +// prefix: 'Table', +// generateSuffix: 'true', +// }, +// tableColumns, +// }); +// }); +// +// it('tableName property is pulled from custom resource', () => { +// const table = new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// }); +// +// expect(stack.resolve(table.tableName)).toStrictEqual({ +// Ref: 'Table7ABB320E', +// }); +// }); +// +// it('uses table name when provided', () => { +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableName, +// tableColumns, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// tableName: { +// prefix: tableName, +// generateSuffix: 'false', +// }, +// }); +// }); +// +// it('can import from name and columns', () => { +// const table = redshift.Table.fromTableAttributes(stack, 'Table', { +// tableName, +// tableColumns, +// cluster, +// databaseName: 'databaseName', +// }); +// +// expect(table.tableName).toBe(tableName); +// expect(table.tableColumns).toStrictEqual(tableColumns); +// expect(table.cluster).toBe(cluster); +// expect(table.databaseName).toBe('databaseName'); +// }); +// +// it('grant adds privileges to user', () => { +// const user = redshift.User.fromUserAttributes(stack, 'User', { +// ...databaseOptions, +// username: 'username', +// password: cdk.SecretValue.unsafePlainText('INSECURE_NOT_FOR_PRODUCTION'), +// }); +// const table = redshift.Table.fromTableAttributes(stack, 'Table', { +// tableName, +// tableColumns, +// cluster, +// databaseName: 'databaseName', +// }); +// +// table.grant(user, redshift.TableAction.INSERT); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// handler: 'user-table-privileges', +// }); +// }); +// +// it('retains table on deletion by default', () => { +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// }); +// +// Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { +// Properties: { +// handler: 'table', +// }, +// DeletionPolicy: 'Retain', +// }); +// }); +// +// it('destroys table on deletion if requested', () => { +// const table = new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// }); +// +// table.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); +// +// Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { +// Properties: { +// handler: 'table', +// }, +// DeletionPolicy: 'Delete', +// }); +// }); +// +// describe('columnId', () => { +// it('throws if column ids are not unique', async () => { +// const updatedTableColumns: redshift.Column[] = [ +// { id: 'col1', name: 'col1', dataType: 'varchar(4)' }, +// { id: 'col1', name: 'col2', dataType: 'float' }, +// ]; +// +// expect(() => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: updatedTableColumns, +// }), +// ).toThrow("Column id 'col1' is not unique."); +// }); +// +// it('populates column id if no id provided', () => { +// const updatedTableColumns: redshift.Column[] = [ +// { id: 'col1', name: 'col1', dataType: 'varchar(4)' }, +// { name: 'col2', dataType: 'float' }, +// ]; +// +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: updatedTableColumns, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// tableColumns: [ +// { id: 'col1', name: 'col1', dataType: 'varchar(4)' }, +// { id: 'col2', name: 'col2', dataType: 'float' }, +// ], +// }); +// }); +// }); +// +// describe('@aws-cdk/aws-redshift:columnId', () => { +// it('uses column ids if feature flag provided', () => { +// const app = new cdk.App({ context: { [REDSHIFT_COLUMN_ID]: true } }); +// const newStack = new cdk.Stack(app, 'NewStack'); +// vpc = new ec2.Vpc(newStack, 'VPC'); +// cluster = new redshift.Cluster(newStack, 'Cluster', { +// vpc: vpc, +// vpcSubnets: { +// subnetType: ec2.SubnetType.PUBLIC, +// }, +// masterUser: { +// masterUsername: 'admin', +// }, +// publiclyAccessible: true, +// }); +// databaseOptions = { +// cluster: cluster, +// databaseName: 'databaseName', +// }; +// +// new redshift.Table(newStack, 'Table', { +// ...databaseOptions, +// tableColumns, +// }); +// +// Template.fromStack(newStack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// useColumnIds: true, +// }); +// }); +// +// it('does not use column ids if feature flag not provided', () => { +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// }); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// useColumnIds: false, +// }); +// }); +// }); +// +// describe('distKey and distStyle', () => { +// it('throws if more than one distKeys are configured', () => { +// const updatedTableColumns: redshift.Column[] = [ +// ...tableColumns, +// { name: 'col3', dataType: 'varchar(4)', distKey: true }, +// { name: 'col4', dataType: 'float', distKey: true }, +// ]; +// +// expect( +// () => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: updatedTableColumns, +// }), +// ).toThrow(/Only one column can be configured as distKey./); +// }); +// +// it('throws if distStyle other than KEY is configured with configured distKey column', () => { +// const updatedTableColumns: redshift.Column[] = [ +// ...tableColumns, +// { name: 'col3', dataType: 'varchar(4)', distKey: true }, +// ]; +// +// expect( +// () => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: updatedTableColumns, +// distStyle: redshift.TableDistStyle.EVEN, +// }), +// ).toThrow(`Only 'TableDistStyle.KEY' can be configured when distKey is also configured. Found ${redshift.TableDistStyle.EVEN}`); +// }); +// +// it('throws if KEY distStyle is configired with no distKey column', () => { +// expect( +// () => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// distStyle: redshift.TableDistStyle.KEY, +// }), +// ).toThrow('distStyle of "TableDistStyle.KEY" can only be configured when distKey is also configured.'); +// }); +// }); +// +// describe('sortKeys and sortStyle', () => { +// it('configures default sortStyle based on sortKeys if no sortStyle is passed: AUTO', () => { +// // GIVEN +// const tableColumnsWithoutSortKey = tableColumns; +// +// // WHEN +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: tableColumnsWithoutSortKey, +// }); +// +// // THEN +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// sortStyle: redshift.TableSortStyle.AUTO, +// }); +// }); +// +// it('configures default sortStyle based on sortKeys if no sortStyle is passed: COMPOUND', () => { +// // GIVEN +// const tableColumnsWithSortKey: redshift.Column[] = [ +// ...tableColumns, +// { name: 'col3', dataType: 'varchar(4)', sortKey: true }, +// ]; +// +// // WHEN +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: tableColumnsWithSortKey, +// }); +// +// // THEN +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// sortStyle: redshift.TableSortStyle.COMPOUND, +// }); +// }); +// +// it('throws if sortStlye other than AUTO is passed with no configured sortKeys', () => { +// expect( +// () => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// sortStyle: redshift.TableSortStyle.COMPOUND, +// }), +// ).toThrow(`sortStyle of '${redshift.TableSortStyle.COMPOUND}' can only be configured when sortKey is also configured.`); +// }); +// +// it('throws if sortStlye of AUTO is passed with some configured sortKeys', () => { +// // GIVEN +// const tableColumnsWithSortKey: redshift.Column[] = [ +// ...tableColumns, +// { name: 'col3', dataType: 'varchar(4)', sortKey: true }, +// ]; +// +// // THEN +// expect( +// () => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns: tableColumnsWithSortKey, +// sortStyle: redshift.TableSortStyle.AUTO, +// }), +// ).toThrow(`sortStyle of '${redshift.TableSortStyle.AUTO}' cannot be configured when sortKey is also configured.`); +// }); +// }); +// +// describe('timeout', () => { +// test('specify timeout', () => { +// new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// timeout: cdk.Duration.minutes(5), +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { +// Timeout: 300, +// Role: { 'Fn::GetAtt': ['QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717', 'Arn'] }, +// Handler: 'index.handler', +// Code: { +// S3Bucket: { 'Fn::Sub': 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}' }, +// }, +// }); +// }); +// +// test('throw error for timeout being too short', () => { +// expect(() => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// timeout: cdk.Duration.millis(999), +// })).toThrow('The timeout for the handler must be BETWEEN 1 second and 15 minutes, got 999 milliseconds.'); +// }); +// +// test('throw error for timeout being too long', () => { +// expect(() => new redshift.Table(stack, 'Table', { +// ...databaseOptions, +// tableColumns, +// timeout: cdk.Duration.minutes(16), +// })).toThrow('The timeout for the handler must be between 1 second and 15 minutes, got 960 seconds.'); +// }); +// }); +// }); +// +// -- END fully commented-out upstream port of test/table.test.ts -- diff --git a/test/aws/storage/redshift/user.test.ts b/test/aws/storage/redshift/user.test.ts new file mode 100644 index 00000000..3bad772c --- /dev/null +++ b/test/aws/storage/redshift/user.test.ts @@ -0,0 +1,274 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// +// TODO(scope-reduction): omitted in this port. Upstream's Table/User test surface (this file +// and its siblings test/table.test.ts, test/user.test.ts, test/privileges.test.ts, +// test/database-query.test.ts, and test/database-query-provider/**) exercises the Table/User L2s +// and their Lambda custom-resource handler (`Custom::RedshiftDatabaseQuery`), which are +// themselves ported as fully commented-out files -- see the leading TODO block in +// `../table.ts` / `../user.ts` / `../private/database-query.ts` for the full rationale +// (TerraConstructs has no framework equivalent to CDK's `Provider`/`CustomResource` L2s in this +// repo yet). Per the "comment out, never delete" scope-reduction directive for this PR, this +// test file is ported here verbatim but fully commented out rather than dropped, so +// re-enablement is a de-commenting exercise (in lockstep with `../table.ts` / `../user.ts` / +// `../private/**`) once a custom-resource Lambda framework lands in this repo. +// +// Permalinks (v2.263.0): +// test/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/table.test.ts +// test/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/user.test.ts +// test/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/privileges.test.ts +// test/database-query.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query.test.ts +// test/database-query-provider/escape.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/escape.test.ts +// test/database-query-provider/index.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/index.test.ts +// test/database-query-provider/privileges.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts +// test/database-query-provider/table.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/table.test.ts +// test/database-query-provider/user.test.ts https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/user.test.ts + +// Placeholder so this suite satisfies Jest's "must contain at least one test" requirement while +// every upstream test below stays fully commented out (never deleted, per the scope-reduction +// directive). Remove this stub in the same de-commenting pass that re-enables the tests below. +test.skip("scope-reduction: test/user.test.ts ported commented-out, see TODO above", () => {}); + +// -- BEGIN fully commented-out upstream port of test/user.test.ts -- +// +// import * as cdk from 'aws-cdk-lib'; +// import { Match, Template } from 'aws-cdk-lib/assertions'; +// import * as ec2 from 'aws-cdk-lib/aws-ec2'; +// import * as kms from 'aws-cdk-lib/aws-kms'; +// import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +// import * as redshift from '../lib'; +// +// describe('cluster user', () => { +// let stack: cdk.Stack; +// let vpc: ec2.Vpc; +// let cluster: redshift.ICluster; +// const databaseName = 'databaseName'; +// let databaseOptions: redshift.DatabaseOptions; +// +// beforeEach(() => { +// stack = new cdk.Stack(); +// vpc = new ec2.Vpc(stack, 'VPC'); +// cluster = new redshift.Cluster(stack, 'Cluster', { +// vpc: vpc, +// vpcSubnets: { +// subnetType: ec2.SubnetType.PUBLIC, +// }, +// masterUser: { +// masterUsername: 'admin', +// }, +// publiclyAccessible: true, +// }); +// databaseOptions = { +// cluster, +// databaseName, +// }; +// }); +// +// it('creates using custom resource', () => { +// new redshift.User(stack, 'User', databaseOptions); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// passwordSecretArn: { Ref: 'UserSecretAttachment02022609' }, +// }); +// Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { +// PolicyDocument: { +// Statement: Match.arrayWith([{ +// Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], +// Effect: 'Allow', +// Resource: { Ref: 'UserSecretAttachment02022609' }, +// }]), +// }, +// Roles: [{ Ref: 'QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717' }], +// }); +// }); +// +// it('creates database secret', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { +// GenerateSecretString: { +// SecretStringTemplate: `{"username":"${cdk.Names.uniqueId(user).toLowerCase()}"}`, +// }, +// }); +// Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::SecretTargetAttachment', { +// SecretId: { Ref: 'UserSecretE2C04A69' }, +// }); +// }); +// +// it('username property is pulled from custom resource', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// expect(stack.resolve(user.username)).toStrictEqual({ +// 'Fn::GetAtt': [ +// 'UserFDDCDD17', +// 'username', +// ], +// }); +// }); +// +// it('password property is pulled from attached secret', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// expect(stack.resolve(user.password)).toStrictEqual({ +// 'Fn::Join': [ +// '', +// [ +// '{{resolve:secretsmanager:', +// { +// Ref: 'UserSecretAttachment02022609', +// }, +// ':SecretString:password::}}', +// ], +// ], +// }); +// }); +// +// it('secret property is exposed', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// expect(stack.resolve(user.secret.secretArn)).toStrictEqual({ +// Ref: 'UserSecretE2C04A69', +// }); +// }); +// +// it('uses username when provided', () => { +// const username = 'username'; +// +// new redshift.User(stack, 'User', { +// ...databaseOptions, +// username, +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { +// GenerateSecretString: { +// SecretStringTemplate: `{"username":"${username}"}`, +// }, +// }); +// }); +// +// it('can import from username and password', () => { +// const userSecret = secretsmanager.Secret.fromSecretNameV2(stack, 'User Secret', 'redshift-user-secret'); +// +// const user = redshift.User.fromUserAttributes(stack, 'User', { +// ...databaseOptions, +// username: userSecret.secretValueFromJson('username').toString(), +// password: userSecret.secretValueFromJson('password'), +// }); +// +// expect(stack.resolve(user.username)).toStrictEqual({ +// 'Fn::Join': [ +// '', +// [ +// '{{resolve:secretsmanager:arn:', +// { +// Ref: 'AWS::Partition', +// }, +// ':secretsmanager:', +// { +// Ref: 'AWS::Region', +// }, +// ':', +// { +// Ref: 'AWS::AccountId', +// }, +// ':secret:redshift-user-secret:SecretString:username::}}', +// ], +// ], +// }); +// expect(stack.resolve(user.password)).toStrictEqual({ +// 'Fn::Join': [ +// '', +// [ +// '{{resolve:secretsmanager:arn:', +// { +// Ref: 'AWS::Partition', +// }, +// ':secretsmanager:', +// { +// Ref: 'AWS::Region', +// }, +// ':', +// { +// Ref: 'AWS::AccountId', +// }, +// ':secret:redshift-user-secret:SecretString:password::}}', +// ], +// ], +// }); +// }); +// +// it('destroys user on deletion by default', () => { +// new redshift.User(stack, 'User', databaseOptions); +// +// Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { +// Properties: { +// passwordSecretArn: { Ref: 'UserSecretAttachment02022609' }, +// }, +// DeletionPolicy: 'Delete', +// }); +// }); +// +// it('retains user on deletion if requested', () => { +// const user = new redshift.User(stack, 'User', databaseOptions); +// +// user.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); +// +// Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { +// Properties: { +// passwordSecretArn: { Ref: 'UserSecretAttachment02022609' }, +// }, +// DeletionPolicy: 'Retain', +// }); +// }); +// +// it('uses encryption key if one is provided', () => { +// const encryptionKey = new kms.Key(stack, 'Key'); +// +// new redshift.User(stack, 'User', { +// ...databaseOptions, +// encryptionKey, +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { +// KmsKeyId: stack.resolve(encryptionKey.keyArn), +// }); +// }); +// +// it('addTablePrivileges grants access to table', () => { +// const user = redshift.User.fromUserAttributes(stack, 'User', { +// ...databaseOptions, +// username: 'username', +// password: cdk.SecretValue.unsafePlainText('INSECURE_NOT_FOR_PRODUCTION'), +// }); +// const table = redshift.Table.fromTableAttributes(stack, 'Table', { +// tableName: 'tableName', +// tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], +// cluster, +// databaseName: 'databaseName', +// }); +// +// user.addTablePrivileges(table, redshift.TableAction.INSERT); +// +// Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { +// handler: 'user-table-privileges', +// }); +// }); +// +// it('set excludeCharacters', () => { +// const username = 'username'; +// +// new redshift.User(stack, 'User', { +// ...databaseOptions, +// username, +// excludeCharacters: '"@/\\\ \'`', +// }); +// +// Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { +// GenerateSecretString: { +// ExcludeCharacters: '"@/\\\ \'`', +// SecretStringTemplate: `{"username":"${username}"}`, +// }, +// }); +// }); +// }); +// +// -- END fully commented-out upstream port of test/user.test.ts --