From 6586b81cbd9deb65faaeb354f213019bb8655fbd Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Fri, 7 Aug 2026 07:38:47 +0700 Subject: [PATCH] =?UTF-8?q?feat(aws):=20storage.neptune=20=E2=80=94=20@aws?= =?UTF-8?q?-cdk/aws-neptune-alpha=20port=20at=20v2.263.0-alpha.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 6 upstream files into storage.neptune, 83 tests. Neptune Serverless maps as a native typed serverless_v2_scaling_configuration block (docdb pattern — value known synchronously, no addOverride) with upstream's full validation set. kmsKey → kms_key_arn (the #151 id-vs-ARN lesson pre-applied). grant/grantConnect render neptune-db IAM statements against the clusterResourceIdentifier ARN with lazy iam-auth enablement. House patterns: grid-lowercased identifiers + derived-base instance naming (instanceN, regression-tested), skipFinalSnapshot/ finalSnapshotIdentifier + synth warning, Lambda-backed log retention TODO-omitted (native enable_cloudwatch_logs_exports wired). Build note: the conversion workflow died twice on API-connection failures and stalled; the 936-line cluster.test.ts port and gates were completed by hand (33 ported + 2 LogRetention comment-omitted + 2 repo additions). Live integ neptune.cluster: real Neptune Serverless 1-2.5 NCU + db.serverless instance, ServerlessV2ScalingConfiguration read back, grid-derived instance id asserted live, drift-clean, PASS 1684.18s. --- go.mod | 5 +- go.sum | 6 + integ/aws/storage/Makefile | 4 + integ/aws/storage/apps/neptune.cluster.ts | 66 ++ integ/aws/storage/neptune_cluster_test.go | 71 ++ src/aws/storage/index.ts | 3 + src/aws/storage/neptune/cluster.ts | 964 ++++++++++++++++++ src/aws/storage/neptune/endpoint.ts | 40 + src/aws/storage/neptune/index.ts | 19 + src/aws/storage/neptune/instance.ts | 634 ++++++++++++ src/aws/storage/neptune/parameter-group.ts | 265 +++++ src/aws/storage/neptune/subnet-group.ts | 144 +++ .../__snapshots__/cluster.test.ts.snap | 416 ++++++++ .../__snapshots__/instance.test.ts.snap | 52 + .../parameter-group.test.ts.snap | 115 +++ .../__snapshots__/subnet-group.test.ts.snap | 268 +++++ test/aws/storage/neptune/cluster.test.ts | 861 ++++++++++++++++ test/aws/storage/neptune/endpoint.test.ts | 55 + test/aws/storage/neptune/instance.test.ts | 385 +++++++ .../storage/neptune/parameter-group.test.ts | 271 +++++ test/aws/storage/neptune/subnet-group.test.ts | 126 +++ 21 files changed, 4768 insertions(+), 2 deletions(-) create mode 100644 integ/aws/storage/apps/neptune.cluster.ts create mode 100644 integ/aws/storage/neptune_cluster_test.go create mode 100644 src/aws/storage/neptune/cluster.ts create mode 100644 src/aws/storage/neptune/endpoint.ts create mode 100644 src/aws/storage/neptune/index.ts create mode 100644 src/aws/storage/neptune/instance.ts create mode 100644 src/aws/storage/neptune/parameter-group.ts create mode 100644 src/aws/storage/neptune/subnet-group.ts create mode 100644 test/aws/storage/neptune/__snapshots__/cluster.test.ts.snap create mode 100644 test/aws/storage/neptune/__snapshots__/instance.test.ts.snap create mode 100644 test/aws/storage/neptune/__snapshots__/parameter-group.test.ts.snap create mode 100644 test/aws/storage/neptune/__snapshots__/subnet-group.test.ts.snap create mode 100644 test/aws/storage/neptune/cluster.test.ts create mode 100644 test/aws/storage/neptune/endpoint.test.ts create mode 100644 test/aws/storage/neptune/instance.test.ts create mode 100644 test/aws/storage/neptune/parameter-group.test.ts create mode 100644 test/aws/storage/neptune/subnet-group.test.ts diff --git a/go.mod b/go.mod index e5a50d66..f076a77c 100644 --- a/go.mod +++ b/go.mod @@ -62,11 +62,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9 // indirect github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 // indirect github.com/aws/aws-sdk-go-v2/service/elasticache v1.56.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect 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/route53 v1.46.2 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect diff --git a/go.sum b/go.sum index 33247fc0..8d16e93c 100644 --- a/go.sum +++ b/go.sum @@ -76,12 +76,16 @@ github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 h1:xNCUk9XN6Pa9PyzbEfzgRpvEIVlq github.com/aws/aws-sdk-go-v2/service/iam v1.53.1/go.mod h1:GNQZL4JRSGH6L0/SNGOtffaB1vmlToYp3KtcUIB0NhI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 h1:8g4OLy3zfNzLV20wXmZgx+QumI9WhWHnd4GCdvETxs4= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16/go.mod h1:5a78jwLMs7BaesU0UIhLfVy2ZmOEgOy6ewYQXKTD37Q= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9 h1:9Dme/lCNr7GT+n3+AsJV95g5akEhSYeJKoQOcrL8xZ4= @@ -90,6 +94,8 @@ github.com/aws/aws-sdk-go-v2/service/kms v1.49.4 h1:2gom8MohxN0SnhHZBYAC4S8jHG+E github.com/aws/aws-sdk-go-v2/service/kms v1.49.4/go.mod h1:HO31s0qt0lso/ADvZQyzKs8js/ku0fMHsfyXW8OPVYc= github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 h1:E5UXxF3vK3JuViwKCHfTJBIiFjvE4aytSucZjI2UAlQ= github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0/go.mod h1:6f64Y1BEf6e1uCI+LtGbcZSKDK1GvgJ+iI4vP/bbE8s= +github.com/aws/aws-sdk-go-v2/service/neptune v1.48.4 h1:OKqe8wmRV8/b0PgfEm2xBKUdxO81uVNF2TOvLny5mVE= +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/route53 v1.46.2 h1:wmt05tPp/CaRZpPV5B4SaJ5TwkHKom07/BzHoLdkY1o= diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 83522836..02db2a20 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -48,6 +48,10 @@ elasticache.serverless-cache: ## Test ElastiCache ServerlessCache L2 (live Valke go test -v -count 1 -timeout 30m ./... -run ^TestElasticacheServerlessCache$ .PHONY: elasticache.serverless-cache +neptune.cluster: ## Test Neptune DatabaseCluster L2 (live serverless cluster + instance) + go test -v -count 1 -timeout 45m ./... -run ^TestNeptuneCluster$ +.PHONY: neptune.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/neptune.cluster.ts b/integ/aws/storage/apps/neptune.cluster.ts new file mode 100644 index 00000000..c5a1f83b --- /dev/null +++ b/integ/aws/storage/apps/neptune.cluster.ts @@ -0,0 +1,66 @@ +// Live test for the storage.neptune DatabaseCluster L2 (alpha port): a real +// Neptune SERVERLESS cluster (db.serverless instance) deployed through the +// ported construct in an isolated VPC. Validates the aws_neptune_cluster / +// aws_neptune_cluster_instance mapping, the native serverless_v2 scaling +// block, and grid-scoped 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 ?? "neptune.cluster"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "gbbbbbbbb-bbbb", + environmentName, + providerConfig: { + region, + }, +}); +new LocalBackend(stack, { + path: `${stackName}.tfstate`, +}); + +const vpc = new aws.compute.Vpc(stack, "Vpc", { + maxAzs: 2, + natGateways: 0, + subnetConfiguration: [ + { + name: "isolated", + subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED, + cidrMask: 24, + }, + ], +}); + +const cluster = new aws.storage.neptune.DatabaseCluster(stack, "Cluster", { + vpc, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, + instanceType: aws.storage.neptune.InstanceType.SERVERLESS, + serverlessScalingConfiguration: { + minCapacity: 1, + maxCapacity: 2.5, + }, + // Terraform-native replacement for upstream removalPolicy: allow clean destroy. + skipFinalSnapshot: true, +}); + +new TerraformOutput(stack, "cluster_identifier", { + value: cluster.clusterIdentifier, + staticId: true, +}); +new TerraformOutput(stack, "cluster_endpoint_address", { + value: cluster.clusterEndpoint.hostname, + staticId: true, +}); +new TerraformOutput(stack, "cluster_resource_identifier", { + value: cluster.clusterResourceIdentifier, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/neptune_cluster_test.go b/integ/aws/storage/neptune_cluster_test.go new file mode 100644 index 00000000..21fae34d --- /dev/null +++ b/integ/aws/storage/neptune_cluster_test.go @@ -0,0 +1,71 @@ +package test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/neptune" + "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/neptune.cluster.ts integration test: a real Neptune SERVERLESS +// cluster + db.serverless instance through the storage.neptune alpha port. +// Validates cluster read-back incl. the serverless v2 scaling configuration, +// grid-scoped naming, and the post-apply drift oracle. +func TestNeptuneCluster(t *testing.T) { + runStorageIntegrationTest(t, "neptune.cluster", "us-east-1", validateNeptuneCluster) +} + +func validateNeptuneCluster(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) + resourceID := outputs["cluster_resource_identifier"].(string) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion)) + require.NoError(t, err) + client := neptune.NewFromConfig(cfg) + + // --- 1. Cluster read-back incl. serverless scaling. --- + dc, err := client.DescribeDBClusters(ctx, &neptune.DescribeDBClustersInput{ + DBClusterIdentifier: &clusterID, + }) + require.NoError(t, err) + require.Len(t, dc.DBClusters, 1) + c := dc.DBClusters[0] + require.Equal(t, "available", *c.Status) + require.Equal(t, "neptune", *c.Engine) + require.Equal(t, endpointAddress, *c.Endpoint) + require.Equal(t, resourceID, *c.DbClusterResourceId) + require.True(t, *c.StorageEncrypted, "storage encryption defaults to true") + require.NotNil(t, c.ServerlessV2ScalingConfiguration, + "serverless scaling configuration must reach AWS") + require.Equal(t, float64(1), *c.ServerlessV2ScalingConfiguration.MinCapacity) + require.Equal(t, 2.5, *c.ServerlessV2ScalingConfiguration.MaxCapacity) + t.Logf("neptune-cluster: %s available (neptune %s, serverless %.1f-%.1f NCU, encrypted)", + clusterID, *c.EngineVersion, *c.ServerlessV2ScalingConfiguration.MinCapacity, *c.ServerlessV2ScalingConfiguration.MaxCapacity) + + // --- 2. The single instance is db.serverless with a grid-derived name. --- + require.Len(t, c.DBClusterMembers, 1) + instanceID := *c.DBClusterMembers[0].DBInstanceIdentifier + di, err := client.DescribeDBInstances(ctx, &neptune.DescribeDBInstancesInput{ + DBInstanceIdentifier: &instanceID, + }) + require.NoError(t, err) + require.Len(t, di.DBInstances, 1) + require.Equal(t, "db.serverless", *di.DBInstances[0].DBInstanceClass) + require.Equal(t, clusterID+"instance1", instanceID, + "instance identifier must derive from the grid-scoped cluster identifier") + t.Logf("neptune-cluster: instance %s is db.serverless", instanceID) + + // --- Drift oracle: re-planning the already-applied stack must show zero changes. --- + planExitCode := terraform.PlanExitCode(t, terraformOptions) + require.Equal(t, terraform.DefaultSuccessExitCode, planExitCode, + "expected `tofu plan -detailed-exitcode` to report no drift after apply (got exit code %d)", planExitCode) +} diff --git a/src/aws/storage/index.ts b/src/aws/storage/index.ts index b82944f5..a43ca18a 100644 --- a/src/aws/storage/index.ts +++ b/src/aws/storage/index.ts @@ -45,3 +45,6 @@ export * as docdb from "./docdb"; // aws-elasticache-alpha export * as elasticache from "./elasticache"; + +// aws-neptune-alpha +export * as neptune from "./neptune"; diff --git a/src/aws/storage/neptune/cluster.ts b/src/aws/storage/neptune/cluster.ts new file mode 100644 index 00000000..5117e72e --- /dev/null +++ b/src/aws/storage/neptune/cluster.ts @@ -0,0 +1,964 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/cluster.ts + +import { neptuneCluster, neptuneClusterInstance } from "@cdktn/provider-aws"; +import { Annotations, Lazy, Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import { Endpoint } from "./endpoint"; +import { InstanceType } from "./instance"; +import type { + IClusterParameterGroup, + IParameterGroup, +} from "./parameter-group"; +import type { ISubnetGroup } from "./subnet-group"; +import { SubnetGroup } from "./subnet-group"; +import type { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as cloudwatch from "../../cloudwatch"; +import * as ec2 from "../../compute"; +import type * as encryption from "../../encryption"; +import * as iam from "../../iam"; + +/** + * Possible Instances Types to use in Neptune cluster + * used for defining `DatabaseClusterProps.engineVersion`. + */ +export class EngineVersion { + /** + * Neptune engine version 1.0.1.0 + */ + public static readonly V1_0_1_0 = new EngineVersion("1.0.1.0"); + /** + * Neptune engine version 1.0.1.1 + */ + public static readonly V1_0_1_1 = new EngineVersion("1.0.1.1"); + /** + * Neptune engine version 1.0.1.2 + */ + public static readonly V1_0_1_2 = new EngineVersion("1.0.1.2"); + /** + * Neptune engine version 1.0.2.1 + */ + public static readonly V1_0_2_1 = new EngineVersion("1.0.2.1"); + /** + * Neptune engine version 1.0.2.2 + */ + public static readonly V1_0_2_2 = new EngineVersion("1.0.2.2"); + /** + * Neptune engine version 1.0.3.0 + */ + public static readonly V1_0_3_0 = new EngineVersion("1.0.3.0"); + /** + * Neptune engine version 1.0.4.0 + */ + public static readonly V1_0_4_0 = new EngineVersion("1.0.4.0"); + /** + * Neptune engine version 1.0.4.1 + */ + public static readonly V1_0_4_1 = new EngineVersion("1.0.4.1"); + /** + * Neptune engine version 1.0.5.0 + */ + public static readonly V1_0_5_0 = new EngineVersion("1.0.5.0"); + /** + * Neptune engine version 1.1.0.0 + */ + public static readonly V1_1_0_0 = new EngineVersion("1.1.0.0"); + /** + * Neptune engine version 1.1.1.0 + */ + public static readonly V1_1_1_0 = new EngineVersion("1.1.1.0"); + /** + * Neptune engine version 1.2.0.0 + */ + public static readonly V1_2_0_0 = new EngineVersion("1.2.0.0"); + /** + * Neptune engine version 1.2.0.1 + */ + public static readonly V1_2_0_1 = new EngineVersion("1.2.0.1"); + /** + * Neptune engine version 1.2.0.2 + */ + public static readonly V1_2_0_2 = new EngineVersion("1.2.0.2"); + /** + * Neptune engine version 1.2.1.0 + */ + public static readonly V1_2_1_0 = new EngineVersion("1.2.1.0"); + /** + * Neptune engine version 1.2.1.1 + */ + public static readonly V1_2_1_1 = new EngineVersion("1.2.1.1"); + /** + * Neptune engine version 1.2.1.2 + */ + public static readonly V1_2_1_2 = new EngineVersion("1.2.1.2"); + /** + * Neptune engine version 1.3.0.0 + */ + public static readonly V1_3_0_0 = new EngineVersion("1.3.0.0"); + /** + * Neptune engine version 1.3.1.0 + */ + public static readonly V1_3_1_0 = new EngineVersion("1.3.1.0"); + /** + * Neptune engine version 1.3.2.0 + */ + public static readonly V1_3_2_0 = new EngineVersion("1.3.2.0"); + /** + * Neptune engine version 1.3.2.1 + */ + public static readonly V1_3_2_1 = new EngineVersion("1.3.2.1"); + /** + * Neptune engine version 1.3.3.0 + */ + public static readonly V1_3_3_0 = new EngineVersion("1.3.3.0"); + /** + * Neptune engine version 1.3.4.0 + */ + public static readonly V1_3_4_0 = new EngineVersion("1.3.4.0"); + /** + * Neptune engine version 1.4.0.0 + */ + public static readonly V1_4_0_0 = new EngineVersion("1.4.0.0"); + /** + * Neptune engine version 1.4.1.0 + */ + public static readonly V1_4_1_0 = new EngineVersion("1.4.1.0"); + /** + * Neptune engine version 1.4.2.0 + */ + public static readonly V1_4_2_0 = new EngineVersion("1.4.2.0"); + /** + * Neptune engine version 1.4.3.0 + */ + public static readonly V1_4_3_0 = new EngineVersion("1.4.3.0"); + /** + * Neptune engine version 1.4.4.0 + */ + public static readonly V1_4_4_0 = new EngineVersion("1.4.4.0"); + /** + * Neptune engine version 1.4.5.0 + */ + public static readonly V1_4_5_0 = new EngineVersion("1.4.5.0"); + /** + * Neptune engine version 1.4.5.1 + */ + public static readonly V1_4_5_1 = new EngineVersion("1.4.5.1"); + /** + * Neptune engine version 1.4.6.0 + */ + public static readonly V1_4_6_0 = new EngineVersion("1.4.6.0"); + /** + * Neptune engine version 1.4.6.1 + */ + public static readonly V1_4_6_1 = new EngineVersion("1.4.6.1"); + + /** + * Constructor for specifying a custom engine version + * @param version the engine version of Neptune + */ + public constructor(public readonly version: string) {} +} + +/** + * Neptune log types that can be exported to CloudWatch logs + * + * @see https://docs.aws.amazon.com/neptune/latest/userguide/cloudwatch-logs.html + */ +export class LogType { + /** + * Audit logs + * + * @see https://docs.aws.amazon.com/neptune/latest/userguide/auditing.html + */ + public static readonly AUDIT = new LogType("audit"); + + /** + * Constructor for specifying a custom log type + * @param value the log type + */ + public constructor(public readonly value: string) {} +} + +/** + * ServerlessV2 scaling configuration for Neptune clusters. + */ +export interface ServerlessScalingConfiguration { + /** + * Minimum NCU capacity (min value 1) + */ + readonly minCapacity: number; + + /** + * Maximum NCU capacity (min value 2.5 - max value 128) + */ + readonly maxCapacity: number; +} + +/** + * Properties for a new database cluster + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `DatabaseClusterProps` does not — matching the base-idiom used throughout this + * repo (e.g. `DatabaseClusterProps` in `../docdb/cluster.ts`) for cross-account/-region construct + * placement. + * + * NOTE: unlike `../rds/cluster.ts` and `../docdb/cluster.ts`, Neptune clusters have no + * username/password (`Login`/`Credentials`) surface at all upstream — Neptune access is governed by + * IAM authentication (`iamAuthentication`/`grant`/`grantConnect` below) or left fully open, so there + * is no generated-password `DatabaseSecret` to port here. + */ +export interface DatabaseClusterProps extends AwsConstructProps { + /** + * What version of the database to start + * + * @default - The default engine version. + */ + readonly engineVersion?: EngineVersion; + + /** + * How many days to retain the backup + * + * @default - cdk.Duration.days(1) + */ + readonly backupRetention?: Duration; + + /** + * A daily time range in 24-hours UTC format in which backups preferably execute. + * + * Must be at least 30 minutes long. + * + * Example: '01:00-02:00' + * + * @default - a 30-minute window selected at random from an 8-hour block of + * time for each AWS Region. To see the time blocks available, see + */ + readonly preferredBackupWindow?: string; + + /** + * The KMS key for storage encryption. + * + * @default - default master key. + */ + readonly kmsKey?: encryption.IKey; + + /** + * Whether to enable storage encryption + * + * @default true + */ + readonly storageEncrypted?: boolean; + + /** + * Number of Neptune compute instances + * + * @default 1 + */ + readonly instances?: number; + + /** + * An optional identifier for the cluster + * + * If you specify a name, it is lowercased at synth: Neptune stores DB + * identifiers lowercase server-side, and emitting the original casing would + * report a perpetual Terraform diff. + * + * @default - a gridUUID-scoped generated name + */ + readonly dbClusterName?: string; + + /** + * Map AWS Identity and Access Management (IAM) accounts to database accounts + * + * @default - `false` + */ + readonly iamAuthentication?: boolean; + + /** + * Base identifier for instances + * + * Every replica is named by appending the replica number to this string, 1-based. + * + * If you specify a base, it is lowercased at synth (Neptune stores DB identifiers lowercase + * server-side; see `dbClusterName`). + * + * @default - `dbClusterName` is used with the word "Instance" appended. If `dbClusterName` is not provided, the + * identifier is automatically generated. + */ + readonly instanceIdentifierBase?: string; + + /** + * What type of instance to start for the replicas + */ + readonly instanceType: InstanceType; + + /** + * A list of AWS Identity and Access Management (IAM) role that can be used by the cluster to access other AWS services. + * + * @default - No role is attached to the cluster. + */ + readonly associatedRoles?: iam.IRole[]; + + /** + * Indicates whether the DB cluster should have deletion protection enabled. + * + * TERRACONSTRUCTS DEVIATION: upstream defaults to `true if removalPolicy is RETAIN, false + * otherwise`. `core.RemovalPolicy` is not ported in this repo (see `skipFinalSnapshot` below), so + * this defaults straight through to the provider default (`false`) instead. + * + * @default false + */ + readonly deletionProtection?: boolean; + + /** + * A weekly time range in which maintenance should preferably execute. + * + * Must be at least 30 minutes long. + * + * Example: 'tue:04:17-tue:04:47' + * + * @default - 30-minute window selected at random from an 8-hour block of time for + * each AWS Region, occurring on a random day of the week. + */ + readonly preferredMaintenanceWindow?: string; + + /** + * Additional parameters to pass to the database engine + * + * @default - No parameter group. + */ + readonly clusterParameterGroup?: IClusterParameterGroup; + + /** + * The DB parameter group to associate with the instance. + * + * @default no parameter group + */ + readonly parameterGroup?: IParameterGroup; + + /** + * Existing subnet group for the cluster. + * + * @default - a new subnet group will be created. + */ + readonly subnetGroup?: ISubnetGroup; + + /** + * What subnets to run the Neptune instances in. + * + * Must be at least 2 subnets in two different AZs. + */ + readonly vpc: ec2.IVpc; + + /** + * Where to place the instances within the VPC + * + * @default private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * Security group. + * + * @default a new security group is created. + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`, + // applied uniformly to the cluster, its auto-created instances, and its auto-created security + // group) is CloudFormation's DeletionPolicy concept. `core.RemovalPolicy` is not ported anywhere + // in this repo (see the identical omission on `DatabaseClusterProps` in `../docdb/cluster.ts` and + // `../rds/cluster.ts`). Terraform's `aws_neptune_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-neptune-alpha/lib/cluster.ts#L128-L134 + // 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_neptune_cluster` behavior, not enforced here at synth time). Also threaded through to each + * auto-created `aws_neptune_cluster_instance` below (mirroring upstream's single `removalPolicy` + * governing the cluster AND its instances together). + * + * @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; + + /** + * If set to true, Neptune will automatically update the engine of the entire + * cluster to the latest minor version after a stabilization window of 2 to 3 weeks. + * + * @default - false + */ + readonly autoMinorVersionUpgrade?: boolean; + + /** + * The list of log types that need to be enabled for exporting to + * CloudWatch Logs. + * + * @see https://docs.aws.amazon.com/neptune/latest/userguide/cloudwatch-logs.html + * @see https://docs.aws.amazon.com/neptune/latest/userguide/auditing.html#auditing-enable + * + * @default - no log exports + */ + readonly cloudwatchLogsExports?: LogType[]; + + // TODO: omitted — upstream's `cloudwatchLogsRetention?: logs.RetentionDays` and + // `cloudwatchLogsRetentionRole?: iam.IRole` (and the Lambda-backed `logs.LogRetention` custom + // resource machinery that consumes them) are dropped for the identical reason given on + // `DatabaseClusterProps.cloudWatchLogsRetention` in `../docdb/cluster.ts` — there is no + // Terraform-native equivalent of upstream's Lambda-backed custom resource; the + // `aws_neptune_cluster` resource only controls WHICH logs are exported + // (`enable_cloudwatch_logs_exports`, ported above via `cloudwatchLogsExports`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/cluster.ts#L207-L221 + // readonly cloudwatchLogsRetention?: logs.RetentionDays; + // readonly cloudwatchLogsRetentionRole?: iam.IRole; + + /** + * Specify minimum and maximum NCUs capacity for a serverless cluster. + * See https://docs.aws.amazon.com/neptune/latest/userguide/neptune-serverless-capacity-scaling.html + * + * @default - required if instanceType is db.serverless + */ + readonly serverlessScalingConfiguration?: ServerlessScalingConfiguration; + + /** + * Whether to copy tags to the snapshot when a snapshot is created. + * + * @default - false + */ + readonly copyTagsToSnapshot?: boolean; + + /** + * The port number on which the DB instances in the DB cluster accept connections. + * + * @default 8182 + */ + readonly port?: number; +} + +/** + * Create a clustered database with a given number of instances. + * + * TODO: omitted — upstream also extends `cdk.IResource`-equivalent generated-reference typing is + * not applicable here; see the identical omission note on `IDatabaseCluster` in + * `../docdb/cluster-ref.ts`. + */ +export interface IDatabaseCluster extends IAwsConstruct, ec2.IConnectable { + /** + * Identifier of the cluster + */ + readonly clusterIdentifier: string; + + /** + * Resource identifier of the cluster + */ + readonly clusterResourceIdentifier: string; + + /** + * The endpoint to use for read/write operations + */ + readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + readonly clusterReadEndpoint: Endpoint; + + /** + * Grant the given identity the specified actions + * @param grantee the identity to be granted the actions + * @param actions the data-access actions + * + * @see https://docs.aws.amazon.com/neptune/latest/userguide/iam-dp-actions.html + */ + grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant; + + /** + * Grant the given identity connection access to the database. + */ + grantConnect(grantee: iam.IGrantable): iam.Grant; + + /** + * Return the given named metric associated with this DatabaseCluster instance + * + * @see https://docs.aws.amazon.com/neptune/latest/userguide/cw-metrics.html + * @see https://docs.aws.amazon.com/neptune/latest/userguide/cw-dimensions.html + */ + metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric; +} + +/** + * Properties that describe an existing cluster instance + */ +export interface DatabaseClusterAttributes { + /** + * The database port + */ + readonly port: number; + + /** + * The security group of the database cluster + */ + readonly securityGroup: ec2.ISecurityGroup; + + /** + * Identifier for the cluster + */ + readonly clusterIdentifier: string; + + /** + * Resource Identifier for the cluster + */ + readonly clusterResourceIdentifier: string; + + /** + * Cluster endpoint address + */ + readonly clusterEndpointAddress: string; + + /** + * Reader endpoint address + */ + readonly readerEndpointAddress: string; +} + +/** + * A new or imported database cluster. + */ +export abstract class DatabaseClusterBase + extends AwsConstructBase + implements IDatabaseCluster +{ + /** + * Import an existing DatabaseCluster from properties + */ + public static fromDatabaseClusterAttributes( + scope: Construct, + id: string, + attrs: DatabaseClusterAttributes, + ): IDatabaseCluster { + class Import extends DatabaseClusterBase implements IDatabaseCluster { + public readonly defaultPort = ec2.Port.tcp(attrs.port); + public readonly connections = new ec2.Connections({ + securityGroups: [attrs.securityGroup], + defaultPort: this.defaultPort, + }); + public readonly clusterIdentifier = attrs.clusterIdentifier; + public readonly clusterResourceIdentifier = + attrs.clusterResourceIdentifier; + public readonly clusterEndpoint = new Endpoint( + attrs.clusterEndpointAddress, + attrs.port, + ); + public readonly clusterReadEndpoint = new Endpoint( + attrs.readerEndpointAddress, + attrs.port, + ); + protected enableIamAuthentication = true; + } + + return new Import(scope, id, {}); + } + + /** + * Identifier of the cluster + */ + public abstract readonly clusterIdentifier: string; + + /** + * Resource identifier of the cluster + */ + public abstract readonly clusterResourceIdentifier: string; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + public abstract readonly clusterReadEndpoint: Endpoint; + + /** + * The connections object to implement IConnectable + */ + public abstract readonly connections: ec2.Connections; + + protected abstract enableIamAuthentication?: boolean; + + /** + * [disable-awslint:no-grants] + */ + public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { + if (this.enableIamAuthentication === false) { + throw new ValidationError( + "Cannot grant permissions when IAM authentication is disabled", + this, + ); + } + + this.enableIamAuthentication = true; + return iam.Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [ + this.stack.formatArn({ + service: "neptune-db", + resource: `${this.clusterResourceIdentifier}/*`, + }), + ], + }); + } + + /** + * [disable-awslint:no-grants] + */ + public grantConnect(grantee: iam.IGrantable): iam.Grant { + return this.grant(grantee, "neptune-db:*"); + } + + public metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: "AWS/Neptune", + dimensionsMap: { + DBClusterIdentifier: this.clusterIdentifier, + }, + metricName, + ...props, + }); + } + + /** + * 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.clusterIdentifier, + endpointAddress: this.clusterEndpoint.hostname, + endpointPort: Tokenization.stringifyNumber(this.clusterEndpoint.port), + readEndpointAddress: this.clusterReadEndpoint.hostname, + }; + } +} + +/** + * Create a clustered database with a given number of instances. + * + * @resource aws_neptune_cluster + */ +export class DatabaseCluster extends DatabaseClusterBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.neptune.DatabaseCluster"; + /** + * The default number of instances in the Neptune cluster if none are + * specified + */ + public static readonly DEFAULT_NUM_INSTANCES = 1; + + public readonly clusterIdentifier: string; + public readonly clusterEndpoint: Endpoint; + public readonly clusterReadEndpoint: Endpoint; + public readonly connections: ec2.Connections; + + /** + * The resource id for the cluster; for example: cluster-ABCD1234EFGH5678IJKL90MNOP. The cluster ID uniquely + * identifies the cluster and is used in things like IAM authentication policies. + */ + public readonly clusterResourceIdentifier: string; + + /** + * The VPC where the DB subnet group is created. + */ + public readonly vpc: ec2.IVpc; + + /** + * The subnets used by the DB subnet group. + */ + public readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * Subnet group used by the DB + */ + public readonly subnetGroup: ISubnetGroup; + + /** + * Identifiers of the instance + */ + public readonly instanceIdentifiers: string[] = []; + + /** + * Endpoints which address each individual instance. + */ + public readonly instanceEndpoints: Endpoint[] = []; + + protected enableIamAuthentication?: boolean; + + /** + * The underlying `aws_neptune_cluster` L1. + */ + public readonly resource: neptuneCluster.NeptuneCluster; + + constructor(scope: Construct, id: string, props: DatabaseClusterProps) { + super(scope, id, props); + + this.vpc = props.vpc; + this.vpcSubnets = props.vpcSubnets ?? { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }; + + // Determine the subnet(s) to deploy the Neptune cluster to + const { subnetIds, internetConnectivityEstablished } = + this.vpc.selectSubnets(this.vpcSubnets); + + // Cannot test whether the subnets are in different AZs, but at least we can test the amount. + if (subnetIds.length < 2) { + throw new ValidationError( + `Cluster requires at least 2 subnets, got ${subnetIds.length}`, + this, + ); + } + + this.subnetGroup = + props.subnetGroup ?? + new SubnetGroup(this, "Subnets", { + description: `Subnets for ${id} database`, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + }); + + const securityGroups = props.securityGroups ?? [ + new ec2.SecurityGroup(this, "SecurityGroup", { + description: "Neptune security group", + vpc: this.vpc, + }), + ]; + + // Default to encrypted storage + const storageEncrypted = props.storageEncrypted ?? true; + + if (props.kmsKey && !storageEncrypted) { + throw new ValidationError( + "KMS key supplied but storageEncrypted is false", + this, + ); + } + + this.enableIamAuthentication = props.iamAuthentication; + + if ( + props.instanceType === InstanceType.SERVERLESS && + !props.serverlessScalingConfiguration + ) { + throw new ValidationError( + "You need to specify a serverless scaling configuration with a db.serverless instance type.", + this, + ); + } + + this.validateServerlessScalingConfiguration( + props.serverlessScalingConfiguration, + ); + + // TERRACONSTRUCTS DEVIATION: hoisted so the auto-created instances below can reuse the + // derived (gridUUID-scoped, lowercased) cluster identifier as their name base -- see the + // instance-identifier note in the creation loop. `ClusterIdentifier` is capped at 63 + // characters, but a shorter cap is used here to leave room for the `instanceN` suffix + // appended below (mirrors `../docdb/cluster.ts`'s identical `derivedClusterIdentifier` idiom). + const derivedClusterIdentifier = Token.isUnresolved(props.dbClusterName) + ? props.dbClusterName + : ( + props.dbClusterName ?? + this.stack.uniqueResourceName(this, { maxLength: 55 }) + ).toLowerCase(); + + // Create the Neptune cluster + const cluster = new neptuneCluster.NeptuneCluster(this, "Resource", { + // Basic + engineVersion: props.engineVersion?.version, + clusterIdentifier: derivedClusterIdentifier, + neptuneSubnetGroupName: this.subnetGroup.subnetGroupName, + vpcSecurityGroupIds: securityGroups.map((sg) => sg.securityGroupId), + neptuneClusterParameterGroupName: + props.clusterParameterGroup?.clusterParameterGroupName, + deletionProtection: props.deletionProtection, + iamRoles: props.associatedRoles?.map((role) => role.roleArn), + // TERRACONSTRUCTS DEVIATION: upstream sets `iamAuthEnabled` via `Lazy.any({ produce: () => + // this.enableIamAuthentication })` so that a later `grant()`/`grantConnect()` call (which + // flips `enableIamAuthentication` to `true` after construction) is still reflected at + // CloudFormation synth time. `Lazy.anyValue()` is the equivalent CDKTF idiom for a scalar + // (non-block) L1 attribute -- see the identical idiom on `enableHttpEndpoint` in + // `../rds/cluster.ts`. + iamDatabaseAuthenticationEnabled: Lazy.anyValue({ + produce: () => this.enableIamAuthentication, + }), + port: props.port, + // Backup + backupRetentionPeriod: props.backupRetention?.toDays(), + preferredBackupWindow: props.preferredBackupWindow, + preferredMaintenanceWindow: props.preferredMaintenanceWindow, + // Encryption + // TERRACONSTRUCTS DEVIATION / ID-vs-ARN AUDIT: `kmsKeyArn` (the Terraform argument name + // matches upstream's ARN-form `kmsKeyId` 1:1 here) is fed the KMS key ARN, not a bare key + // id -- AWS always reports the ARN back on read, so supplying the ARN up front avoids a + // perpetual "inconsistent result after apply" diff (the #151 kms lesson). + kmsKeyArn: props.kmsKey?.keyArn, + // CloudWatch Logs exports + enableCloudwatchLogsExports: props.cloudwatchLogsExports?.map( + (logType) => logType.value, + ), + storageEncrypted, + serverlessV2ScalingConfiguration: props.serverlessScalingConfiguration, + // Tags + copyTagsToSnapshot: props.copyTagsToSnapshot, + skipFinalSnapshot: props.skipFinalSnapshot, + finalSnapshotIdentifier: props.finalSnapshotIdentifier, + } as neptuneCluster.NeptuneClusterConfig); + + this.resource = cluster; + this.clusterIdentifier = cluster.clusterIdentifier; + this.clusterResourceIdentifier = cluster.clusterResourceId; + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `skipFinalSnapshot`/`finalSnapshotIdentifier` + // synth-time warning on `DatabaseCluster` in `../docdb/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.", + ); + } + + // TERRACONSTRUCTS DEVIATION: upstream converts `cluster.attrPort` (a CFN `Fn::GetAtt` string + // attribute) into a number token via `cdk.Token.asNumber(...)`. The CDKTF L1 `port` getter + // already returns a native `number`-typed (Token-backed) attribute, so no string-to-number + // conversion is needed here (identical deviation to `../docdb/cluster.ts`). + const port = cluster.port; + this.clusterEndpoint = new Endpoint(cluster.endpoint, port); + this.clusterReadEndpoint = new Endpoint(cluster.readerEndpoint, port); + + // Create the instances + const instanceCount = + props.instances ?? DatabaseCluster.DEFAULT_NUM_INSTANCES; + if (instanceCount < 1) { + throw new ValidationError("At least one instance is required", this); + } + + for (let i = 0; i < instanceCount; i++) { + const instanceIndex = i + 1; + + // TERRACONSTRUCTS DEVIATION: upstream lets CloudFormation auto-generate a name from each + // instance's per-index logical id (`Instance1`, `Instance2`, ...) when neither + // `instanceIdentifierBase` nor `dbClusterName` is provided. A single + // `uniqueResourceName(this, ...)` call would collide across loop iterations (shared + // scope), so the repo naming invariant is preserved by reusing the DERIVED cluster + // identifier (already gridUUID-scoped + lowercased above) as the per-instance base -- + // upstream's own fallback semantic (`dbClusterName` + "instance" + N), just with the + // derived default instead of the raw prop (identical idiom to `../docdb/cluster.ts`). + const instanceIdentifierBase = + props.instanceIdentifierBase != null + ? `${props.instanceIdentifierBase}${instanceIndex}` + : `${derivedClusterIdentifier}instance${instanceIndex}`; + const instanceIdentifier = Token.isUnresolved(instanceIdentifierBase) + ? instanceIdentifierBase + : instanceIdentifierBase.toLowerCase(); + + const instance = new neptuneClusterInstance.NeptuneClusterInstance( + this, + `Instance${instanceIndex}`, + { + // Link to cluster + clusterIdentifier: cluster.clusterIdentifier, + identifier: instanceIdentifier, + // Instance properties + instanceClass: props.instanceType._instanceType, + neptuneParameterGroupName: props.parameterGroup?.parameterGroupName, + autoMinorVersionUpgrade: props.autoMinorVersionUpgrade === true, + // TERRACONSTRUCTS DEVIATION: not present upstream — see `skipFinalSnapshot` on + // `DatabaseClusterProps` above. Unlike upstream (a single `removalPolicy` applied to both + // the cluster and its auto-created instances via `applyRemovalPolicy`), the cluster-level + // `skipFinalSnapshot` flag is threaded through to each instance here as its own native + // Terraform equivalent. + skipFinalSnapshot: props.skipFinalSnapshot, + }, + ); + + // We must have a dependency on the NAT gateway provider here to create + // things in the right order. + instance.node.addDependency(internetConnectivityEstablished); + + this.instanceIdentifiers.push(instance.identifier); + this.instanceEndpoints.push(new Endpoint(instance.endpoint, port)); + } + + this.connections = new ec2.Connections({ + defaultPort: ec2.Port.tcp(port), + securityGroups, + }); + } + + public get outputs(): Record { + return { + ...super.outputs, + arn: this.resource.arn, + }; + } + + private validateServerlessScalingConfiguration( + serverlessScalingConfiguration?: ServerlessScalingConfiguration, + ) { + if (!serverlessScalingConfiguration) return; + if (serverlessScalingConfiguration.minCapacity < 1) { + throw new ValidationError( + `ServerlessScalingConfiguration minCapacity must be greater or equal than 1, received ${serverlessScalingConfiguration.minCapacity}`, + this, + ); + } + if ( + serverlessScalingConfiguration.maxCapacity < 2.5 || + serverlessScalingConfiguration.maxCapacity > 128 + ) { + throw new ValidationError( + `ServerlessScalingConfiguration maxCapacity must be between 2.5 and 128, received ${serverlessScalingConfiguration.maxCapacity}`, + this, + ); + } + if ( + serverlessScalingConfiguration.minCapacity >= + serverlessScalingConfiguration.maxCapacity + ) { + throw new ValidationError( + `ServerlessScalingConfiguration minCapacity ${serverlessScalingConfiguration.minCapacity} ` + + `must be less than serverlessScalingConfiguration maxCapacity ${serverlessScalingConfiguration.maxCapacity}`, + this, + ); + } + } +} diff --git a/src/aws/storage/neptune/endpoint.ts b/src/aws/storage/neptune/endpoint.ts new file mode 100644 index 00000000..69f9edd1 --- /dev/null +++ b/src/aws/storage/neptune/endpoint.ts @@ -0,0 +1,40 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/endpoint.ts + +import { Token } from "cdktn"; + +/** + * Connection endpoint of a neptune cluster or instance + * + * 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` + * and `../docdb/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/neptune/index.ts b/src/aws/storage/neptune/index.ts new file mode 100644 index 00000000..b865039e --- /dev/null +++ b/src/aws/storage/neptune/index.ts @@ -0,0 +1,19 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/index.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-neptune-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 "./instance"; +export * from "./endpoint"; +export * from "./parameter-group"; +export * from "./subnet-group"; + +// TODO: omitted — upstream also re-exports the generated CFN L1 (`./neptune.generated`, i.e. +// `CfnDBCluster`/`CfnDBInstance`/`CfnDBClusterParameterGroup`/`CfnDBParameterGroup`/ +// `CfnDBSubnetGroup`). This repo has no CloudFormation-generated L1 layer to re-export (Terraform +// L1s come from `@cdktn/provider-aws` instead, already consumed directly by +// `./instance.ts`/`./parameter-group.ts`/`./subnet-group.ts`/`./cluster.ts`) — identical omission +// to every other ported module in this repo (e.g. `../docdb/index.ts`) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/index.ts#L7 diff --git a/src/aws/storage/neptune/instance.ts b/src/aws/storage/neptune/instance.ts new file mode 100644 index 00000000..85125917 --- /dev/null +++ b/src/aws/storage/neptune/instance.ts @@ -0,0 +1,634 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/instance.ts + +import { neptuneClusterInstance } from "@cdktn/provider-aws"; +import { Annotations, Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import type { IDatabaseCluster } from "./cluster"; +import { Endpoint } from "./endpoint"; +import type { IParameterGroup } from "./parameter-group"; +import { UnscopedValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as cloudwatch from "../../cloudwatch"; + +/** + * Possible Instances Types to use in Neptune cluster + * used for defining `DatabaseInstanceProps.instanceType`. + */ +export class InstanceType { + /** + * db.x2g.large + */ + public static readonly X2G_LARGE = InstanceType.of("db.x2g.large"); + + /** + * db.x2g.xlarge + */ + public static readonly X2G_XLARGE = InstanceType.of("db.x2g.xlarge"); + + /** + * db.x2g.2xlarge + */ + public static readonly X2G_2XLARGE = InstanceType.of("db.x2g.2xlarge"); + + /** + * db.x2g.4xlarge + */ + public static readonly X2G_4XLARGE = InstanceType.of("db.x2g.4xlarge"); + + /** + * db.x2g.8xlarge + */ + public static readonly X2G_8XLARGE = InstanceType.of("db.x2g.8xlarge"); + + /** + * db.x2g.12xlarge + */ + public static readonly X2G_12XLARGE = InstanceType.of("db.x2g.12xlarge"); + + /** + * db.x2g.16xlarge + */ + public static readonly X2G_16XLARGE = InstanceType.of("db.x2g.16xlarge"); + + /** + * db.x2iedn.xlarge + */ + public static readonly X2IEDN_XLARGE = InstanceType.of("db.x2iedn.xlarge"); + + /** + * db.x2iedn.2xlarge + */ + public static readonly X2IEDN_2XLARGE = InstanceType.of("db.x2iedn.2xlarge"); + + /** + * db.x2iedn.4xlarge + */ + public static readonly X2IEDN_4XLARGE = InstanceType.of("db.x2iedn.4xlarge"); + + /** + * db.x2iedn.8xlarge + */ + public static readonly X2IEDN_8XLARGE = InstanceType.of("db.x2iedn.8xlarge"); + + /** + * db.x2iedn.16xlarge + */ + public static readonly X2IEDN_16XLARGE = + InstanceType.of("db.x2iedn.16xlarge"); + + /** + * db.x2iedn.24xlarge + */ + public static readonly X2IEDN_24XLARGE = + InstanceType.of("db.x2iedn.24xlarge"); + + /** + * db.x2iedn.32xlarge + */ + public static readonly X2IEDN_32XLARGE = + InstanceType.of("db.x2iedn.32xlarge"); + + /** + * db.r6g.large + */ + public static readonly R6G_LARGE = InstanceType.of("db.r6g.large"); + + /** + * db.r6g.xlarge + */ + public static readonly R6G_XLARGE = InstanceType.of("db.r6g.xlarge"); + + /** + * db.r6g.2xlarge + */ + public static readonly R6G_2XLARGE = InstanceType.of("db.r6g.2xlarge"); + + /** + * db.r6g.4xlarge + */ + public static readonly R6G_4XLARGE = InstanceType.of("db.r6g.4xlarge"); + + /** + * db.r6g.8xlarge + */ + public static readonly R6G_8XLARGE = InstanceType.of("db.r6g.8xlarge"); + + /** + * db.r6g.12xlarge + */ + public static readonly R6G_12XLARGE = InstanceType.of("db.r6g.12xlarge"); + + /** + * db.r6g.16xlarge + */ + public static readonly R6G_16XLARGE = InstanceType.of("db.r6g.16xlarge"); + + /** + * db.r6i.large + */ + public static readonly R6I_LARGE = InstanceType.of("db.r6i.large"); + + /** + * db.r6i.xlarge + */ + public static readonly R6I_XLARGE = InstanceType.of("db.r6i.xlarge"); + + /** + * db.r6i.2xlarge + */ + public static readonly R6I_2XLARGE = InstanceType.of("db.r6i.2xlarge"); + + /** + * db.r6i.4xlarge + */ + public static readonly R6I_4XLARGE = InstanceType.of("db.r6i.4xlarge"); + + /** + * db.r6i.8xlarge + */ + public static readonly R6I_8XLARGE = InstanceType.of("db.r6i.8xlarge"); + + /** + * db.r6i.12xlarge + */ + public static readonly R6I_12XLARGE = InstanceType.of("db.r6i.12xlarge"); + + /** + * db.r6i.16xlarge + */ + public static readonly R6I_16XLARGE = InstanceType.of("db.r6i.16xlarge"); + + /** + * db.r6i.24xlarge + */ + public static readonly R6I_24XLARGE = InstanceType.of("db.r6i.24xlarge"); + + /** + * db.r6i.32xlarge + */ + public static readonly R6I_32XLARGE = InstanceType.of("db.r6i.32xlarge"); + + /** + * db.r5.large + */ + public static readonly R5_LARGE = InstanceType.of("db.r5.large"); + + /** + * db.r5.xlarge + */ + public static readonly R5_XLARGE = InstanceType.of("db.r5.xlarge"); + + /** + * db.r5.2xlarge + */ + public static readonly R5_2XLARGE = InstanceType.of("db.r5.2xlarge"); + + /** + * db.r5.4xlarge + */ + public static readonly R5_4XLARGE = InstanceType.of("db.r5.4xlarge"); + + /** + * db.r5.8xlarge + */ + public static readonly R5_8XLARGE = InstanceType.of("db.r5.8xlarge"); + + /** + * db.r5.12xlarge + */ + public static readonly R5_12XLARGE = InstanceType.of("db.r5.12xlarge"); + + /** + * db.r5.16xlarge + */ + public static readonly R5_16XLARGE = InstanceType.of("db.r5.16xlarge"); + + /** + * db.r5.24xlarge + */ + public static readonly R5_24XLARGE = InstanceType.of("db.r5.24xlarge"); + + /** + * db.r5d.large + */ + public static readonly R5D_LARGE = InstanceType.of("db.r5d.large"); + + /** + * db.r5d.xlarge + */ + public static readonly R5D_XLARGE = InstanceType.of("db.r5d.xlarge"); + + /** + * db.r5d.2xlarge + */ + public static readonly R5D_2XLARGE = InstanceType.of("db.r5d.2xlarge"); + + /** + * db.r5d.4xlarge + */ + public static readonly R5D_4XLARGE = InstanceType.of("db.r5d.4xlarge"); + + /** + * db.r5d.8xlarge + */ + public static readonly R5D_8XLARGE = InstanceType.of("db.r5d.8xlarge"); + + /** + * db.r5d.12xlarge + */ + public static readonly R5D_12XLARGE = InstanceType.of("db.r5d.12xlarge"); + + /** + * db.r5d.16xlarge + */ + public static readonly R5D_16XLARGE = InstanceType.of("db.r5d.16xlarge"); + + /** + * db.r5d.24xlarge + */ + public static readonly R5D_24XLARGE = InstanceType.of("db.r5d.24xlarge"); + + /** + * db.r4.large + */ + public static readonly R4_LARGE = InstanceType.of("db.r4.large"); + + /** + * db.r4.xlarge + */ + public static readonly R4_XLARGE = InstanceType.of("db.r4.xlarge"); + + /** + * db.r4.2xlarge + */ + public static readonly R4_2XLARGE = InstanceType.of("db.r4.2xlarge"); + + /** + * db.r4.4xlarge + */ + public static readonly R4_4XLARGE = InstanceType.of("db.r4.4xlarge"); + + /** + * db.r4.8xlarge + */ + public static readonly R4_8XLARGE = InstanceType.of("db.r4.8xlarge"); + + /** + * db.t4g.medium + */ + public static readonly T4G_MEDIUM = InstanceType.of("db.t4g.medium"); + + /** + * db.t3.medium + */ + public static readonly T3_MEDIUM = InstanceType.of("db.t3.medium"); + + /** + * db.serverless + */ + public static readonly SERVERLESS = InstanceType.of("db.serverless"); + + /** + * Build an InstanceType from given string or token, such as CfnParameter. + */ + public static of(instanceType: string): InstanceType { + return new InstanceType(instanceType); + } + + /** + * @internal + */ + readonly _instanceType: string; + + private constructor(instanceType: string) { + if (Token.isUnresolved(instanceType) || instanceType.startsWith("db.")) { + this._instanceType = instanceType; + } else { + throw new UnscopedValidationError( + `instance type must start with 'db.'; (got ${instanceType})`, + ); + } + } +} + +/** + * A database instance + * + * TODO: omitted — upstream also extends `cdk.IResource`-equivalent generated-reference typing is + * not applicable here; see the identical omission note on `IDatabaseInstance` in + * `../docdb/instance.ts`. + */ +export interface IDatabaseInstance extends IAwsConstruct { + /** + * The instance identifier. + */ + readonly instanceIdentifier: string; + + /** + * The instance endpoint. + */ + readonly instanceEndpoint: Endpoint; + + /** + * The instance endpoint address. + */ + readonly dbInstanceEndpointAddress: string; + + /** + * The instance endpoint port. + */ + readonly dbInstanceEndpointPort: string; + + /** + * Return the given named metric associated with this database instance + * + * @see https://docs.aws.amazon.com/neptune/latest/userguide/cw-metrics.html + * @see https://docs.aws.amazon.com/neptune/latest/userguide/cw-dimensions.html + */ + metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric; +} + +/** + * Properties that describe an existing instance + */ +export interface DatabaseInstanceAttributes { + /** + * The instance identifier. + */ + readonly instanceIdentifier: string; + + /** + * The endpoint address. + */ + readonly instanceEndpointAddress: string; + + /** + * The database port. + */ + readonly port: number; +} + +/** + * A new or imported database instance. + */ +abstract class DatabaseInstanceBase + extends AwsConstructBase + implements IDatabaseInstance +{ + /** + * Import an existing database instance. + */ + public static fromDatabaseInstanceAttributes( + scope: Construct, + id: string, + attrs: DatabaseInstanceAttributes, + ): IDatabaseInstance { + class Import extends DatabaseInstanceBase implements IDatabaseInstance { + public readonly instanceIdentifier = attrs.instanceIdentifier; + public readonly dbInstanceEndpointAddress = attrs.instanceEndpointAddress; + public readonly dbInstanceEndpointPort = Tokenization.stringifyNumber( + attrs.port, + ); + public readonly instanceEndpoint = new Endpoint( + attrs.instanceEndpointAddress, + attrs.port, + ); + } + return new Import(scope, id, {}); + } + + /** + * @inheritdoc + */ + public abstract readonly dbInstanceEndpointAddress: string; + + /** + * @inheritdoc + */ + public abstract readonly dbInstanceEndpointPort: string; + + /** + * @inheritdoc + */ + public abstract readonly instanceEndpoint: Endpoint; + + /** + * @inheritdoc + */ + public abstract readonly instanceIdentifier: string; + + /** + * @inheritdoc + */ + public metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: "AWS/Neptune", + dimensionsMap: { + DBInstanceIdentifier: this.instanceIdentifier, + }, + metricName, + ...props, + }); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — repo-wide construct-output convention (see + * `DatabaseInstance` in `../docdb/instance.ts`) for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + identifier: this.instanceIdentifier, + endpointAddress: this.dbInstanceEndpointAddress, + endpointPort: this.dbInstanceEndpointPort, + }; + } +} + +/** + * Construction properties for a DatabaseInstanceNew + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `DatabaseInstanceProps` does not — matching the base-idiom used throughout this + * repo (e.g. `DatabaseInstanceProps` in `../docdb/instance.ts`) for cross-account/-region + * construct placement. + */ +export interface DatabaseInstanceProps extends AwsConstructProps { + /** + * The Neptune database cluster the instance should launch into. + */ + readonly cluster: IDatabaseCluster; + + /** + * What type of instance to start for the replicas + */ + readonly instanceType: InstanceType; + + /** + * The name of the Availability Zone where the DB instance will be located. + * + * @default - no preference + */ + readonly availabilityZone?: string; + + /** + * A name for the DB instance. If you specify a name, it is lowercased at synth (Neptune stores + * DB identifiers lowercase server-side, and emitting the original casing would report a + * perpetual Terraform diff). + * + * @default - a gridUUID-scoped generated name + */ + readonly dbInstanceName?: string; + + /** + * The DB parameter group to associate with the instance. + * + * @default no parameter group + */ + readonly parameterGroup?: IParameterGroup; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.Retain`) + // maps onto `CfnDBInstance`'s CloudFormation `DeletionPolicy`/`UpdateReplacePolicy`. + // `core.RemovalPolicy` is not ported in this repo (see the identical omission throughout + // `../docdb`/`../rds`, e.g. `SubnetGroupProps`). Unlike `aws_docdb_cluster_instance` (which has + // no snapshot-related argument at all -- see `../docdb/instance.ts`), the Terraform + // `aws_neptune_cluster_instance` resource DOES expose a native `skip_final_snapshot` argument, + // the TERRACONSTRUCTS-native replacement ported below as `skipFinalSnapshot` — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/instance.ts#L98-L103 + // readonly removalPolicy?: cdk.RemovalPolicy; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — native Terraform replacement for upstream's + * `removalPolicy` (see the TODO above). Whether Terraform should take a final DB snapshot before + * destroying this instance. The underlying Neptune `DeleteDBInstance` API requires a + * `FinalDBSnapshotIdentifier` whenever `SkipFinalSnapshot` is `false` + * (https://docs.aws.amazon.com/neptune/latest/apiref/API_DeleteDBInstance.html), but unlike + * `aws_db_instance` (RDS, see `../rds/instance.ts`'s `skipFinalSnapshot`/ + * `finalSnapshotIdentifier`), the `aws_neptune_cluster_instance` Terraform resource has NO + * `final_snapshot_identifier` (or equivalent) argument to supply one -- so leaving this `false` + * (the provider default) will FAIL at `terraform destroy`/replace time with no way to work + * around it from this resource. See the synth-time warning in the constructor below. + * + * @default false (matches the provider default; will FAIL at apply/destroy time unless set to + * `true` -- see above) + */ + readonly skipFinalSnapshot?: boolean; + + /** + * Indicates that minor version patches are applied automatically. + * + * @default undefined + */ + readonly autoMinorVersionUpgrade?: boolean; +} + +/** + * A database instance + * + * @resource aws_neptune_cluster_instance + */ +export class DatabaseInstance + extends DatabaseInstanceBase + implements IDatabaseInstance +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.neptune.DatabaseInstance"; + + /** + * The instance's database cluster + */ + public readonly cluster: IDatabaseCluster; + + /** + * @inheritdoc + */ + public readonly instanceIdentifier: string; + + /** + * @inheritdoc + */ + public readonly instanceEndpoint: Endpoint; + + /** + * @inheritdoc + */ + public readonly dbInstanceEndpointAddress: string; + + /** + * @inheritdoc + */ + public readonly dbInstanceEndpointPort: string; + + /** + * The underlying `aws_neptune_cluster_instance` L1. + */ + public readonly resource: neptuneClusterInstance.NeptuneClusterInstance; + + constructor(scope: Construct, id: string, props: DatabaseInstanceProps) { + super(scope, id, props); + + // TERRACONSTRUCTS DEVIATION: repo invariant -- unnamed resources get a gridUUID-scoped + // `uniqueResourceName` default (lowercased; Neptune always lowercases DB instance identifiers + // server-side) instead of relying on CloudFormation's Ref-based logical-id naming (which this + // repo has no equivalent of -- see `../docdb/instance.ts` for the same idiom) or the + // provider's own generated `terraform-` fallback. `DBInstanceIdentifier` is capped at 63 + // characters (same limit as RDS/DocumentDB DB instance identifiers), so `maxLength` is passed + // explicitly here. + const instanceIdentifier = Token.isUnresolved(props.dbInstanceName) + ? props.dbInstanceName + : ( + props.dbInstanceName ?? + this.stack.uniqueResourceName(this, { maxLength: 63 }) + ).toLowerCase(); + + this.resource = new neptuneClusterInstance.NeptuneClusterInstance( + this, + "Resource", + { + clusterIdentifier: props.cluster.clusterIdentifier, + instanceClass: props.instanceType._instanceType, + availabilityZone: props.availabilityZone, + identifier: instanceIdentifier, + neptuneParameterGroupName: props.parameterGroup?.parameterGroupName, + autoMinorVersionUpgrade: props.autoMinorVersionUpgrade, + skipFinalSnapshot: props.skipFinalSnapshot, + }, + ); + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `skipFinalSnapshot` synth-time warning on + // `DatabaseCluster` in `../docdb/cluster.ts` -- see the `skipFinalSnapshot` note on + // `DatabaseInstanceProps` above for the full rationale (no `final_snapshot_identifier` + // equivalent exists on `aws_neptune_cluster_instance` to honestly map RETAIN semantics onto). + if (props.skipFinalSnapshot !== true) { + Annotations.of(this).addWarning( + "`skipFinalSnapshot` is not set to `true`: `terraform destroy` (or any change that replaces this instance) will FAIL at apply time because `aws_neptune_cluster_instance` has no `final_snapshot_identifier` (or equivalent) argument to take a final snapshot with. Set `skipFinalSnapshot: true` to acknowledge this.", + ); + } + + this.cluster = props.cluster; + this.instanceIdentifier = this.resource.identifier; + this.dbInstanceEndpointAddress = this.resource.endpoint; + this.dbInstanceEndpointPort = Tokenization.stringifyNumber( + this.resource.port, + ); + + // TERRACONSTRUCTS DEVIATION: upstream converts `instance.attrPort` (a CFN `Fn::GetAtt` string + // attribute) into a number token via `cdk.Token.asNumber(...)`. The CDKTF L1 `port` getter + // already returns a native `number`-typed (Token-backed) attribute, so no string-to-number + // conversion is needed here. + this.instanceEndpoint = new Endpoint( + this.resource.endpoint, + this.resource.port, + ); + } +} diff --git a/src/aws/storage/neptune/parameter-group.ts b/src/aws/storage/neptune/parameter-group.ts new file mode 100644 index 00000000..0fe86095 --- /dev/null +++ b/src/aws/storage/neptune/parameter-group.ts @@ -0,0 +1,265 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/parameter-group.ts + +import { + neptuneClusterParameterGroup, + neptuneParameterGroup, +} from "@cdktn/provider-aws"; +import { Construct } from "constructs"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; + +/** + * The DB parameter group family that a DB parameter group is compatible with + */ +export class ParameterGroupFamily { + /** + * Family used by Neptune engine versions before 1.2.0.0 + */ + public static readonly NEPTUNE_1 = new ParameterGroupFamily("neptune1"); + /** + * Family used by Neptune engine versions 1.2.0.0 and later + */ + public static readonly NEPTUNE_1_2 = new ParameterGroupFamily("neptune1.2"); + /** + * Family used by Neptune engine versions 1.3.0.0 and later + */ + public static readonly NEPTUNE_1_3 = new ParameterGroupFamily("neptune1.3"); + /** + * Family used by Neptune engine versions 1.4.0.0 and later + */ + public static readonly NEPTUNE_1_4 = new ParameterGroupFamily("neptune1.4"); + + /** + * Constructor for specifying a custom parameter group family + * @param family the family of the parameter group Neptune + */ + public constructor(public readonly family: string) {} +} + +/** + * Properties for a parameter group + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `ParameterGroupPropsBase` does not — matching the base-idiom used throughout + * this repo (e.g. `ClusterParameterGroupProps` in `../docdb/parameter-group.ts`) for + * cross-account/-region construct placement. + */ +interface ParameterGroupPropsBase extends AwsConstructProps { + /** + * Description for this parameter group + * + * @default a generated description + */ + readonly description?: string; + + /** + * The parameters in this parameter group + */ + readonly parameters: { [key: string]: string }; + + /** + * Parameter group family + * + * @default - NEPTUNE_1 + */ + readonly family?: ParameterGroupFamily; +} + +/** + * Marker class for cluster parameter group + */ +export interface ClusterParameterGroupProps extends ParameterGroupPropsBase { + /** + * The name of the parameter group + * + * @default - a gridUUID-scoped generated name + */ + readonly clusterParameterGroupName?: string; +} + +/** + * Marker class for cluster parameter group + */ +export interface ParameterGroupProps extends ParameterGroupPropsBase { + /** + * The name of the parameter group + * + * @default - a gridUUID-scoped generated name + */ + readonly parameterGroupName?: string; +} + +/** + * A parameter group + * + * TODO: omitted — upstream also extends `aws_neptune.IDBClusterParameterGroupRef`-equivalent + * generated-reference typing is not applicable here (neptune-alpha's `IResource`-only interface + * has no such marker to begin with); this note mirrors the identical omission pattern used + * elsewhere in this repo for cross-stack CloudFormation "Reference" marker interfaces (e.g. + * `IClusterParameterGroup` in `../docdb/parameter-group.ts`) so future re-diffs know the gap is + * intentional and not a missed port. + */ +export interface IClusterParameterGroup extends IAwsConstruct { + /** + * The name of this parameter group + */ + readonly clusterParameterGroupName: string; +} + +/** + * A cluster parameter group + * + * @resource aws_neptune_cluster_parameter_group + */ +export class ClusterParameterGroup + extends AwsConstructBase + implements IClusterParameterGroup +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.neptune.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; + + private readonly resource: neptuneClusterParameterGroup.NeptuneClusterParameterGroup; + + constructor(scope: Construct, id: string, props: ClusterParameterGroupProps) { + super(scope, id, props); + + this.resource = + new neptuneClusterParameterGroup.NeptuneClusterParameterGroup( + this, + "Resource", + { + // TERRACONSTRUCTS DEVIATION: when unnamed, upstream lets CloudFormation generate a name + // from the logical id; the repo invariant is a gridUUID-scoped `uniqueResourceName` + // default instead (mirroring `ClusterParameterGroup` in `../docdb/parameter-group.ts`), + // lowercased to match the Neptune/RDS-family server-side storage convention. + name: + props.clusterParameterGroupName ?? + this.stack.uniqueResourceName(this).toLowerCase(), + description: + props.description || + "Cluster parameter group for neptune db cluster", + family: (props.family ?? ParameterGroupFamily.NEPTUNE_1).family, + parameter: Object.entries(props.parameters).map(([name, value]) => ({ + name, + value, + })), + }, + ); + + this.clusterParameterGroupName = this.resource.name; + } + + public get outputs(): Record { + return { + name: this.clusterParameterGroupName, + arn: this.resource.arn, + }; + } +} + +/** + * A parameter group + */ +export interface IParameterGroup extends IAwsConstruct { + /** + * The name of this parameter group + */ + readonly parameterGroupName: string; +} + +/** + * DB parameter group + * + * @resource aws_neptune_parameter_group + */ +export class ParameterGroup + extends AwsConstructBase + implements IParameterGroup +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.neptune.ParameterGroup"; + + /** + * Imports a parameter group + */ + public static fromParameterGroupName( + scope: Construct, + id: string, + parameterGroupName: string, + ): IParameterGroup { + class Import extends AwsConstructBase implements IParameterGroup { + public readonly parameterGroupName = parameterGroupName; + public get outputs(): Record { + return { name: this.parameterGroupName }; + } + } + return new Import(scope, id); + } + + /** + * The name of the parameter group + */ + public readonly parameterGroupName: string; + + private readonly resource: neptuneParameterGroup.NeptuneParameterGroup; + + constructor(scope: Construct, id: string, props: ParameterGroupProps) { + super(scope, id, props); + + this.resource = new neptuneParameterGroup.NeptuneParameterGroup( + this, + "Resource", + { + // TERRACONSTRUCTS DEVIATION: see the identical naming-default note on + // `ClusterParameterGroup` above. + name: + props.parameterGroupName ?? + this.stack.uniqueResourceName(this).toLowerCase(), + description: + props.description || + "Instance parameter group for neptune db instances", + family: (props.family ?? ParameterGroupFamily.NEPTUNE_1).family, + parameter: Object.entries(props.parameters).map(([name, value]) => ({ + name, + value, + })), + }, + ); + + this.parameterGroupName = this.resource.name; + } + + public get outputs(): Record { + return { + name: this.parameterGroupName, + arn: this.resource.arn, + }; + } +} diff --git a/src/aws/storage/neptune/subnet-group.ts b/src/aws/storage/neptune/subnet-group.ts new file mode 100644 index 00000000..f61f7c2c --- /dev/null +++ b/src/aws/storage/neptune/subnet-group.ts @@ -0,0 +1,144 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/subnet-group.ts + +import { neptuneSubnetGroup } 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 subnet group. + * + * TODO: omitted — upstream also extends 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 no such marker is added here — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/lib/subnet-group.ts#L10-L17 + */ +export interface ISubnetGroup extends IAwsConstruct { + /** + * The name of the subnet group. + */ + readonly subnetGroupName: string; +} + +/** + * Properties for creating a SubnetGroup. + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `SubnetGroupProps` 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 SubnetGroupProps extends AwsConstructProps { + /** + * Description of the subnet group. + * + * @default - a default description is generated + */ + readonly description?: string; + + /** + * The VPC to place the subnet group in. + */ + readonly vpc: ec2.IVpc; + + /** + * The name of the subnet group. + * + * @default - a gridUUID-scoped generated name + */ + readonly subnetGroupName?: 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.DESTROY, mapped onto the CfnDBSubnetGroup's DeletionPolicy). `core.RemovalPolicy` + // is not ported in this repo (see the identical omission on `SubnetGroupProps` in + // `../rds/subnet-group.ts`), and the `aws_neptune_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-neptune-alpha/lib/subnet-group.ts#L51-L56 + // readonly removalPolicy?: RemovalPolicy; +} + +/** + * Class for creating a Neptune subnet group + * + * @resource aws_neptune_subnet_group + */ +export class SubnetGroup extends AwsConstructBase implements ISubnetGroup { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.neptune.SubnetGroup"; + + /** + * Imports an existing subnet group by name. + */ + public static fromSubnetGroupName( + scope: Construct, + id: string, + subnetGroupName: string, + ): ISubnetGroup { + class Import extends AwsConstructBase implements ISubnetGroup { + public readonly subnetGroupName = subnetGroupName; + public get outputs(): Record { + return { name: this.subnetGroupName }; + } + } + return new Import(scope, id); + } + + public readonly subnetGroupName: string; + + private readonly resource: neptuneSubnetGroup.NeptuneSubnetGroup; + + constructor(scope: Construct, id: string, props: SubnetGroupProps) { + super(scope, id, props); + + const { subnetIds } = props.vpc.selectSubnets( + props.vpcSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + ); + + this.resource = new neptuneSubnetGroup.NeptuneSubnetGroup( + this, + "Resource", + { + description: props.description || "Subnet group for Neptune", + // names are actually stored by Neptune 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 Neptune/RDS-family server-side storage convention. + name: Token.isUnresolved(props.subnetGroupName) + ? props.subnetGroupName + : ( + props.subnetGroupName ?? this.stack.uniqueResourceName(this) + ).toLowerCase(), + subnetIds, + }, + ); + + // TERRACONSTRUCTS DEVIATION: no `removalPolicy` to apply here — see `SubnetGroupProps` note + // above. + + this.subnetGroupName = this.resource.name; + } + + public get outputs(): Record { + return { + name: this.subnetGroupName, + arn: this.resource.arn, + }; + } +} diff --git a/test/aws/storage/neptune/__snapshots__/cluster.test.ts.snap b/test/aws/storage/neptune/__snapshots__/cluster.test.ts.snap new file mode 100644 index 00000000..e5c40181 --- /dev/null +++ b/test/aws/storage/neptune/__snapshots__/cluster.test.ts.snap @@ -0,0 +1,416 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatabaseCluster 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_neptune_cluster": { + "Database_B269D8BB": { + "cluster_identifier": "mystackdatabase2b988910", + "neptune_subnet_group_name": "\${aws_neptune_subnet_group.Database_Subnets_3C9252C9.name}", + "storage_encrypted": true, + "tags": { + "Name": "Test-Database", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_security_group_ids": [ + "\${aws_security_group.Database_SecurityGroup_5C91FDCB.id}" + ] + } + }, + "aws_neptune_cluster_instance": { + "Database_Instance1_844F58FD": { + "auto_minor_version_upgrade": false, + "cluster_identifier": "\${aws_neptune_cluster.Database_B269D8BB.cluster_identifier}", + "depends_on": [ + "aws_route_table_association.VPC_PrivateSubnet1_RouteTableAssociation_347902D1", + "aws_route.VPC_PrivateSubnet1_DefaultRoute_AE1D6490", + "aws_route_table_association.VPC_PrivateSubnet2_RouteTableAssociation_0C73D413", + "aws_route.VPC_PrivateSubnet2_DefaultRoute_F4F5CFD2", + "aws_route_table_association.VPC_PrivateSubnet3_RouteTableAssociation_C28D144E", + "aws_route.VPC_PrivateSubnet3_DefaultRoute_27F311AE" + ], + "identifier": "mystackdatabase2b988910instance1", + "instance_class": "db.r5.large", + "tags": { + "Name": "Test-Database", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_neptune_subnet_group": { + "Database_Subnets_3C9252C9": { + "description": "Subnets for Database database", + "name": "mystackdatabasesubnets878f1f2b", + "subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}", + "\${aws_subnet.VPC_PrivateSubnet3_EAEE5839.id}" + ], + "tags": { + "Name": "Test-Database", + "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": { + "Database_SecurityGroup_5C91FDCB": { + "description": "Neptune 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-12d3MyStackDatabaseSecurityGroup228B52A5", + "tags": { + "Name": "Test-Database", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_subnet": { + "VPC_PrivateSubnet1_05F5A6DA": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.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/neptune/__snapshots__/instance.test.ts.snap b/test/aws/storage/neptune/__snapshots__/instance.test.ts.snap new file mode 100644 index 00000000..420e16cc --- /dev/null +++ b/test/aws/storage/neptune/__snapshots__/instance.test.ts.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatabaseInstance check that instantiation works 1`] = ` +"{ + "data": { + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_neptune_cluster_instance": { + "Instance_C1063A87": { + "cluster_identifier": "test-cluster", + "identifier": "mystackinstancec8b5f353", + "instance_class": "db.r5.large", + "tags": { + "Name": "Test-Instance", + "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/neptune/__snapshots__/parameter-group.test.ts.snap b/test/aws/storage/neptune/__snapshots__/parameter-group.test.ts.snap new file mode 100644 index 00000000..6c7d964d --- /dev/null +++ b/test/aws/storage/neptune/__snapshots__/parameter-group.test.ts.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClusterParameterGroup 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_neptune_cluster_parameter_group": { + "Params_A8366201": { + "description": "desc", + "family": "neptune1", + "name": "mystackparams9fd42da1", + "parameter": [ + { + "name": "key", + "value": "value" + } + ], + "tags": { + "Name": "Test-Params", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; + +exports[`ParameterGroup create a instance/db parameter group 1`] = ` +"{ + "data": { + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_neptune_parameter_group": { + "Params_A8366201": { + "description": "desc", + "family": "neptune1", + "name": "mystackparams9fd42da1", + "parameter": [ + { + "name": "key", + "value": "value" + } + ], + "tags": { + "Name": "Test-Params", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/neptune/__snapshots__/subnet-group.test.ts.snap b/test/aws/storage/neptune/__snapshots__/subnet-group.test.ts.snap new file mode 100644 index 00000000..17a40938 --- /dev/null +++ b/test/aws/storage/neptune/__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_neptune_subnet_group": { + "Group_C77FDACD": { + "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/neptune/cluster.test.ts b/test/aws/storage/neptune/cluster.test.ts new file mode 100644 index 00000000..56d3c1b6 --- /dev/null +++ b/test/aws/storage/neptune/cluster.test.ts @@ -0,0 +1,861 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/cluster.test.ts + +import { + cloudwatchMetricAlarm, + neptuneCluster, + neptuneClusterInstance, + neptuneSubnetGroup, + dataAwsIamPolicyDocument, + vpcSecurityGroupEgressRule, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as cloudwatch from "../../../../src/aws/cloudwatch"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as iam from "../../../../src/aws/iam"; +import * as neptune from "../../../../src/aws/storage/neptune"; +import { Duration } from "../../../../src/duration"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +let vpc: compute.IVpc; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + vpc = new compute.Vpc(stack, "VPC"); +}); + +describe("DatabaseCluster", () => { + test("check that instantiation works", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: upstream asserts CFN DeletionPolicy/UpdateReplacePolicy Retain + // (core.RemovalPolicy is not ported; destroy-time semantics map onto + // skipFinalSnapshot/finalSnapshotIdentifier — see the props' deviation notes). + const t = new Template(stack, { snapshot: true }); + const [cluster]: any[] = t.resourceTypeArray(neptuneCluster.NeptuneCluster); + expect(cluster.storage_encrypted).toEqual(true); + expect(cluster.neptune_subnet_group_name).toBeDefined(); + expect(cluster.vpc_security_group_ids).toHaveLength(1); + + t.resourceCountIs(neptuneClusterInstance.NeptuneClusterInstance, 1); + + const [subnets]: any[] = t.resourceTypeArray( + neptuneSubnetGroup.NeptuneSubnetGroup, + ); + // default Vpc has private-with-egress subnets across the available AZs + expect(subnets.subnet_ids.length).toBeGreaterThanOrEqual(2); + }); + + test("can create a cluster with a single instance", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + instances: 1, + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const t = new Template(stack); + t.resourceCountIs(neptuneClusterInstance.NeptuneClusterInstance, 1); + const [cluster]: any[] = t.resourceTypeArray(neptuneCluster.NeptuneCluster); + expect(cluster.neptune_subnet_group_name).toBeDefined(); + expect(cluster.vpc_security_group_ids).toHaveLength(1); + }); + + test("errors when less than one instance is specified", () => { + // WHEN + expect(() => { + new neptune.DatabaseCluster(stack, "Database", { + instances: 0, + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + }).toThrow("At least one instance is required"); + }); + + test("errors when only one subnet is specified", () => { + // GIVEN + const smallVpc = new compute.Vpc(stack, "SmallVPC", { + maxAzs: 1, + }); + + // WHEN + expect(() => { + new neptune.DatabaseCluster(stack, "Database", { + instances: 1, + vpc: smallVpc, + vpcSubnets: { + subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS, + }, + instanceType: neptune.InstanceType.R5_LARGE, + }); + }).toThrow("Cluster requires at least 2 subnets, got 1"); + }); + + test("can create a cluster with custom engine version", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + engineVersion: neptune.EngineVersion.V1_0_4_1, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + engine_version: "1.0.4.1", + }, + ); + }); + + test.each([ + ["1.1.1.0", neptune.EngineVersion.V1_1_1_0], + ["1.2.0.0", neptune.EngineVersion.V1_2_0_0], + ["1.3.0.0", neptune.EngineVersion.V1_3_0_0], + ["1.4.0.0", neptune.EngineVersion.V1_4_0_0], + ])("can create a cluster for engine version %s", (expected, version) => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + engineVersion: version, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + engine_version: expected, + }, + ); + }); + + test("can create a cluster with imported security group", () => { + // GIVEN + // TERRACONSTRUCTS DEVIATION: upstream uses `ec2.Vpc.fromLookup` (ContextProvider, not ported — + // see the fromLookup TODO in ../../../../src/aws/compute); a constructed Vpc with an imported + // security group exercises the same code path (caller-supplied securityGroups). + const sg = compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "SecurityGroupId12345", + ); + + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + instances: 1, + vpc, + securityGroups: [sg], + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + vpc_security_group_ids: ["SecurityGroupId12345"], + }, + ); + }); + + test("cluster with parameter group", () => { + // WHEN + const group = new neptune.ClusterParameterGroup(stack, "Params", { + description: "bye", + parameters: { + param: "value", + }, + }); + const cluster = new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + clusterParameterGroup: group, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + neptune_cluster_parameter_group_name: stack.resolve( + group.clusterParameterGroupName, + ), + }, + ); + expect(cluster).toBeDefined(); + }); + + test("cluster with associated role", () => { + // WHEN + const role = new iam.Role(stack, "Role", { + assumedBy: new iam.ServicePrincipal("rds.amazonaws.com"), + }); + role.addManagedPolicy( + // TERRACONSTRUCTS DEVIATION: base's fromAwsManagedPolicyName takes (scope, id, name) + iam.ManagedPolicy.fromAwsManagedPolicyName( + stack, + "S3ReadOnly", + "AmazonS3ReadOnlyAccess", + ), + ); + + new neptune.DatabaseCluster(stack, "Database", { + vpc, + associatedRoles: [role], + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + iam_roles: [stack.resolve(role.roleArn)], + }, + ); + }); + + test("cluster with port", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + port: 1234, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + port: 1234, + }, + ); + }); + + test("cluster with imported parameter group", () => { + // WHEN + const group = neptune.ClusterParameterGroup.fromClusterParameterGroupName( + stack, + "Params", + "ParamGroupName", + ); + + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + clusterParameterGroup: group, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + neptune_cluster_parameter_group_name: "ParamGroupName", + }, + ); + }); + + test("create an encrypted cluster with custom KMS key", () => { + // GIVEN + const key = new encryption.Key(stack, "Key"); + + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + kmsKey: key, + }); + + // THEN -- the key ARN, not the bare id (id-vs-ARN read-back parity; the + // PR #151 kms lesson) + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + kms_key_arn: stack.resolve(key.keyArn), + storage_encrypted: true, + }, + ); + }); + + test("creating a cluster defaults to using encryption", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + storage_encrypted: true, + }, + ); + }); + + test("supplying a KMS key with storageEncryption false throws an error", () => { + // GIVEN + const key = new encryption.Key(stack, "Key"); + + // WHEN + function action() { + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + kmsKey: key, + storageEncrypted: false, + }); + } + + // THEN + expect(action).toThrow(); + }); + + test("cluster exposes different read and write endpoints", () => { + // WHEN + const cluster = new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + expect(stack.resolve(cluster.clusterEndpoint)).not.toBe( + stack.resolve(cluster.clusterReadEndpoint), + ); + }); + + test("instance identifier used when present", () => { + // WHEN + const instanceIdentifierBase = "instanceidentifierbase-"; + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + instanceIdentifierBase, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + identifier: `${instanceIdentifierBase}1`, + }, + ); + }); + + test("cluster identifier used", () => { + // WHEN + const clusterIdentifier = "clusteridentifier-"; + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + dbClusterName: clusterIdentifier, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + identifier: `${clusterIdentifier}instance1`, + }, + ); + }); + + test("unnamed cluster derives lowercase gridUUID-scoped instance identifiers", () => { + // TERRACONSTRUCTS DEVIATION: repo naming invariant (docdb precedent) -- + // instances reuse the DERIVED grid-scoped cluster identifier as their base + // instead of provider auto-naming. + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + instances: 2, + }); + + const t = new Template(stack); + const instances: any[] = t.resourceTypeArray( + neptuneClusterInstance.NeptuneClusterInstance, + ); + expect(instances).toHaveLength(2); + const [cluster]: any[] = t.resourceTypeArray(neptuneCluster.NeptuneCluster); + expect(instances[0].identifier).toEqual( + `${cluster.cluster_identifier}instance1`, + ); + expect(instances[1].identifier).toEqual( + `${cluster.cluster_identifier}instance2`, + ); + expect(instances[0].identifier).toMatch(/^[a-z0-9-]+$/); + }); + + test("imported cluster has supplied attributes", () => { + // WHEN + const cluster = neptune.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Database", + { + clusterEndpointAddress: "addr", + clusterIdentifier: "identifier", + clusterResourceIdentifier: "resourceIdentifier", + port: 3306, + readerEndpointAddress: "reader-address", + securityGroup: compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { + allowAllOutbound: false, + }, + ), + }, + ); + + // THEN + expect(cluster.clusterEndpoint.hostname).toEqual("addr"); + expect(cluster.clusterEndpoint.port).toEqual(3306); + expect(cluster.clusterIdentifier).toEqual("identifier"); + expect(cluster.clusterReadEndpoint.hostname).toEqual("reader-address"); + }); + + test("imported cluster with imported security group honors allowAllOutbound", () => { + // GIVEN + const cluster = neptune.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Database", + { + clusterEndpointAddress: "addr", + clusterIdentifier: "identifier", + clusterResourceIdentifier: "resourceIdentifier", + port: 3306, + readerEndpointAddress: "reader-address", + securityGroup: compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { + allowAllOutbound: false, + }, + ), + }, + ); + + // WHEN + cluster.connections.allowToAnyIpv4(compute.Port.tcp(443)); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + vpcSecurityGroupEgressRule.VpcSecurityGroupEgressRule, + { + security_group_id: "sg-123456789", + }, + ); + }); + + test("backup retention period respected", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + backupRetention: Duration.days(20), + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + backup_retention_period: 20, + }, + ); + }); + + test("backup maintenance window respected", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + backupRetention: Duration.days(20), + preferredBackupWindow: "07:34-08:04", + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + backup_retention_period: 20, + preferred_backup_window: "07:34-08:04", + }, + ); + }); + + test("regular maintenance window respected", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + preferredMaintenanceWindow: "07:34-08:04", + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + preferred_maintenance_window: "07:34-08:04", + }, + ); + }); + + test("iam authentication - off by default", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const t = new Template(stack); + const [cluster]: any[] = t.resourceTypeArray(neptuneCluster.NeptuneCluster); + expect(cluster.iam_database_authentication_enabled).toBeUndefined(); + }); + + test("grantConnect - enables IAM auth and grants neptune-db:* to the grantee", () => { + // WHEN + const cluster = new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + const role = new iam.Role(stack, "DBRole", { + assumedBy: new iam.AccountPrincipal(stack.account), + }); + cluster.grantConnect(role); + + // THEN + const t = new Template(stack); + const [clusterRes]: any[] = t.resourceTypeArray( + neptuneCluster.NeptuneCluster, + ); + expect(clusterRes.iam_database_authentication_enabled).toEqual(true); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["neptune-db:*"], + effect: "Allow", + resources: [ + stack.resolve( + `arn:${stack.partition}:neptune-db:${stack.region}:${stack.account}:${cluster.clusterResourceIdentifier}/*`, + ), + ], + }, + ], + }, + ); + }); + + test("grantConnect - throws if IAM auth disabled", () => { + // WHEN + const cluster = new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + iamAuthentication: false, + }); + const role = new iam.Role(stack, "DBRole", { + assumedBy: new iam.AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { + cluster.grantConnect(role); + }).toThrow(/Cannot grant permissions when IAM authentication is disabled/); + }); + + test("grant - enables IAM auth and grants specified actions to the grantee", () => { + // WHEN + const cluster = new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + const role = new iam.Role(stack, "DBRole", { + assumedBy: new iam.AccountPrincipal(stack.account), + }); + cluster.grant( + role, + "neptune-db:ReadDataViaQuery", + "neptune-db:WriteDataViaQuery", + ); + + // THEN + const t = new Template(stack); + const [clusterRes]: any[] = t.resourceTypeArray( + neptuneCluster.NeptuneCluster, + ); + expect(clusterRes.iam_database_authentication_enabled).toEqual(true); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: [ + "neptune-db:ReadDataViaQuery", + "neptune-db:WriteDataViaQuery", + ], + effect: "Allow", + resources: [ + stack.resolve( + `arn:${stack.partition}:neptune-db:${stack.region}:${stack.account}:${cluster.clusterResourceIdentifier}/*`, + ), + ], + }, + ], + }, + ); + }); + + test("grant - throws if IAM auth disabled", () => { + // WHEN + const cluster = new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + iamAuthentication: false, + }); + const role = new iam.Role(stack, "DBRole", { + assumedBy: new iam.AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { + cluster.grant( + role, + "neptune-db:ReadDataViaQuery", + "neptune-db:WriteDataViaQuery", + ); + }).toThrow(/Cannot grant permissions when IAM authentication is disabled/); + }); + + test("autoMinorVersionUpgrade is enabled when configured", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + autoMinorVersionUpgrade: true, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + auto_minor_version_upgrade: true, + }, + ); + }); + + test("autoMinorVersionUpgrade is not enabled when not configured", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + auto_minor_version_upgrade: false, + }, + ); + }); + + test("cloudwatchLogsExports is enabled when configured", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + cloudwatchLogsExports: [neptune.LogType.AUDIT], + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + enable_cloudwatch_logs_exports: ["audit"], + }, + ); + }); + + // TODO: omitted — upstream's `cloudwatchLogsRetention` tests exercise the Lambda-backed + // `Custom::LogRetention` custom resource, which has no Terraform equivalent (the prop is + // TODO-omitted in cluster.ts) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/cluster.test.ts#L697-L729 + // test('cloudwatchLogsExports log retention is enabled when configured', () => { ... }); + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/cluster.test.ts#L848-L895 + // test('cloudwatchLogsExports log retention is enabled when configured for multiple logs exports', () => { ... }); + + test("metric - constructs metric with correct namespace and dimension and inputs", () => { + // GIVEN + const cluster = new neptune.DatabaseCluster(stack, "Cluster", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // WHEN + const metric = cluster.metric("SparqlRequestsPerSec"); + new cloudwatch.Alarm(stack, "Alarm", { + evaluationPeriods: 1, + threshold: 1, + comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, + metric: metric, + }); + + // THEN + expect(metric).toEqual( + new cloudwatch.Metric({ + namespace: "AWS/Neptune", + dimensionsMap: { + DBClusterIdentifier: cluster.clusterIdentifier, + }, + metricName: "SparqlRequestsPerSec", + }), + ); + Template.synth(stack).toHaveResourceWithProperties( + cloudwatchMetricAlarm.CloudwatchMetricAlarm, + { + namespace: "AWS/Neptune", + metric_name: "SparqlRequestsPerSec", + dimensions: { + DBClusterIdentifier: stack.resolve(cluster.clusterIdentifier), + }, + comparison_operator: "LessThanThreshold", + evaluation_periods: 1, + threshold: 1, + }, + ); + }); + + test("should instantiate a serverless cluster", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.SERVERLESS, + serverlessScalingConfiguration: { + minCapacity: 1, + maxCapacity: 10, + }, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + serverless_v2_scaling_configuration: { + min_capacity: 1, + max_capacity: 10, + }, + }, + ); + Template.synth(stack).toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + instance_class: "db.serverless", + }, + ); + }); + + test("should validate serverlessScalingConfiguration", () => { + expect(() => { + new neptune.DatabaseCluster(stack, "Database0", { + vpc, + instanceType: neptune.InstanceType.SERVERLESS, + }); + }).toThrow( + /You need to specify a serverless scaling configuration with a db.serverless instance type./, + ); + + expect(() => { + new neptune.DatabaseCluster(stack, "Database1", { + vpc, + instanceType: neptune.InstanceType.SERVERLESS, + serverlessScalingConfiguration: { + minCapacity: 0, + maxCapacity: 10, + }, + }); + }).toThrow( + /ServerlessScalingConfiguration minCapacity must be greater or equal than 1, received 0/, + ); + + expect(() => { + new neptune.DatabaseCluster(stack, "Database2", { + vpc, + instanceType: neptune.InstanceType.SERVERLESS, + serverlessScalingConfiguration: { + minCapacity: 1, + maxCapacity: 200, + }, + }); + }).toThrow( + /ServerlessScalingConfiguration maxCapacity must be between 2.5 and 128, received 200/, + ); + + expect(() => { + new neptune.DatabaseCluster(stack, "Database3", { + vpc, + instanceType: neptune.InstanceType.SERVERLESS, + serverlessScalingConfiguration: { + minCapacity: 10, + maxCapacity: 5, + }, + }); + }).toThrow( + /ServerlessScalingConfiguration minCapacity 10 must be less than serverlessScalingConfiguration maxCapacity 5/, + ); + }); + + test("copyTagsToSnapshot is not set by default", () => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const t = new Template(stack); + const [cluster]: any[] = t.resourceTypeArray(neptuneCluster.NeptuneCluster); + expect(cluster.copy_tags_to_snapshot).toBeUndefined(); + }); + + test.each([false, true])("cluster with copyTagsToSnapshot set", (value) => { + // WHEN + new neptune.DatabaseCluster(stack, "Database", { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + copyTagsToSnapshot: value, + }); + + // THEN + Template.synth(stack).toHaveResourceWithProperties( + neptuneCluster.NeptuneCluster, + { + copy_tags_to_snapshot: value, + }, + ); + }); +}); diff --git a/test/aws/storage/neptune/endpoint.test.ts b/test/aws/storage/neptune/endpoint.test.ts new file mode 100644 index 00000000..bcaff5d3 --- /dev/null +++ b/test/aws/storage/neptune/endpoint.test.ts @@ -0,0 +1,55 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/endpoint.test.ts + +import { Token } from "cdktn"; +import { Endpoint } from "../../../../src/aws/storage/neptune"; + +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/neptune/instance.test.ts b/test/aws/storage/neptune/instance.test.ts new file mode 100644 index 00000000..3db2cd5a --- /dev/null +++ b/test/aws/storage/neptune/instance.test.ts @@ -0,0 +1,385 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/instance.test.ts + +import { neptuneClusterInstance } from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing, Tokenization } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { Construct } from "constructs"; +import { AwsConstructBase, AwsStack } from "../../../../src/aws"; +import * as cloudwatch from "../../../../src/aws/cloudwatch"; +import * as compute from "../../../../src/aws/compute"; +import * as iam from "../../../../src/aws/iam"; +import * as neptune from "../../../../src/aws/storage/neptune"; +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", +}; + +/** + * TEST-ONLY adapter implementing `neptune.IDatabaseCluster` directly on top of a bare + * `compute.Connections`/identifier pair. This fixture stands in for the real `DatabaseCluster` + * construct (`cluster.ts`) so `DatabaseInstance` (which requires a `neptune.IDatabaseCluster`) can + * be exercised in isolation from `DatabaseCluster`'s own subnet-group/security-group machinery. + * Mirrors the `TestDatabaseCluster` TEST-ONLY adapter pattern in `../docdb/instance.test.ts`. + */ +class TestDatabaseCluster + extends AwsConstructBase + implements neptune.IDatabaseCluster +{ + public readonly clusterIdentifier: string; + public readonly clusterResourceIdentifier: string; + public readonly clusterEndpoint: neptune.Endpoint; + public readonly clusterReadEndpoint: neptune.Endpoint; + public readonly connections: compute.Connections; + + constructor(scope: Construct, id: string) { + super(scope, id, {}); + + this.clusterIdentifier = "test-cluster"; + this.clusterResourceIdentifier = "cluster-ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + this.clusterEndpoint = new neptune.Endpoint("cluster.endpoint", 8182); + this.clusterReadEndpoint = new neptune.Endpoint( + "cluster.reader.endpoint", + 8182, + ); + this.connections = new compute.Connections(); + } + + public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [ + this.stack.formatArn({ + service: "neptune-db", + resource: this.clusterResourceIdentifier, + resourceName: "*", + }), + ], + }); + } + + public grantConnect(grantee: iam.IGrantable): iam.Grant { + return this.grant(grantee, "neptune-db:connect"); + } + + public metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: "AWS/Neptune", + dimensionsMap: { DBClusterIdentifier: this.clusterIdentifier }, + metricName, + ...props, + }); + } + + public get outputs(): Record { + return { identifier: this.clusterIdentifier }; + } +} + +let app: App; +let stack: AwsStack; +let cluster: neptune.IDatabaseCluster; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + cluster = new TestDatabaseCluster(stack, "Database"); +}); + +describe("DatabaseInstance", () => { + test("check that instantiation works", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + cluster_identifier: stack.resolve(cluster.clusterIdentifier), + instance_class: "db.r5.large", + }, + ); + }); + + test("check that the endpoint works", () => { + // WHEN + const instance = new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + expect(stack.resolve(instance.instanceEndpoint.port)).toEqual( + stack.resolve(instance.resource.port), + ); + // Built from the L1 attribute references directly (NOT from instanceEndpoint's own fields) + // so this fails if socketAddress ever stops interpolating hostname:port. + expect(stack.resolve(instance.instanceEndpoint.socketAddress)).toEqual( + stack.resolve( + `${instance.resource.endpoint}:${Tokenization.stringifyNumber(instance.resource.port)}`, + ), + ); + }); + + test("check importing works as expected", () => { + // GIVEN + const instanceEndpointAddress = "127.0.0.1"; + const instanceIdentifier = "InstanceID"; + const port = 8888; + + // WHEN + const instance = neptune.DatabaseInstance.fromDatabaseInstanceAttributes( + stack, + "ImportedInstance", + { + instanceEndpointAddress, + instanceIdentifier, + port, + }, + ); + + // THEN + expect(instance.instanceIdentifier).toEqual(instanceIdentifier); + expect(instance.instanceEndpoint.socketAddress).toEqual( + `${instanceEndpointAddress}:${port}`, + ); + }); + + test("instance with parameter group", () => { + // WHEN + const group = new neptune.ParameterGroup(stack, "Params", { + description: "bye", + parameters: { + param: "value", + }, + }); + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + parameterGroup: group, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + neptune_parameter_group_name: stack.resolve(group.parameterGroupName), + }, + ); + }); + + test.each([true, false])( + "instance with auto minor version upgrade: %p", + (autoMinorVersionUpgrade) => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + autoMinorVersionUpgrade, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + auto_minor_version_upgrade: autoMinorVersionUpgrade, + }, + ); + }, + ); + + test("autoMinorVersionUpgrade left unset when not provided", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + neptuneClusterInstance.NeptuneClusterInstance, + ) as any[]; + expect(resource.auto_minor_version_upgrade).toBeUndefined(); + }); + + test("instance type from a token", () => { + // GIVEN + const instanceType = new TerraformVariable(stack, "NeptuneInstanceType", { + type: "string", + default: "db.r5.8xlarge", + }); + + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.of(instanceType.stringValue), + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + neptuneClusterInstance.NeptuneClusterInstance, + ) as any[]; + expect(stack.resolve(resource.instance_class)).toEqual( + stack.resolve(instanceType.stringValue), + ); + }); + + test("instance type from string throws if missing db prefix", () => { + expect(() => { + neptune.InstanceType.of("r5.xlarge"); + }).toThrow(/instance type must start with 'db.'/); + }); + + test("metric - constructs metric with correct namespace and dimension and inputs", () => { + // GIVEN + const instance = new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // WHEN + const metric = instance.metric("SparqlRequestsPerSec"); + new cloudwatch.Alarm(stack, "Alarm", { + evaluationPeriods: 1, + threshold: 1, + comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, + metric, + }); + + // THEN + expect(metric).toEqual( + new cloudwatch.Metric({ + namespace: "AWS/Neptune", + dimensionsMap: { + DBInstanceIdentifier: instance.instanceIdentifier, + }, + metricName: "SparqlRequestsPerSec", + }), + ); + }); + + test("should instantiate a serverless instance", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.SERVERLESS, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + instance_class: "db.serverless", + }, + ); + }); + + test("instance identifier defaults to a gridUUID-scoped, lowercased generated name", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + neptuneClusterInstance.NeptuneClusterInstance, + ) as any[]; + expect(resource.identifier).toEqual(expect.any(String)); + expect(resource.identifier).toEqual(resource.identifier.toLowerCase()); + }); + + test("an explicit dbInstanceName is lowercased", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + dbInstanceName: "MyInstanceName", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + identifier: "myinstancename", + }, + ); + }); + + describe("skipFinalSnapshot", () => { + test("passes through to the underlying resource", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + skipFinalSnapshot: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterInstance.NeptuneClusterInstance, + { + skip_final_snapshot: true, + }, + ); + }); + + test("warns when not set to true", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + }); + + // THEN + const warnings = Annotations.fromStack(stack).warnings; + expect( + warnings.some((w) => + w.message.toString().includes("skipFinalSnapshot"), + ), + ).toEqual(true); + }); + + test("does not warn when true", () => { + // WHEN + new neptune.DatabaseInstance(stack, "Instance", { + cluster, + instanceType: neptune.InstanceType.R5_LARGE, + skipFinalSnapshot: true, + }); + + // THEN + const warnings = Annotations.fromStack(stack).warnings; + expect( + warnings.some((w) => + w.message.toString().includes("skipFinalSnapshot"), + ), + ).toEqual(false); + }); + }); +}); diff --git a/test/aws/storage/neptune/parameter-group.test.ts b/test/aws/storage/neptune/parameter-group.test.ts new file mode 100644 index 00000000..a87e6d79 --- /dev/null +++ b/test/aws/storage/neptune/parameter-group.test.ts @@ -0,0 +1,271 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/parameter-group.test.ts + +import { + neptuneClusterParameterGroup, + neptuneParameterGroup, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as neptune from "../../../../src/aws/storage/neptune"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +}); + +describe("ClusterParameterGroup", () => { + test("create a cluster parameter group", () => { + // WHEN + new neptune.ClusterParameterGroup(stack, "Params", { + description: "desc", + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + neptuneClusterParameterGroup.NeptuneClusterParameterGroup, + { + description: "desc", + family: "neptune1", + parameter: [{ name: "key", value: "value" }], + }, + ); + }); + + test.each([ + ["neptune1", neptune.ParameterGroupFamily.NEPTUNE_1], + ["neptune1.2", neptune.ParameterGroupFamily.NEPTUNE_1_2], + ["neptune1.3", neptune.ParameterGroupFamily.NEPTUNE_1_3], + ["neptune1.4", neptune.ParameterGroupFamily.NEPTUNE_1_4], + ])( + "create a cluster parameter group with family %s", + (expectedFamily, family) => { + // WHEN + new neptune.ClusterParameterGroup(stack, "Params", { + description: "desc", + family, + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterParameterGroup.NeptuneClusterParameterGroup, + { + description: "desc", + family: expectedFamily, + parameter: [{ name: "key", value: "value" }], + }, + ); + }, + ); + + test("check automatically generated descriptions", () => { + // WHEN + new neptune.ClusterParameterGroup(stack, "Params", { + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterParameterGroup.NeptuneClusterParameterGroup, + { + description: "Cluster parameter group for neptune db cluster", + family: "neptune1", + }, + ); + }); + + test("check that name defaults to a gridUUID-scoped generated name", () => { + // WHEN + new neptune.ClusterParameterGroup(stack, "Params", { + parameters: {}, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + neptuneClusterParameterGroup.NeptuneClusterParameterGroup, + ) 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 neptune.ClusterParameterGroup(stack, "Params", { + clusterParameterGroupName: "my-group", + parameters: {}, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneClusterParameterGroup.NeptuneClusterParameterGroup, + { + name: "my-group", + }, + ); + }); + + test("check that fromClusterParameterGroupName imports by name", () => { + // WHEN + const group = neptune.ClusterParameterGroup.fromClusterParameterGroupName( + stack, + "Imported", + "my-existing-group", + ); + + // THEN + expect(group.clusterParameterGroupName).toEqual("my-existing-group"); + const t = new Template(stack); + t.resourceCountIs( + neptuneClusterParameterGroup.NeptuneClusterParameterGroup, + 0, + ); + }); +}); + +describe("ParameterGroup", () => { + test("create a instance/db parameter group", () => { + // WHEN + new neptune.ParameterGroup(stack, "Params", { + description: "desc", + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties( + neptuneParameterGroup.NeptuneParameterGroup, + { + description: "desc", + family: "neptune1", + parameter: [{ name: "key", value: "value" }], + }, + ); + }); + + test.each([ + ["neptune1", neptune.ParameterGroupFamily.NEPTUNE_1], + ["neptune1.2", neptune.ParameterGroupFamily.NEPTUNE_1_2], + ["neptune1.3", neptune.ParameterGroupFamily.NEPTUNE_1_3], + ["neptune1.4", neptune.ParameterGroupFamily.NEPTUNE_1_4], + ])( + "create a instance/db parameter group with family %s", + (expectedFamily, family) => { + // WHEN + new neptune.ParameterGroup(stack, "Params", { + description: "desc", + family, + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneParameterGroup.NeptuneParameterGroup, + { + description: "desc", + family: expectedFamily, + parameter: [{ name: "key", value: "value" }], + }, + ); + }, + ); + + test("check automatically generated descriptions", () => { + // WHEN + new neptune.ParameterGroup(stack, "Params", { + parameters: { + key: "value", + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneParameterGroup.NeptuneParameterGroup, + { + description: "Instance parameter group for neptune db instances", + family: "neptune1", + }, + ); + }); + + test("check that name defaults to a gridUUID-scoped generated name", () => { + // WHEN + new neptune.ParameterGroup(stack, "Params", { + parameters: {}, + }); + + // THEN + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + neptuneParameterGroup.NeptuneParameterGroup, + ) 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 neptune.ParameterGroup(stack, "Params", { + parameterGroupName: "my-group", + parameters: {}, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneParameterGroup.NeptuneParameterGroup, + { + name: "my-group", + }, + ); + }); + + test("check that fromParameterGroupName imports by name", () => { + // WHEN + const group = neptune.ParameterGroup.fromParameterGroupName( + stack, + "Imported", + "my-existing-group", + ); + + // THEN + expect(group.parameterGroupName).toEqual("my-existing-group"); + const t = new Template(stack); + t.resourceCountIs(neptuneParameterGroup.NeptuneParameterGroup, 0); + }); +}); diff --git a/test/aws/storage/neptune/subnet-group.test.ts b/test/aws/storage/neptune/subnet-group.test.ts new file mode 100644 index 00000000..ab1fecb6 --- /dev/null +++ b/test/aws/storage/neptune/subnet-group.test.ts @@ -0,0 +1,126 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/subnet-group.test.ts + +import { neptuneSubnetGroup } 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 neptune from "../../../../src/aws/storage/neptune"; +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`'s identical adaptation) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-neptune-alpha/test/subnet-group.test.ts#L11 + vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); +}); + +test("creates a subnet group from minimal properties", () => { + new neptune.SubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + }); + + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties(neptuneSubnetGroup.NeptuneSubnetGroup, { + description: "MyGroup", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }); +}); + +test("creates a subnet group from all properties", () => { + new neptune.SubnetGroup(stack, "Group", { + description: "My Shared Group", + subnetGroupName: "SharedGroup", + vpc, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS }, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(neptuneSubnetGroup.NeptuneSubnetGroup, { + description: "My Shared Group", + // TERRACONSTRUCTS DEVIATION: Neptune stores subnet group names lowercase server-side (see + // `SubnetGroup`'s naming note in `../../../../src/aws/storage/neptune/subnet-group.ts`). + name: "sharedgroup", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }); +}); + +describe("subnet selection", () => { + test("defaults to private subnets", () => { + new neptune.SubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + }); + + const t = new Template(stack); + t.resourceCountIs(neptuneSubnetGroup.NeptuneSubnetGroup, 1); + t.expect.toHaveResourceWithProperties( + neptuneSubnetGroup.NeptuneSubnetGroup, + { + description: "MyGroup", + subnet_ids: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + stack.resolve(vpc.privateSubnets[1].subnetId), + ], + }, + ); + }); + + test("can specify subnet type", () => { + new neptune.SubnetGroup(stack, "Group", { + description: "MyGroup", + vpc, + vpcSubnets: { subnetType: compute.SubnetType.PUBLIC }, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + neptuneSubnetGroup.NeptuneSubnetGroup, + { + description: "MyGroup", + subnet_ids: [ + stack.resolve(vpc.publicSubnets[0].subnetId), + stack.resolve(vpc.publicSubnets[1].subnetId), + ], + }, + ); + }); +}); + +test("import group by name", () => { + const subnetGroup = neptune.SubnetGroup.fromSubnetGroupName( + stack, + "Group", + "my-subnet-group", + ); + + expect(subnetGroup.subnetGroupName).toEqual("my-subnet-group"); +});