diff --git a/go.mod b/go.mod index f1d9b8a8..e5a50d66 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ go 1.24.0 replace github.com/jmespath/go-jmespath => github.com/AndrewKlopper/go-jmespath v0.4.1 require ( - github.com/aws/aws-sdk-go-v2 v1.43.2 + github.com/aws/aws-sdk-go-v2 v1.43.4 github.com/aws/aws-sdk-go-v2/config v1.32.5 github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29 github.com/aws/aws-sdk-go-v2/service/acm v1.37.18 @@ -54,13 +54,14 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/service/codeartifact v1.30.3 // indirect 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/checksum v1.9.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect @@ -73,7 +74,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect - github.com/aws/smithy-go v1.27.5 // indirect + github.com/aws/smithy-go v1.27.6 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect diff --git a/go.sum b/go.sum index 817c07b0..33247fc0 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.43.2 h1:cl+IXwWb3qazClUcm08tGSsB6OiuV83JVJO9B0jQcPc= github.com/aws/aws-sdk-go-v2 v1.43.2/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= @@ -28,8 +30,12 @@ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 h1:hqcxMc2g/MwwnRMod9n6 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41/go.mod h1:d1eH0VrttvPmrCraU68LOyNdu26zFxQFjrVSb5vdhog= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 h1:HAp1wLFZzch054uh3FK7rcVYg4v7J2FxVf3h3IGNZas= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33/go.mod h1:mJk5fmqnF+WUlMdPG37pR2Fh3oh6r8F6ZGUgPKvzu0c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 h1:0YA0aCKgsJyno6xkFfaIgjE3/wK08+Qxo9nQfe1UrWM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33/go.mod h1:UZqj4WIdTH+ga8Y/DgpAuy/8cGjM3h7gDCliJYGg2SE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= @@ -62,6 +68,8 @@ github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 h1:zg+3FGHA0PBs0KM25qE/rOf2o5zs github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6/go.mod h1:ZSq54Z9SIsOTf1Efwgw1msilSs4XVEfVQiP9nYVnKpM= github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0 h1:7/vgFWplkusJN/m+3QOa+W9FNRqa8ujMPNmdufRaJpg= github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0/go.mod h1:dPTOvmjJQ1T7Q+2+Xs2KSPrMvx+p0rpyV+HsQVnUK4o= +github.com/aws/aws-sdk-go-v2/service/elasticache v1.56.4 h1:ApnIXsRMPkYY4ehi+SVuSmarkvWerqIncHtR2jPFthI= +github.com/aws/aws-sdk-go-v2/service/elasticache v1.56.4/go.mod h1:cOZC/yZzWPVLKlsEyvgzaGQUisyPcTAf4dPbRPekl9s= github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.17 h1:ltbEzdlO5qKYK1FuwTt2LibddWFmH/QY6usxvPOQP08= github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.17/go.mod h1:KXFNdzl+mZpQlLYm378Ml18wBHybbMpyBwNXuYjbDT4= github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 h1:xNCUk9XN6Pa9PyzbEfzgRpvEIVlqtth402yjaWvNMu4= @@ -110,6 +118,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.27.5 h1:d1ro7KpYOYwP6m73YFa+Kc/A130VsAdX68SpsJwARMM= github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 91b86caa..83522836 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -44,6 +44,10 @@ docdb.cluster: ## Test DocDB DatabaseCluster L2 (live DocumentDB cluster + insta go test -v -count 1 -timeout 45m ./... -run ^TestDocdbCluster$ .PHONY: docdb.cluster +elasticache.serverless-cache: ## Test ElastiCache ServerlessCache L2 (live Valkey 8 + IamUser + UserGroup) + go test -v -count 1 -timeout 30m ./... -run ^TestElasticacheServerlessCache$ +.PHONY: elasticache.serverless-cache + 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/elasticache.serverless-cache.ts b/integ/aws/storage/apps/elasticache.serverless-cache.ts new file mode 100644 index 00000000..c1ceab1b --- /dev/null +++ b/integ/aws/storage/apps/elasticache.serverless-cache.ts @@ -0,0 +1,95 @@ +// Live test for the storage.elasticache ServerlessCache L2 (alpha port): +// mirrors upstream integ.serverless-cache.ts -- a real Valkey 8 serverless +// cache with an IamUser + UserGroup, KMS key, security group, backup settings +// and cache usage limits. Validates the full aws_elasticache_serverless_cache +// mapping plus user/user-group wiring against live AWS. +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/integ.serverless-cache.ts +import { App, LocalBackend, TerraformOutput } from "cdktn"; +import { aws, Size } 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 ?? "elasticache.serverless-cache"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "g00000000-0000", + 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 key = new aws.encryption.Key(stack, "Key", {}); +const securityGroup = new aws.compute.SecurityGroup(stack, "SecurityGroup", { + vpc, +}); + +const user = new aws.storage.elasticache.IamUser(stack, "User", { + userId: "cacheuser", + accessControl: aws.storage.elasticache.AccessControl.fromAccessString( + "on ~* +@all", + ), +}); +const userGroup = new aws.storage.elasticache.UserGroup(stack, "UserGroup", { + users: [user], + userGroupName: "usergroup", +}); + +const cache = new aws.storage.elasticache.ServerlessCache(stack, "Cache", { + description: "Serverless cache", + vpc, + engine: aws.storage.elasticache.CacheEngine.VALKEY_8, + serverlessCacheName: "serverlesscache", + kmsKey: key, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, + securityGroups: [securityGroup], + userGroup, + backup: { + backupRetentionLimit: 2, + backupNameBeforeDeletion: "last-snapshot-name", + }, + cacheUsageLimits: { + dataStorageMinimumSize: Size.gibibytes(1), + dataStorageMaximumSize: Size.gibibytes(1), + requestRateLimitMinimum: 1_000, + requestRateLimitMaximum: 2_000, + }, +}); + +const clientSG = new aws.compute.SecurityGroup(stack, "ClientSG", { vpc }); +clientSG.connections.allowToDefaultPort(cache); + +new TerraformOutput(stack, "cache_name", { + value: cache.serverlessCacheName, + staticId: true, +}); +new TerraformOutput(stack, "user_id", { + value: user.userId, + staticId: true, +}); +new TerraformOutput(stack, "user_group_id", { + value: userGroup.userGroupName, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/elasticache_serverless_cache_test.go b/integ/aws/storage/elasticache_serverless_cache_test.go new file mode 100644 index 00000000..fc044b99 --- /dev/null +++ b/integ/aws/storage/elasticache_serverless_cache_test.go @@ -0,0 +1,78 @@ +package test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/elasticache" + "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/elasticache.serverless-cache.ts integration test: a real +// Valkey 8 serverless cache with IamUser + UserGroup through the +// storage.elasticache alpha port. Mirrors upstream's integ assertions +// (describeServerlessCaches engine/version/status) plus user/user-group +// read-backs and the post-apply drift oracle. +func TestElasticacheServerlessCache(t *testing.T) { + runStorageIntegrationTest(t, "elasticache.serverless-cache", "us-east-1", validateElasticacheServerlessCache) +} + +func validateElasticacheServerlessCache(t *testing.T, tfWorkingDir string, awsRegion string) { + terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir) + outputs := terraform.OutputAll(t, terraformOptions) + + cacheName := outputs["cache_name"].(string) + userID := outputs["user_id"].(string) + userGroupID := outputs["user_group_id"].(string) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion)) + require.NoError(t, err) + client := elasticache.NewFromConfig(cfg) + + // --- 1. Serverless cache read-back (upstream integ asserts the same fields). --- + sc, err := client.DescribeServerlessCaches(ctx, &elasticache.DescribeServerlessCachesInput{ + ServerlessCacheName: &cacheName, + }) + require.NoError(t, err) + require.Len(t, sc.ServerlessCaches, 1) + c := sc.ServerlessCaches[0] + require.Equal(t, "available", *c.Status) + require.Equal(t, "valkey", *c.Engine) + require.Equal(t, "8", *c.MajorEngineVersion) + require.NotNil(t, c.CacheUsageLimits) + require.Equal(t, int32(1), *c.CacheUsageLimits.DataStorage.Minimum) + require.Equal(t, int32(1), *c.CacheUsageLimits.DataStorage.Maximum) + require.Equal(t, int32(1000), *c.CacheUsageLimits.ECPUPerSecond.Minimum) + require.Equal(t, int32(2000), *c.CacheUsageLimits.ECPUPerSecond.Maximum) + require.NotNil(t, c.Endpoint) + t.Logf("elasticache-serverless: %s available (valkey %s at %s:%d, limits applied)", + cacheName, *c.MajorEngineVersion, *c.Endpoint.Address, *c.Endpoint.Port) + + // --- 2. IAM user read-back. --- + du, err := client.DescribeUsers(ctx, &elasticache.DescribeUsersInput{ + UserId: &userID, + }) + require.NoError(t, err) + require.Len(t, du.Users, 1) + require.Equal(t, "active", *du.Users[0].Status) + require.Equal(t, "iam", string(du.Users[0].Authentication.Type)) + t.Logf("elasticache-serverless: user %s active (iam auth)", userID) + + // --- 3. User group contains the user and is attached. --- + dg, err := client.DescribeUserGroups(ctx, &elasticache.DescribeUserGroupsInput{ + UserGroupId: &userGroupID, + }) + require.NoError(t, err) + require.Len(t, dg.UserGroups, 1) + require.Contains(t, dg.UserGroups[0].UserIds, userID) + t.Logf("elasticache-serverless: user group %s contains %s", userGroupID, userID) + + // --- 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/elasticache/common.ts b/src/aws/storage/elasticache/common.ts new file mode 100644 index 00000000..2964bc24 --- /dev/null +++ b/src/aws/storage/elasticache/common.ts @@ -0,0 +1,47 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/common.ts + +/** + * Engine type for ElastiCache users and user groups. + * + * Use the named static members for the engines currently supported by ElastiCache + * user/user-group resources. To target an engine not yet represented by a named + * instance, use `UserEngine.of(engineType)`. + */ +export class UserEngine { + /** + * Valkey engine. + */ + public static readonly VALKEY = UserEngine.of("valkey"); + + /** + * Redis engine. + */ + public static readonly REDIS = UserEngine.of("redis"); + + /** + * Create a new `UserEngine` with an arbitrary engine type. + * + * @param engineType the engine type (for example, `'valkey'` or `'redis'`) + */ + public static of(engineType: string): UserEngine { + return new UserEngine(engineType); + } + + /** + * The engine type, for example `'valkey'` or `'redis'`. + * Maps directly to the `Engine` property of `AWS::ElastiCache::User` and + * `AWS::ElastiCache::UserGroup`. + */ + public readonly engineType: string; + + private constructor(engineType: string) { + this.engineType = engineType; + } + + /** + * Returns the engine type as a string (for example, `'valkey'`). + */ + public toString(): string { + return this.engineType; + } +} diff --git a/src/aws/storage/elasticache/elasticache-grants.generated.ts b/src/aws/storage/elasticache/elasticache-grants.generated.ts new file mode 100644 index 00000000..7c2fa948 --- /dev/null +++ b/src/aws/storage/elasticache/elasticache-grants.generated.ts @@ -0,0 +1,102 @@ +/* eslint-disable prettier/prettier,max-len */ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/elasticache-grants.generated.ts + +import * as iam from "../../iam"; + +/** + * The minimal shape `ServerlessCacheGrants` needs from a serverless cache resource. + * + * TERRACONSTRUCTS DEVIATION: upstream types the grants-collection resource as + * `elasticache.IServerlessCacheRef` (a CloudFormation cross-stack "Reference" marker interface + * generated from the CFN resource spec, imported from `aws-cdk-lib/interfaces`). TerraConstructs + * has no equivalent generated-reference layer (identical omission pattern to the rds/docdb `*Ref` + * interfaces, e.g. `IDatabaseCluster.dbClusterRef` in `../rds/cluster-ref.ts`). Rather than import + * this module's own construct-facing `IServerlessCache` (`./serverless-cache-base.ts`, a sibling + * file in this port) and risk a forward/circular module dependency from this "generated" file, the + * minimal structural shape actually used below (`serverlessCacheArn`) is declared locally. + * `IServerlessCache` — and any class that implements it, such as `ServerlessCacheBase` — satisfies + * this interface automatically via TypeScript structural typing, exactly as `elasticache. + * IServerlessCacheRef` does for the upstream CFN L1 — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/serverless-cache-base.ts#L119-L124 + */ +export interface IServerlessCacheRef { + /** + * The ARN of the serverless cache the grant actions apply to. + */ + readonly serverlessCacheArn: string; +} + +/** + * Properties for ServerlessCacheGrants + */ +export interface ServerlessCacheGrantsProps { + /** + * The resource on which actions will be allowed + */ + readonly resource: IServerlessCacheRef; +} + +/** + * Options for a custom-actions grant. + * + * TERRACONSTRUCTS DEVIATION: upstream types the `options` parameter of `actions()` as + * `cdk.PermissionsOptions` (`aws-cdk-lib/core`, not ported here). Only the `resourceArns` override + * used by this file's own `connect()` call site is reproduced. + */ +export interface ServerlessCacheGrantsPermissionsOptions { + /** + * The resource ARNs to grant the actions on + * + * @default - the ARN of the serverless cache this grants object was created for + */ + readonly resourceArns?: string[]; +} + +/** + * Collection of grant methods for a IServerlessCacheRef + */ +export class ServerlessCacheGrants { + /** + * Creates grants for ServerlessCacheGrants + */ + public static fromServerlessCache( + resource: IServerlessCacheRef, + ): ServerlessCacheGrants { + return new ServerlessCacheGrants({ + resource: resource, + }); + } + + protected readonly resource: IServerlessCacheRef; + + private constructor(props: ServerlessCacheGrantsProps) { + this.resource = props.resource; + } + + /** + * Grant the given identity custom permissions + */ + public actions( + grantee: iam.IGrantable, + actions: Array, + options: ServerlessCacheGrantsPermissionsOptions = {}, + ): iam.Grant { + const result = iam.Grant.addToPrincipal({ + actions: actions, + grantee: grantee, + resourceArns: options.resourceArns ?? [this.resource.serverlessCacheArn], + }); + return result; + } + + /** + * Grant connect permissions to the cache + */ + public connect(grantee: iam.IGrantable): iam.Grant { + const actions = [ + "elasticache:Connect", + "elasticache:DescribeServerlessCaches", + ]; + return this.actions(grantee, actions, {}); + } +} diff --git a/src/aws/storage/elasticache/iam-user.ts b/src/aws/storage/elasticache/iam-user.ts new file mode 100644 index 00000000..4058f211 --- /dev/null +++ b/src/aws/storage/elasticache/iam-user.ts @@ -0,0 +1,169 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/iam-user.ts + +import { elasticacheUser } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import { UserEngine } from "./common"; +import type { UserBaseProps } from "./user-base"; +import { UserBase } from "./user-base"; +import { ValidationError } from "../../../errors"; +import * as iam from "../../iam"; + +const ELASTICACHE_IAMUSER_SYMBOL = Symbol.for( + "@aws-cdk/aws-elasticache.IamUser", +); + +/** + * Properties for defining an ElastiCache user with IAM authentication. + */ +export interface IamUserProps extends UserBaseProps { + /** + * The name of the user. + * + * @default - Same as userId. + */ + readonly userName?: string; +} + +/** + * Define an ElastiCache user with IAM authentication. + * + * @resource aws_elasticache_user + */ +export class IamUser extends UserBase { + /** + * Uniquely identifies this class. + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.elasticache.IamUser"; + + /** + * Return whether the given object is an `IamUser`. + */ + public static isIamUser(x: any): x is IamUser { + return ( + x !== null && typeof x === "object" && ELASTICACHE_IAMUSER_SYMBOL in x + ); + } + + /** + * The engine for the user. + */ + public readonly engine?: UserEngine; + /** + * The user's ID. + * + * TERRACONSTRUCTS DEVIATION: lowercased at synth — ElastiCache stores `UserId` as a lowercase + * string server-side (verified against the `CreateUser` API reference: "The ID of the user. This + * value is stored as a lowercase string."), and emitting the original casing would report a + * perpetual Terraform diff. Mirrors the identical `subnetGroupName` lowercasing convention in + * `../rds/subnet-group.ts` and the sibling `userId` lowercasing on `PasswordUser`/`NoPasswordUser`. + * + * @attribute + */ + public readonly userId: string; + /** + * The user's name. + * For IAM authentication userName must be equal to userId. + * + * @attribute + */ + public readonly userName?: string; + /** + * The access string that defines the user's permissions. + */ + public readonly accessString: string; + /** + * The user's ARN. + * + * @attribute + */ + public readonly userArn: string; + + // TODO: omitted — upstream also exposes `userStatus` (`'active' | 'modifying' | 'deleting'`), + // read off the generated CFN L1's `attrStatus`. See the identical omission and permalink on + // `NoPasswordUser` in `./no-password-user.ts` — the `aws_elasticache_user` Terraform resource has + // no equivalent attribute to honestly populate it from. + + /** + * The underlying `aws_elasticache_user` L1. + */ + public readonly resource: elasticacheUser.ElasticacheUser; + + constructor(scope: Construct, id: string, props: IamUserProps) { + super(scope, id, props); + + this.engine = props.engine ?? UserEngine.VALKEY; + this.userId = Token.isUnresolved(props.userId) + ? props.userId + : props.userId.toLowerCase(); + // TERRACONSTRUCTS DEVIATION: kept byte-close to upstream — `userName` defaults from + // `props.userId` in its ORIGINAL casing, NOT the lowercased `this.userId` above. On `IamUser` + // this matters: a mixed-case `userId` supplied without an explicit lowercase `userName` fails + // the `userName === userId` equality check below, because `this.userId` has been lowercased + // while `this.userName` has not — callers must pass an already-lowercase `userId` (or a + // matching lowercase `userName`). When `userId` is an unresolved Token, no case transform is + // applied to it, so the default `userName` (also `props.userId`) equals `this.userId` and the + // check below passes. + this.userName = props.userName ?? props.userId; + this.accessString = props.accessControl.accessString; + + if (this.userName !== this.userId) { + throw new ValidationError( + `For IAM authentication, userName must be equal to userId. \`userId\` is lowercased to '${this.userId}' (ElastiCache stores UserId as a lowercase string), so supply an already-lowercase \`userId\`, or pass a matching lowercase \`userName\`.`, + this, + ); + } + + this.resource = new elasticacheUser.ElasticacheUser(this, "Resource", { + engine: this.engine.engineType, + userId: this.userId, + userName: this.userName, + accessString: this.accessString, + authenticationMode: { + type: "iam", + }, + noPasswordRequired: false, + }); + + this.userArn = this.resource.arn; + + Object.defineProperty(this, ELASTICACHE_IAMUSER_SYMBOL, { value: true }); + } + + /** + * Grant connect permissions to the given IAM identity. + * + * @param grantee The IAM identity to grant permissions to. + */ + public grantConnect(grantee: iam.IGrantable): iam.Grant { + return this.grant(grantee, "elasticache:Connect"); + } + + /** + * Grant the given identity custom permissions. + * + * @param grantee The IAM identity to grant permissions to. + * @param actions The actions to grant. + */ + public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [this.userArn], + }); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — repo-wide construct-output convention (see + * `DatabaseInstanceBase.outputs` in `../docdb/instance.ts`) for use with `registerOutputs`/the + * Grid. + */ + public get outputs(): Record { + return { + userId: this.userId, + arn: this.userArn, + userName: this.userName, + }; + } +} diff --git a/src/aws/storage/elasticache/index.ts b/src/aws/storage/elasticache/index.ts new file mode 100644 index 00000000..48ce595e --- /dev/null +++ b/src/aws/storage/elasticache/index.ts @@ -0,0 +1,17 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/index.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-elasticache-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 "./common"; +export * from "./user-group"; +export * from "./user-base"; +export * from "./iam-user"; +export * from "./password-user"; +export * from "./no-password-user"; +export * from "./serverless-cache-base"; +export * from "./serverless-cache"; + +// generated grants collection — mirrors src/aws/notify/sqs-grants.generated.ts wiring +export * from "./elasticache-grants.generated"; diff --git a/src/aws/storage/elasticache/no-password-user.ts b/src/aws/storage/elasticache/no-password-user.ts new file mode 100644 index 00000000..4a064906 --- /dev/null +++ b/src/aws/storage/elasticache/no-password-user.ts @@ -0,0 +1,153 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/no-password-user.ts + +import { elasticacheUser } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import { UserEngine } from "./common"; +import type { UserBaseProps } from "./user-base"; +import { UserBase } from "./user-base"; +import { ValidationError } from "../../../errors"; + +/** + * List of engines that support no-password authentication. + */ +const SUPPORTED_NO_PASSWORD_ENGINES = [UserEngine.REDIS]; + +const ELASTICACHE_NOPASSWORDUSER_SYMBOL = Symbol.for( + "@aws-cdk/aws-elasticache.NoPasswordUser", +); + +/** + * Properties for defining an ElastiCache user with no password authentication. + */ +export interface NoPasswordUserProps extends UserBaseProps { + /** + * The name of the user. + * + * @default - Same as userId. + */ + readonly userName?: string; +} + +/** + * Define an ElastiCache user with no password authentication. + * + * @resource aws_elasticache_user + */ +export class NoPasswordUser extends UserBase { + /** + * Uniquely identifies this class. + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.elasticache.NoPasswordUser"; + + /** + * Return whether the given object is a `NoPasswordUser`. + */ + public static isNoPasswordUser(x: any): x is NoPasswordUser { + return ( + x !== null && + typeof x === "object" && + ELASTICACHE_NOPASSWORDUSER_SYMBOL in x + ); + } + + /** + * The engine for the user. + */ + public readonly engine?: UserEngine; + /** + * The user's ID. + * + * TERRACONSTRUCTS DEVIATION: lowercased at synth — ElastiCache stores `UserId` as a lowercase + * string server-side (verified against the `CreateUser` API reference: "The ID of the user. This + * value is stored as a lowercase string."), and emitting the original casing would report a + * perpetual Terraform diff. Mirrors the identical `dbClusterName`/`clusterIdentifier` lowercasing + * convention in `../docdb/cluster.ts`. + * + * @attribute + */ + public readonly userId: string; + /** + * The user's name. + * + * @attribute + */ + public readonly userName?: string; + /** + * The access string that defines the user's permissions. + */ + public readonly accessString: string; + /** + * The user's ARN. + * + * @attribute + */ + public readonly userArn: string; + + // TODO: omitted — upstream's `userStatus` (`CfnUser.attrStatus`, CloudFormation-computed + // `Status` attribute: 'active' | 'modifying' | 'deleting') has no Terraform-provider equivalent. + // The `aws_elasticache_user` resource does not expose a computed `status`/`user_status` attribute + // at all (verified against the full config shape in + // `node_modules/@cdktn/provider-aws/lib/elasticache-user/index.d.ts`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/no-password-user.ts#L76-L81 + // readonly userStatus: string; + + /** + * The underlying `aws_elasticache_user` L1. + */ + public readonly resource: elasticacheUser.ElasticacheUser; + + constructor(scope: Construct, id: string, props: NoPasswordUserProps) { + super(scope, id, props); + + this.engine = props.engine ?? UserEngine.REDIS; + this.userId = Token.isUnresolved(props.userId) + ? props.userId + : props.userId.toLowerCase(); + // TERRACONSTRUCTS DEVIATION: kept byte-close to upstream — defaults from `props.userId` (the + // original casing), NOT the lowercased `this.userId` above. + this.userName = props.userName ?? props.userId; + this.accessString = props.accessControl.accessString; + + if ( + !SUPPORTED_NO_PASSWORD_ENGINES.some( + (e) => e.engineType === this.engine!.engineType, + ) + ) { + throw new ValidationError( + `Engine '${this.engine}' does not support no-password authentication. Supported engines: ${SUPPORTED_NO_PASSWORD_ENGINES.join(", ")}.`, + this, + ); + } + + this.resource = new elasticacheUser.ElasticacheUser(this, "Resource", { + engine: this.engine.engineType, + userId: this.userId, + userName: this.userName, + accessString: this.accessString, + authenticationMode: { + type: "no-password-required", + }, + noPasswordRequired: true, + }); + + this.userArn = this.resource.arn; + + Object.defineProperty(this, ELASTICACHE_NOPASSWORDUSER_SYMBOL, { + value: true, + }); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `../rds/cluster.ts`) — bare, bound-per-construct `outputs` for + * use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + userId: this.userId, + arn: this.userArn, + }; + } +} diff --git a/src/aws/storage/elasticache/password-user.ts b/src/aws/storage/elasticache/password-user.ts new file mode 100644 index 00000000..129cb5cc --- /dev/null +++ b/src/aws/storage/elasticache/password-user.ts @@ -0,0 +1,154 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/password-user.ts + +import { elasticacheUser } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import { UserEngine } from "./common"; +import type { UserBaseProps } from "./user-base"; +import { UserBase } from "./user-base"; +import { ValidationError } from "../../../errors"; + +const ELASTICACHE_PASSWORDUSER_SYMBOL = Symbol.for( + "@aws-cdk/aws-elasticache.PasswordUser", +); + +// TERRACONSTRUCTS DEVIATION: upstream types password fields as `core.SecretValue` (a +// CloudFormation dynamic-reference wrapper), which is not ported in this repo (see the +// TERRACONSTRUCTS DEVIATION note on `SecretProps.secretObjectValue` in `../../encryption/secret.ts`, +// and the identical deviation on `../rds/props.ts`). `passwords` below is a plain `string[]` +// instead (each entry may itself be an unresolved Token). + +/** + * Properties for defining an ElastiCache user with password authentication. + */ +export interface PasswordUserProps extends UserBaseProps { + /** + * The name of the user. + * + * @default - Same as userId. + */ + readonly userName?: string; + /** + * The passwords for the user. + * Password authentication requires using 1-2 passwords. + */ + readonly passwords: string[]; +} + +/** + * Define an ElastiCache user with password authentication. + * + * @resource aws_elasticache_user + */ +export class PasswordUser extends UserBase { + /** + * Uniquely identifies this class. + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.elasticache.PasswordUser"; + + /** + * Return whether the given object is a `PasswordUser`. + */ + public static isPasswordUser(x: any): x is PasswordUser { + return ( + x !== null && + typeof x === "object" && + ELASTICACHE_PASSWORDUSER_SYMBOL in x + ); + } + + /** + * The engine for the user. + */ + public readonly engine?: UserEngine; + /** + * The user's ID. + * + * TERRACONSTRUCTS DEVIATION: lowercased at synth — ElastiCache stores `UserId` as a lowercase + * string server-side (verified against the `CreateUser` API reference: "The ID of the user. This + * value is stored as a lowercase string."), and emitting the original casing would report a + * perpetual Terraform diff. Mirrors the identical `subnetGroupName` lowercasing convention in + * `../rds/subnet-group.ts` and the sibling `userId` lowercasing on `IamUser`/`NoPasswordUser`. + * + * @attribute + */ + public readonly userId: string; + /** + * The user's name. + * + * @attribute + */ + public readonly userName?: string; + /** + * The access string that defines the user's permissions. + */ + public readonly accessString: string; + /** + * The user's ARN. + * + * @attribute + */ + public readonly userArn: string; + + // TODO: omitted — upstream also exposes `userStatus` (`'active' | 'modifying' | 'deleting'`), + // read off the generated CFN L1's `attrStatus`. See the identical omission and permalink on + // `NoPasswordUser` in `./no-password-user.ts` — the `aws_elasticache_user` Terraform resource has + // no equivalent attribute to honestly populate it from. + + /** + * The underlying `aws_elasticache_user` L1. + */ + public readonly resource: elasticacheUser.ElasticacheUser; + + constructor(scope: Construct, id: string, props: PasswordUserProps) { + super(scope, id, props); + + this.engine = props.engine ?? UserEngine.VALKEY; + this.userId = Token.isUnresolved(props.userId) + ? props.userId + : props.userId.toLowerCase(); + // TERRACONSTRUCTS DEVIATION: kept byte-close to upstream — defaults from `props.userId` (the + // original casing), NOT the lowercased `this.userId` above. + this.userName = props.userName ?? props.userId; + this.accessString = props.accessControl.accessString; + + if (props.passwords.length < 1 || props.passwords.length > 2) { + throw new ValidationError( + "Password authentication requires 1-2 passwords.", + this, + ); + } + + this.resource = new elasticacheUser.ElasticacheUser(this, "Resource", { + engine: this.engine.engineType, + userId: this.userId, + userName: this.userName, + accessString: this.accessString, + authenticationMode: { + type: "password", + passwords: props.passwords, + }, + noPasswordRequired: false, + }); + + this.userArn = this.resource.arn; + + Object.defineProperty(this, ELASTICACHE_PASSWORDUSER_SYMBOL, { + value: true, + }); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — repo-wide construct-output convention (see + * `DatabaseInstanceBase.outputs` in `../docdb/instance.ts`) for use with `registerOutputs`/the + * Grid. + */ + public get outputs(): Record { + return { + userId: this.userId, + arn: this.userArn, + userName: this.userName, + }; + } +} diff --git a/src/aws/storage/elasticache/serverless-cache-base.ts b/src/aws/storage/elasticache/serverless-cache-base.ts new file mode 100644 index 00000000..fcb061ce --- /dev/null +++ b/src/aws/storage/elasticache/serverless-cache-base.ts @@ -0,0 +1,429 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/serverless-cache-base.ts + +import { ServerlessCacheGrants } from "./elasticache-grants.generated"; +import type { IUserGroup } from "./user-group"; +import { Duration } from "../../../duration"; +import { AwsConstructBase, 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"; + +/** + * Supported cache engines together with available versions. + * + * Named instances cover the versions currently available on ElastiCache Serverless. + * To target a version that is not yet represented by a named instance, use + * `CacheEngine.of(engineType, majorEngineVersion)`. + * + * @see https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/engine-versions.html + */ +export class CacheEngine { + /** + * Valkey engine, latest major version available, minor version is selected automatically. + */ + public static readonly VALKEY_LATEST = CacheEngine.of("valkey"); + + /** + * Valkey engine, major version 7, minor version is selected automatically. + */ + public static readonly VALKEY_7 = CacheEngine.of("valkey", "7"); + + /** + * Valkey engine, major version 8, minor version is selected automatically. + */ + public static readonly VALKEY_8 = CacheEngine.of("valkey", "8"); + + /** + * Valkey engine, major version 9, minor version is selected automatically. + */ + public static readonly VALKEY_9 = CacheEngine.of("valkey", "9"); + + /** + * Redis engine, latest major version available, minor version is selected automatically. + */ + public static readonly REDIS_LATEST = CacheEngine.of("redis"); + + /** + * Redis engine, major version 7, minor version is selected automatically. + */ + public static readonly REDIS_7 = CacheEngine.of("redis", "7"); + + /** + * Memcached engine, latest major version available, minor version is selected automatically. + */ + public static readonly MEMCACHED_LATEST = CacheEngine.of("memcached"); + + /** + * Memcached engine, minor version 1.6, patch version is selected automatically. + */ + public static readonly MEMCACHED_1_6 = CacheEngine.of("memcached", "1.6"); + + /** + * Create a new `CacheEngine` with an arbitrary engine type and major version. + * + * Use this for engine/version combinations that are not yet represented by a + * named static member. + * + * @param engineType the engine type (for example, `'valkey'`, `'redis'`, or `'memcached'`) + * @param majorEngineVersion the major engine version (for example, `'9'`). When omitted, + * the latest major version available is selected by the service. + */ + public static of( + engineType: string, + majorEngineVersion?: string, + ): CacheEngine { + return new CacheEngine(engineType, majorEngineVersion); + } + + /** + * The engine type, for example `'valkey'`, `'redis'`, or `'memcached'`. + * Maps directly to the `Engine` property of `AWS::ElastiCache::ServerlessCache`. + */ + public readonly engineType: string; + + /** + * The major engine version, for example `'9'` or `'1.6'`. + * Maps directly to the `MajorEngineVersion` property of + * `AWS::ElastiCache::ServerlessCache`. When `undefined`, the service selects + * the latest major version automatically. + */ + public readonly majorEngineVersion?: string; + + private constructor(engineType: string, majorEngineVersion?: string) { + this.engineType = engineType; + this.majorEngineVersion = majorEngineVersion; + } + + /** + * Returns a string representation of this cache engine, for logging and + * error messages. The format is `engineType_majorEngineVersion` when a + * major version is set, or just `engineType` otherwise (for example, + * `'valkey_8'`, `'memcached_1.6'`, `'redis'`). + */ + public toString(): string { + return this.majorEngineVersion + ? `${this.engineType}_${this.majorEngineVersion}` + : this.engineType; + } +} + +/** + * Represents a Serverless ElastiCache cache + * + * TODO: omitted — upstream also extends `aws_elasticache.IServerlessCacheRef`, a CloudFormation + * cross-stack "Reference" marker interface generated from the CFN resource spec. TerraConstructs + * has no equivalent generated-reference layer (identical omission pattern to the rds/docdb `*Ref` + * interfaces, e.g. `IDatabaseCluster.dbClusterRef` in `../rds/cluster-ref.ts`) — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/serverless-cache-base.ts#L110 + */ +export interface IServerlessCache extends IAwsConstruct, ec2.IConnectable { + /** + * The cache engine used by this cache + */ + readonly engine?: CacheEngine; + /** + * The name of the serverless cache + * + * @attribute + */ + readonly serverlessCacheName: string; + /** + * The ARNs of backups restored in the cache + */ + readonly backupArnsToRestore?: string[]; + /** + * The KMS key used for encryption + */ + readonly kmsKey?: encryption.IKey; + /** + * The VPC this cache is deployed in + */ + readonly vpc?: ec2.IVpc; + /** + * The subnets this cache is deployed in + */ + readonly subnets?: ec2.ISubnet[]; + /** + * The security groups associated with this cache + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + /** + * The user group associated with this cache + */ + readonly userGroup?: IUserGroup; + /** + * The ARN of the serverless cache + * + * @attribute + */ + readonly serverlessCacheArn: string; + + /** + * Grant connect permissions to the cache + */ + grantConnect(grantee: iam.IGrantable): iam.Grant; + /** + * Grant the given identity custom permissions + */ + grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant; + + /** + * Return the given named metric for this cache + */ + metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric; + /** + * Metric for cache hit count + */ + metricCacheHitCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for cache miss count + */ + metricCacheMissCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for cache hit rate + */ + metricCacheHitRate(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for data stored in the cache + */ + metricDataStored(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for ECPUs consumed + */ + metricProcessingUnitsConsumed( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric; + /** + * Metric for network bytes in + */ + metricNetworkBytesIn(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for network bytes out + */ + metricNetworkBytesOut(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for active connections + */ + metricActiveConnections(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for write request latency + */ + metricWriteRequestLatency( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric; + /** + * Metric for read request latency + */ + metricReadRequestLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; +} + +/** + * Base class for ServerlessCache constructs + */ +export abstract class ServerlessCacheBase + extends AwsConstructBase + implements IServerlessCache +{ + public abstract readonly engine?: CacheEngine; + public abstract readonly serverlessCacheName: string; + public abstract readonly backupArnsToRestore?: string[]; + public abstract readonly kmsKey?: encryption.IKey; + public abstract readonly vpc?: ec2.IVpc; + public abstract readonly subnets?: ec2.ISubnet[]; + public abstract readonly securityGroups?: ec2.ISecurityGroup[]; + public abstract readonly userGroup?: IUserGroup; + + public abstract readonly serverlessCacheArn: string; + + /** + * Access to network connections. + */ + public abstract readonly connections: ec2.Connections; + + /** + * Collection of grant methods for this cache + */ + public readonly grants = ServerlessCacheGrants.fromServerlessCache(this); + + // TODO: omitted — upstream's `serverlessCacheRef` getter returns `aws_elasticache.ServerlessCacheReference` + // from the CFN-generated reference layer, stripped along with `IServerlessCacheRef` (see the note + // on `IServerlessCache` above) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/serverless-cache-base.ts#L232-L237 + // public get serverlessCacheRef(): ServerlessCacheReference { + // return { + // serverlessCacheName: this.serverlessCacheName, + // serverlessCacheArn: this.serverlessCacheArn, + // }; + // } + + /** + * Grant connect permissions to the cache + * [disable-awslint:no-grants] + * + * @param grantee The principal to grant permissions to + */ + public grantConnect(grantee: iam.IGrantable): iam.Grant { + return this.grants.connect(grantee); + } + /** + * Grant the given identity custom permissions + * [disable-awslint:no-grants] + * + * @param grantee The principal to grant permissions to + * @param actions The actions to grant + */ + public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [this.serverlessCacheArn], + }); + } + + /** + * Return the given named metric for this cache + * + * @param metricName The name of the metric + * @param props Additional properties which will be merged with the default metric + * @default Average over 5 minutes + */ + public metric( + metricName: string, + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: "AWS/ElastiCache", + metricName, + dimensionsMap: { + ServerlessCacheName: this.serverlessCacheName, + }, + period: Duration.minutes(5), + statistic: "Average", + ...props, + }).attachTo(this); + } + /** + * Metric for cache hit count + * + * @param props Additional properties which will be merged with the default metric + * @default Sum over 5 minutes + */ + public metricCacheHitCount( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("CacheHits", { statistic: "Sum", ...props }); + } + /** + * Metric for cache miss count + * + * @param props Additional properties which will be merged with the default metric + * @default Sum over 5 minutes + */ + public metricCacheMissCount( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("CacheMisses", { statistic: "Sum", ...props }); + } + /** + * Metric for cache hit rate + * + * @param props Additional properties which will be merged with the default metric + * @default Average over 5 minutes + */ + public metricCacheHitRate( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("CacheHitRate", props); + } + /** + * Metric for data stored in the cache + * + * @param props Additional properties which will be merged with the default metric + * @default Maximum over 5 minutes + */ + public metricDataStored(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric("BytesUsedForCache", { statistic: "Maximum", ...props }); + } + /** + * Metric for ECPUs consumed + * + * @param props Additional properties which will be merged with the default metric + * @default Average over 5 minutes + */ + public metricProcessingUnitsConsumed( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("ElastiCacheProcessingUnits", props); + } + /** + * Metric for network bytes in + * + * @param props Additional properties which will be merged with the default metric + * @default Sum over 5 minutes + */ + public metricNetworkBytesIn( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("NetworkBytesIn", { statistic: "Sum", ...props }); + } + /** + * Metric for network bytes out + * + * @param props Additional properties which will be merged with the default metric + * @default Sum over 5 minutes + */ + public metricNetworkBytesOut( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("NetworkBytesOut", { statistic: "Sum", ...props }); + } + /** + * Metric for active connections + * + * @param props Additional properties which will be merged with the default metric + * @default Maximum over 5 minutes + */ + public metricActiveConnections( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("CurrConnections", { statistic: "Maximum", ...props }); + } + /** + * Metric for write request latency + * + * @param props Additional properties which will be merged with the default metric + * @default Average over 5 minutes + */ + public metricWriteRequestLatency( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("SuccessfulWriteRequestLatency", props); + } + /** + * Metric for read request latency + * + * @param props Additional properties which will be merged with the default metric + * @default Average over 5 minutes + */ + public metricReadRequestLatency( + props?: cloudwatch.MetricOptions, + ): cloudwatch.Metric { + return this.metric("SuccessfulReadRequestLatency", props); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `../rds/cluster.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + name: this.serverlessCacheName, + arn: this.serverlessCacheArn, + }; + } +} diff --git a/src/aws/storage/elasticache/serverless-cache.ts b/src/aws/storage/elasticache/serverless-cache.ts new file mode 100644 index 00000000..73acb377 --- /dev/null +++ b/src/aws/storage/elasticache/serverless-cache.ts @@ -0,0 +1,905 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/serverless-cache.ts + +import { elasticacheServerlessCache } from "@cdktn/provider-aws"; +import { Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import type { IServerlessCache } from "./serverless-cache-base"; +import { ServerlessCacheBase, CacheEngine } from "./serverless-cache-base"; +import type { IUserGroup } from "./user-group"; +import { ValidationError } from "../../../errors"; +import { Size } from "../../../size"; +import { ArnFormat } from "../../arn"; +import { AwsStack } from "../../aws-stack"; +import * as ec2 from "../../compute"; +import type * as encryption from "../../encryption"; +import * as events from "../../notify"; + +const ELASTICACHE_SERVERLESSCACHE_SYMBOL = Symbol.for( + "@aws-cdk/aws-elasticache.ServerlessCache", +); + +/** + * Unit types for data storage usage limits + */ +enum DataStorageUnit { + /** + * Gigabytes + */ + GIGABYTES = "GB", +} + +/** + * Minimum data storage size in GB for ServerlessCache + */ +const DATA_STORAGE_MIN_GB = 1; +/** + * Maximum data storage size in GB for ServerlessCache + */ +const DATA_STORAGE_MAX_GB = 5000; +/** + * Minimum request rate limit in ECPUs per second for ServerlessCache + */ +const REQUEST_RATE_MIN_ECPU = 1000; +/** + * Maximum request rate limit in ECPUs per second for ServerlessCache + */ +const REQUEST_RATE_MAX_ECPU = 15000000; + +/** + * Usage limits configuration for ServerlessCache + */ +export interface CacheUsageLimitsProperty { + /** + * Minimum data storage size (1 GB) + * + * @default - No minimum limit + */ + readonly dataStorageMinimumSize?: Size; + /** + * Maximum data storage size (5000 GB) + * + * @default - No maximum limit + */ + readonly dataStorageMaximumSize?: Size; + /** + * Minimum request rate limit (1000 ECPUs per second) + * + * @default - No minimum limit + */ + readonly requestRateLimitMinimum?: number; + /** + * Maximum request rate limit (15000000 ECPUs per second) + * + * @default - No maximum limit + */ + readonly requestRateLimitMaximum?: number; +} + +/** + * Backup configuration for ServerlessCache + */ +export interface BackupSettings { + /** + * Automated daily backup UTC time + * + * @default - No automated backups + */ + readonly backupTime?: events.Schedule; + /** + * Number of days to retain backups (1-35) + * + * @default - Backups are not retained + */ + readonly backupRetentionLimit?: number; + + // TODO: omitted — upstream's `backupNameBeforeDeletion` maps to `CfnServerlessCache.finalSnapshotName` + // (the name for a final backup CloudFormation takes before deleting the cache). The Terraform + // `aws_elasticache_serverless_cache` resource has NO `final_snapshot_name`/equivalent argument at + // all (verified against the full config shape in + // `node_modules/@cdktn/provider-aws/lib/elasticache-serverless-cache/index.d.ts`) — the AWS + // provider does not support requesting a final snapshot on delete for this resource type — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/serverless-cache.ts#L92-L96 + // readonly backupNameBeforeDeletion?: string; + + /** + * ARNs of backups from which to restore data into the new cache + * + * @default - Create a new cache with no existing data + */ + readonly backupArnsToRestore?: string[]; +} + +/** + * Properties for defining a ServerlessCache + * + * TERRACONSTRUCTS DEVIATION: does NOT extend `AwsConstructProps` (account/region/environmentFromArn) + * — upstream's `ServerlessCacheProps` does not either, and unlike the rds/docdb cluster props in + * this repo, ElastiCache serverless caches are always created in the stack's own + * account/region (no cross-environment import-by-attributes analog on the *creation* path). + */ +export interface ServerlessCacheProps { + /** + * The cache engine combined with the version + * Enum options: VALKEY_DEFAULT, VALKEY_7, VALKEY_8, REDIS_DEFAULT, MEMCACHED_DEFAULT + * The default options bring the latest versions available. + * + * @default when not provided, the default engine would be Valkey, latest version available (VALKEY_DEFAULT) + */ + readonly engine?: CacheEngine; + /** + * Name for the serverless cache + * + * TERRACONSTRUCTS DEVIATION: when unspecified, defaults to a gridUUID-scoped + * `uniqueResourceName` (repo-wide invariant, mirrors `DatabaseCluster` in `../docdb/cluster.ts`) + * rather than upstream's CloudFormation-generated-from-logical-id default. Whatever value is used + * (supplied or generated) is lowercased at synth: ElastiCache stores `ServerlessCacheName` as a + * lowercase string server-side (verified against the `CreateServerlessCache` API reference: "This + * parameter is stored as a lowercase string."), and emitting the original casing would report a + * perpetual Terraform diff. + * + * @default automatically generated name by Resource + */ + readonly serverlessCacheName?: string; + /** + * A description for the cache + * + * @default - No description + */ + readonly description?: string; + /** + * Usage limits for the cache + * + * @default - No usage limits + */ + readonly cacheUsageLimits?: CacheUsageLimitsProperty; + /** + * Backup configuration + * + * @default - No backups configured + */ + readonly backup?: BackupSettings; + /** + * KMS key for encryption + * + * @default - Service managed encryption (AWS owned KMS key) + */ + readonly kmsKey?: encryption.IKey; + /** + * The VPC to place the cache in + */ + readonly vpc: ec2.IVpc; + /** + * Which subnets to place the cache in + * + * @default - Private subnets with egress + */ + readonly vpcSubnets?: ec2.SubnetSelection; + /** + * Security groups for the cache + * + * @default - A new security group is created + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + /** + * User group for access control + * + * @default - No user group + */ + readonly userGroup?: IUserGroup; +} + +/** + * Attributes that can be specified when importing a ServerlessCache + */ +export interface ServerlessCacheAttributes { + /** + * The cache engine used by this cache + * + * @default - engine type is unknown + */ + readonly engine?: CacheEngine; + /** + * The name of the serverless cache + * + * One of `serverlessCacheName` or `serverlessCacheArn` is required. + * + * @default - derived from serverlessCacheArn + */ + readonly serverlessCacheName?: string; + /** + * The ARN of the serverless cache + * + * One of `serverlessCacheName` or `serverlessCacheArn` is required. + * + * @default - derived from serverlessCacheName + */ + readonly serverlessCacheArn?: string; + /** + * The ARNs of backups restored in the cache + * + * @default - backups are unknown + */ + readonly backupArnsToRestore?: string[]; + /** + * The KMS key used for encryption + * + * @default - encryption key is unknown + */ + readonly kmsKey?: encryption.IKey; + /** + * The VPC this cache is deployed in + * + * @default - VPC is unknown + */ + readonly vpc?: ec2.IVpc; + /** + * The subnets this cache is deployed in + * + * @default - subnets are unknown + */ + readonly subnets?: ec2.ISubnet[]; + /** + * The security groups associated with this cache + * + * @default - security groups are unknown + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + /** + * The user group associated with this cache + * + * @default - user group is unknown + */ + readonly userGroup?: IUserGroup; +} + +/** + * A serverless ElastiCache cache + * + * @resource aws_elasticache_serverless_cache + */ +export class ServerlessCache extends ServerlessCacheBase { + /** + * Uniquely identifies this class. + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.elasticache.ServerlessCache"; + + /** + * Return whether the given object is a `ServerlessCache` + */ + public static isServerlessCache(x: any): x is ServerlessCache { + return ( + x !== null && + typeof x === "object" && + ELASTICACHE_SERVERLESSCACHE_SYMBOL in x + ); + } + + /** + * Import an existing serverless cache by name + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param serverlessCacheName The name of the existing serverless cache + */ + public static fromServerlessCacheName( + scope: Construct, + id: string, + serverlessCacheName: string, + ): IServerlessCache { + return ServerlessCache.fromServerlessCacheAttributes(scope, id, { + serverlessCacheName, + }); + } + + /** + * Import an existing serverless cache by ARN + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param serverlessCacheArn The ARN of the existing serverless cache + */ + public static fromServerlessCacheArn( + scope: Construct, + id: string, + serverlessCacheArn: string, + ): IServerlessCache { + return ServerlessCache.fromServerlessCacheAttributes(scope, id, { + serverlessCacheArn, + }); + } + + /** + * Import an existing serverless cache using attributes + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param attrs A `ServerlessCacheAttributes` object + */ + public static fromServerlessCacheAttributes( + scope: Construct, + id: string, + attrs: ServerlessCacheAttributes, + ): IServerlessCache { + let name: string; + let arn: string; + const stack = AwsStack.ofAwsConstruct(scope); + + if (attrs.serverlessCacheArn && attrs.serverlessCacheName) { + throw new ValidationError( + "Only one of serverlessCacheArn or serverlessCacheName can be provided.", + scope, + ); + } + + if (attrs.serverlessCacheArn) { + arn = attrs.serverlessCacheArn; + const extractedServerlessCacheName = stack.splitArn( + attrs.serverlessCacheArn, + ArnFormat.SLASH_RESOURCE_NAME, + ).resourceName; + if (!extractedServerlessCacheName) { + throw new ValidationError( + "Unable to extract serverless cache name from ARN.", + scope, + ); + } + name = extractedServerlessCacheName; + } else if (attrs.serverlessCacheName) { + name = attrs.serverlessCacheName; + arn = stack.formatArn({ + service: "elasticache", + resource: "serverlesscache", + resourceName: attrs.serverlessCacheName, + }); + } else { + throw new ValidationError( + "One of serverlessCacheName or serverlessCacheArn is required.", + scope, + ); + } + + class Import extends ServerlessCacheBase { + public readonly engine = attrs.engine; + public readonly serverlessCacheName: string; + public readonly serverlessCacheArn: string; + public readonly backupArnsToRestore = attrs.backupArnsToRestore; + public readonly kmsKey = attrs.kmsKey; + public readonly vpc = attrs.vpc; + public readonly subnets = attrs.subnets; + public readonly securityGroups = attrs.securityGroups; + public readonly userGroup = attrs.userGroup; + + public readonly connections: ec2.Connections; + + constructor(_serverlessCacheArn: string, _serverlessCacheName: string) { + super(scope, id); + this.serverlessCacheArn = _serverlessCacheArn; + this.serverlessCacheName = _serverlessCacheName; + + if (this.engine) { + let defaultPort: ec2.Port; + switch (this.engine.engineType) { + case "valkey": + case "redis": + // Document showing the default port + // https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/set-up.html#elasticache-install-grant-access-VPN + defaultPort = ec2.Port.tcp(6379); + break; + case "memcached": + // Document showing the default port + // https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/set-up.html#elasticache-install-grant-access-VPN + defaultPort = ec2.Port.tcp(11211); + break; + default: + throw new ValidationError( + `Unsupported cache engine: ${this.engine}`, + scope, + ); + } + + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + defaultPort: defaultPort, + }); + } else { + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + }); + } + } + } + + return new Import(arn, name); + } + + public readonly engine?: CacheEngine; + public readonly serverlessCacheName: string; + public readonly backupArnsToRestore?: string[]; + public readonly kmsKey?: encryption.IKey; + public readonly vpc?: ec2.IVpc; + public readonly subnets?: ec2.ISubnet[]; + public readonly securityGroups?: ec2.ISecurityGroup[]; + public readonly userGroup?: IUserGroup; + + /** + * The ARN of the serverless cache + * + * @attribute + */ + public readonly serverlessCacheArn: string; + /** + * The endpoint address of the serverless cache + * + * @attribute + */ + public readonly serverlessCacheEndpointAddress: string; + /** + * The endpoint port of the serverless cache + * + * TERRACONSTRUCTS DEVIATION: number-typed, unlike upstream's `string` (`CfnServerlessCache`'s + * `Endpoint.Port` is a CloudFormation string attribute requiring `Token.asNumber()` at every + * numeric use site). The Terraform L1's nested `endpoint` block exposes `port` as a genuinely + * number-typed attribute (`ElasticacheServerlessCacheEndpointOutputReference.port: number`), so + * it is surfaced as `number` directly here — mirrors the repo-wide "number-typed port tokens" + * convention (e.g. `Endpoint.port` in `../docdb/endpoint.ts`). + * + * @attribute + */ + public readonly serverlessCacheEndpointPort: number; + /** + * The reader endpoint address of the serverless cache + * + * @attribute + */ + public readonly serverlessCacheReaderEndpointAddress: string; + /** + * The reader endpoint port of the serverless cache + * + * TERRACONSTRUCTS DEVIATION: number-typed — see `serverlessCacheEndpointPort` above. + * + * @attribute + */ + public readonly serverlessCacheReaderEndpointPort: number; + + /** + * The current status of the serverless cache + * Can be 'CREATING', 'AVAILABLE', 'DELETING', 'CREATE-FAILED', 'MODIFYING' + * + * @attribute + */ + public readonly serverlessCacheStatus: string; + + /** + * Access to network connections + */ + public readonly connections: ec2.Connections; + + /** + * The underlying `aws_elasticache_serverless_cache` L1. + */ + public readonly resource: elasticacheServerlessCache.ElasticacheServerlessCache; + + constructor(scope: Construct, id: string, props: ServerlessCacheProps) { + super(scope, id, {}); + + this.engine = props.engine ?? CacheEngine.VALKEY_LATEST; + this.serverlessCacheName = Token.isUnresolved(props.serverlessCacheName) + ? (props.serverlessCacheName as string) + : ( + props.serverlessCacheName ?? + this.stack.uniqueResourceName(this, { maxLength: 40 }) + ).toLowerCase(); + this.kmsKey = props.kmsKey; + this.vpc = props.vpc; + this.userGroup = props.userGroup; + + this.validateDescription(props.description); + this.validateDataStorageLimits(props.cacheUsageLimits); + this.validateRequestRateLimits(props.cacheUsageLimits); + this.validateBackupSettings(props.backup); + this.validateUserGroupCompatibility(this.engine, this.userGroup); + + const subnetConfig = this.configureSubnets(props); + const subnetIds = subnetConfig.subnetIds; + this.subnets = subnetConfig.subnets; + + const securityGroupConfig = this.configureSecurityGroups(props); + const securityGroupIds = securityGroupConfig.securityGroupIds; + this.securityGroups = securityGroupConfig.securityGroups; + + const resource = new elasticacheServerlessCache.ElasticacheServerlessCache( + this, + "Resource", + { + engine: this.engine.engineType, + majorEngineVersion: this.engine.majorEngineVersion, + name: this.serverlessCacheName, + description: props.description, + cacheUsageLimits: this.renderCacheUsageLimits(props.cacheUsageLimits), + dailySnapshotTime: props.backup?.backupTime + ? this.formatBackupTime(props.backup.backupTime) + : undefined, + snapshotRetentionLimit: props.backup?.backupRetentionLimit, + snapshotArnsToRestore: props.backup?.backupArnsToRestore, + // TERRACONSTRUCTS DEVIATION: the key ARN, not the bare key id -- + // upstream (CFN KmsKeyId) accepts either, but AWS stores/returns the + // ARN and the Terraform provider read-back then flags "Provider + // produced inconsistent result after apply" (.kms_key_id: was "", + // now "arn:...") when given the id. Live-caught by + // integ/aws/storage TestElasticacheServerlessCache. + kmsKeyId: props.kmsKey?.keyArn, + subnetIds: subnetIds, + securityGroupIds: securityGroupIds, + userGroupId: props.userGroup?.userGroupName, + }, + ); + + if (props.userGroup) { + // TERRACONSTRUCTS DEVIATION: upstream looks up the user group's underlying `CfnUserGroup` L1 + // via `node.findChild('Resource')` and adds a CloudFormation resource-level dependency on it + // specifically. Terraform/constructs' `node.addDependency()` accepts any construct (not just + // a single underlying resource) and resolves it into the full set of Terraform resources it + // owns at synth time, so the whole `props.userGroup` construct is used directly here — this + // also works uniformly whether `userGroup` is a concrete `UserGroup` or one imported via + // `UserGroup.fromUserGroupAttributes()` (which has no `Resource` child at all). + resource.node.addDependency(props.userGroup); + } + + this.backupArnsToRestore = props.backup?.backupArnsToRestore; + this.serverlessCacheArn = resource.arn; + this.serverlessCacheStatus = resource.status; + this.serverlessCacheEndpointAddress = resource.endpoint.get(0).address; + this.serverlessCacheEndpointPort = resource.endpoint.get(0).port; + this.serverlessCacheReaderEndpointAddress = + resource.readerEndpoint.get(0).address; + this.serverlessCacheReaderEndpointPort = + resource.readerEndpoint.get(0).port; + + this.resource = resource; + + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + defaultPort: ec2.Port.tcp(this.serverlessCacheEndpointPort), + }); + + Object.defineProperty(this, ELASTICACHE_SERVERLESSCACHE_SYMBOL, { + value: true, + }); + } + + /** + * Validate description meets AWS requirements + * + * @param description The description to validate + */ + private validateDescription(description?: string): void { + if (!description || Token.isUnresolved(description)) return; + + if (description.length > 255) { + throw new ValidationError( + `Description must not exceed 255 characters, currently has ${description.length}`, + this, + ); + } + + if (description.includes("<") || description.includes(">")) { + throw new ValidationError( + "Description must not contain < or > characters", + this, + ); + } + } + + /** + * Validate data storage size limits + * + * @param limits The usage limits containing data storage settings + */ + private validateDataStorageLimits(limits?: CacheUsageLimitsProperty): void { + if (!limits) return; + + if ( + limits.dataStorageMinimumSize && + !limits.dataStorageMinimumSize.isUnresolved() && + (limits.dataStorageMinimumSize.toGibibytes() < DATA_STORAGE_MIN_GB || + limits.dataStorageMinimumSize.toGibibytes() > DATA_STORAGE_MAX_GB) + ) { + throw new ValidationError( + "Data storage minimum must be between 1 and 5000 GB.", + this, + ); + } + if ( + limits.dataStorageMaximumSize && + !limits.dataStorageMaximumSize.isUnresolved() && + (limits.dataStorageMaximumSize.toGibibytes() < DATA_STORAGE_MIN_GB || + limits.dataStorageMaximumSize.toGibibytes() > DATA_STORAGE_MAX_GB) + ) { + throw new ValidationError( + "Data storage maximum must be between 1 and 5000 GB.", + this, + ); + } + + if ( + limits.dataStorageMinimumSize && + limits.dataStorageMaximumSize && + !limits.dataStorageMinimumSize.isUnresolved() && + !limits.dataStorageMaximumSize.isUnresolved() && + limits.dataStorageMinimumSize.toGibibytes() > + limits.dataStorageMaximumSize.toGibibytes() + ) { + throw new ValidationError( + "Data storage minimum cannot be greater than maximum", + this, + ); + } + } + + /** + * Validate request rate limits + * + * @param limits The usage limits containing request rate settings + */ + private validateRequestRateLimits(limits?: CacheUsageLimitsProperty): void { + if (!limits) return; + + if ( + limits.requestRateLimitMinimum !== undefined && + !Token.isUnresolved(limits.requestRateLimitMinimum) && + (limits.requestRateLimitMinimum < REQUEST_RATE_MIN_ECPU || + limits.requestRateLimitMinimum > REQUEST_RATE_MAX_ECPU) + ) { + throw new ValidationError( + "Request rate minimum must be between 1,000 and 15,000,000 ECPUs per second", + this, + ); + } + if ( + limits.requestRateLimitMaximum !== undefined && + !Token.isUnresolved(limits.requestRateLimitMaximum) && + (limits.requestRateLimitMaximum < REQUEST_RATE_MIN_ECPU || + limits.requestRateLimitMaximum > REQUEST_RATE_MAX_ECPU) + ) { + throw new ValidationError( + "Request rate maximum must be between 1,000 and 15,000,000 ECPUs per second", + this, + ); + } + + if ( + !Token.isUnresolved(limits.requestRateLimitMinimum) && + !Token.isUnresolved(limits.requestRateLimitMaximum) && + limits.requestRateLimitMinimum !== undefined && + limits.requestRateLimitMaximum !== undefined && + limits.requestRateLimitMinimum > limits.requestRateLimitMaximum + ) { + throw new ValidationError( + "Request rate minimum cannot be greater than maximum", + this, + ); + } + } + + /** + * Validate backup settings meet AWS requirements + * + * @param backup The backup settings to validate + */ + private validateBackupSettings(backup?: BackupSettings): void { + if ( + !Token.isUnresolved(backup?.backupRetentionLimit) && + backup?.backupRetentionLimit !== undefined + ) { + const limit = backup.backupRetentionLimit; + if (limit < 1 || limit > 35) { + throw new ValidationError( + "Backup retention limit must be between 1 and 35 days", + this, + ); + } + } + } + + /** + * Validate user group compatibility with cache engine + * + * @param engine The cache engine + * @param userGroup The user group to validate + */ + private validateUserGroupCompatibility( + engine: CacheEngine, + userGroup?: IUserGroup, + ): void { + if (!userGroup) return; + + if (engine.engineType === "memcached") { + throw new ValidationError( + "User groups cannot be used with Memcached engines. Only Redis and Valkey engines support user groups.", + this, + ); + } + + if ( + engine.engineType === "redis" && + userGroup.engine?.engineType !== "redis" + ) { + throw new ValidationError( + "Redis cache can only use Redis user groups.", + this, + ); + } + } + + /** + * Render cache usage limits for the Terraform L1 + * + * TERRACONSTRUCTS DEVIATION: the Terraform `aws_elasticache_serverless_cache` resource represents + * `cache_usage_limits` (and its nested `data_storage`/`ecpu_per_second`) as single-element block + * lists rather than upstream's plain nested objects (`CfnServerlessCache.cacheUsageLimits` is a + * single `CacheUsageLimitsProperty` object) — standard Terraform block-as-list representation. + * + * @param limits The usage limits to render + * @returns Terraform-compatible usage limits block list, or undefined + */ + private renderCacheUsageLimits( + limits?: CacheUsageLimitsProperty, + ): + | elasticacheServerlessCache.ElasticacheServerlessCacheCacheUsageLimits[] + | undefined { + if (!limits) return undefined; + + const dataStorage = + limits.dataStorageMinimumSize !== undefined || + limits.dataStorageMaximumSize !== undefined + ? [ + { + unit: DataStorageUnit.GIGABYTES, + ...(limits.dataStorageMinimumSize !== undefined && { + minimum: limits.dataStorageMinimumSize.toGibibytes(), + }), + ...(limits.dataStorageMaximumSize !== undefined && { + maximum: limits.dataStorageMaximumSize.toGibibytes(), + }), + }, + ] + : undefined; + + const ecpuPerSecond = + limits.requestRateLimitMinimum !== undefined || + limits.requestRateLimitMaximum !== undefined + ? [ + { + ...(limits.requestRateLimitMinimum !== undefined && { + minimum: limits.requestRateLimitMinimum, + }), + ...(limits.requestRateLimitMaximum !== undefined && { + maximum: limits.requestRateLimitMaximum, + }), + }, + ] + : undefined; + + if (!dataStorage && !ecpuPerSecond) return undefined; + + return [ + { + dataStorage, + ecpuPerSecond, + }, + ]; + } + + /** + * Configure subnets for the cache + * + * @param props The ServerlessCache properties + * @returns Object containing subnet IDs and subnet objects + */ + private configureSubnets(props: ServerlessCacheProps): { + subnetIds: string[] | undefined; + subnets: ec2.ISubnet[] | undefined; + } { + let selectedSubnets; + if (props.vpcSubnets) { + selectedSubnets = props.vpc.selectSubnets(props.vpcSubnets); + } else { + selectedSubnets = props.vpc.selectSubnets({ + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }); + } + + return { + subnetIds: + selectedSubnets.subnetIds.length > 0 + ? selectedSubnets.subnetIds + : undefined, + subnets: + selectedSubnets.subnets.length > 0 + ? selectedSubnets.subnets + : undefined, + }; + } + + /** + * Configure security groups for the cache + * + * @param props The ServerlessCache properties + * @returns Object containing security group IDs and security group objects + */ + private configureSecurityGroups(props: ServerlessCacheProps): { + securityGroupIds: string[]; + securityGroups: ec2.ISecurityGroup[]; + } { + if (props.securityGroups && props.securityGroups.length > 0) { + return { + securityGroupIds: props.securityGroups.map((sg) => sg.securityGroupId), + securityGroups: props.securityGroups, + }; + } else { + const newSecurityGroup = new ec2.SecurityGroup(this, "SecurityGroup", { + description: `Security group for ${this.node.id} cache.`, + vpc: props.vpc, + }); + return { + securityGroupIds: [newSecurityGroup.securityGroupId], + securityGroups: [newSecurityGroup], + }; + } + } + + /** + * Format schedule to HH:MM format for daily backups + * + * @param schedule The schedule to format + * @returns Time string in HH:MM format + */ + private formatBackupTime(schedule: events.Schedule): string { + const WILD_CARD = "*"; + const [ + minuteExpression, + hourExpression, + dayExpression, + monthExpression, + weekDayExpression, + yearExpression, + ] = schedule.expressionString.substring(5).slice(0, -1).split(" "); + + if ( + dayExpression != WILD_CARD || + monthExpression != WILD_CARD || + yearExpression != WILD_CARD || + weekDayExpression != "?" + ) { + throw new ValidationError( + "For now, only daily backup time is available (supports just hour and minute). Day, month, year, and weekDay are not allowed", + this, + ); + } + + const hour = hourExpression == WILD_CARD ? "0" : hourExpression; + const minute = minuteExpression == WILD_CARD ? "0" : minuteExpression; + + return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `../rds/cluster.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + ...super.outputs, + endpointAddress: this.serverlessCacheEndpointAddress, + endpointPort: Tokenization.stringifyNumber( + this.serverlessCacheEndpointPort, + ), + readerEndpointAddress: this.serverlessCacheReaderEndpointAddress, + readerEndpointPort: Tokenization.stringifyNumber( + this.serverlessCacheReaderEndpointPort, + ), + }; + } +} diff --git a/src/aws/storage/elasticache/user-base.ts b/src/aws/storage/elasticache/user-base.ts new file mode 100644 index 00000000..47f1c253 --- /dev/null +++ b/src/aws/storage/elasticache/user-base.ts @@ -0,0 +1,263 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/user-base.ts + +import { Construct } from "constructs"; +import type { UserEngine } from "./common"; +import { ValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import { AwsStack } from "../../aws-stack"; + +/** + * Access control configuration for ElastiCache users. + */ +export abstract class AccessControl { + /** + * Create access control from an access string. + * + * @param accessString The access string defining user permissions. + */ + public static fromAccessString(accessString: string): AccessControl { + return new AccessControlString(accessString); + } + + /** + * The access string that defines user's permissions. + */ + public abstract readonly accessString: string; +} + +/** + * Access control implementation using a raw access string. + */ +class AccessControlString extends AccessControl { + /** + * The access string that defines user's permissions. + */ + public readonly accessString: string; + + constructor(accessString: string) { + super(); + this.accessString = accessString; + } +} + +/** + * Properties for defining an ElastiCache base user. + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `UserBaseProps` does not — matching the base-idiom used throughout this repo + * (e.g. `DatabaseClusterProps` in `../rds/cluster.ts`, `DatabaseClusterProps` in `../docdb/cluster.ts`) + * for cross-account/-region construct placement. + */ +export interface UserBaseProps extends AwsConstructProps { + /** + * The engine type for the user. + * Enum options: UserEngine.VALKEY, UserEngine.REDIS. + * + * @default - UserEngine.REDIS for NoPasswordUser, UserEngine.VALKEY for all other user types. + */ + readonly engine?: UserEngine; + /** + * The ID of the user. + */ + readonly userId: string; + /** + * Access control configuration for the user. + */ + readonly accessControl: AccessControl; +} + +/** + * Represents an ElastiCache base user. + * + * TODO: omitted — upstream also extends `aws_elasticache.IUserRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (identical omission pattern to the rds/docdb `*Ref` + * interfaces, e.g. `IDatabaseCluster.dbClusterRef` in `../rds/cluster-ref.ts`) — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/user-base.ts#L65 + */ +export interface IUser extends IAwsConstruct { + /** + * The user's ID. + * + * @attribute + */ + readonly userId: string; + /** + * The engine for the user. + */ + readonly engine?: UserEngine; + /** + * The user's name. + * + * @attribute + */ + readonly userName?: string; + /** + * The user's ARN. + * + * @attribute + */ + readonly userArn: string; +} + +/** + * Attributes for importing an existing ElastiCache user. + */ +export interface UserBaseAttributes { + /** + * The ID of the user. + * One of `userId` or `userArn` is required. + * + * @default - derived from userArn. + */ + readonly userId?: string; + /** + * The engine type for the user. + * + * @default - engine type is unknown. + */ + readonly engine?: UserEngine; + /** + * The user's name. + * + * @default - name is unknown. + */ + readonly userName?: string; + /** + * The ARN of the user. + * + * One of `userId` or `userArn` is required. + * + * @default - derived from userId. + */ + readonly userArn?: string; +} + +/** + * Base class for ElastiCache users. + */ +export abstract class UserBase extends AwsConstructBase implements IUser { + /** + * Import an existing user by ID. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param userId The ID of the existing user. + */ + public static fromUserId( + scope: Construct, + id: string, + userId: string, + ): IUser { + return UserBase.fromUserAttributes(scope, id, { userId }); + } + + /** + * Import an existing user by ARN. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param userArn The ARN of the existing user. + */ + public static fromUserArn( + scope: Construct, + id: string, + userArn: string, + ): IUser { + return UserBase.fromUserAttributes(scope, id, { userArn }); + } + + /** + * Import an existing user using attributes. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param attrs A `UserBaseAttributes` object. + */ + public static fromUserAttributes( + scope: Construct, + id: string, + attrs: UserBaseAttributes, + ): IUser { + let userId: string; + let userArn: string; + const stack = AwsStack.ofAwsConstruct(scope); + + if (attrs.userArn && attrs.userId) { + throw new ValidationError( + "Only one of userArn or userId can be provided.", + scope, + ); + } + + if (attrs.userArn) { + userArn = attrs.userArn; + const extractedUserId = stack.splitArn( + attrs.userArn, + ArnFormat.SLASH_RESOURCE_NAME, + ).resourceName; + if (!extractedUserId) { + throw new ValidationError("Unable to extract user id from ARN.", scope); + } + userId = extractedUserId; + } else if (attrs.userId) { + userId = attrs.userId; + userArn = stack.formatArn({ + service: "elasticache", + resource: "user", + resourceName: attrs.userId, + }); + } else { + throw new ValidationError("One of userId or userArn is required.", scope); + } + + class Import extends AwsConstructBase implements IUser { + public readonly engine?: UserEngine; + public readonly userId: string; + public readonly userArn: string; + readonly userName?: string; + + public get outputs(): Record { + return { userId: this.userId, arn: this.userArn }; + } + + constructor(_userArn: string, _userId: string) { + super(scope, id); + this.userArn = _userArn; + this.userId = _userId; + this.engine = attrs.engine; + this.userName = attrs.userName; + } + } + + return new Import(userArn, userId); + } + + /** + * The user's ID. + * + * @attribute + */ + public abstract readonly userId: string; + /** + * The engine for the user. + */ + public abstract readonly engine?: UserEngine; + /** + * The user's name. + * + * @attribute + */ + public abstract readonly userName?: string; + /** + * The user's ARN. + * + * @attribute + */ + public abstract readonly userArn: string; +} diff --git a/src/aws/storage/elasticache/user-group.ts b/src/aws/storage/elasticache/user-group.ts new file mode 100644 index 00000000..54299057 --- /dev/null +++ b/src/aws/storage/elasticache/user-group.ts @@ -0,0 +1,435 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/user-group.ts + +import { elasticacheUserGroup } from "@cdktn/provider-aws"; +import { Lazy, Token } from "cdktn"; +import { Construct } from "constructs"; +import { UserEngine } from "./common"; +import type { IUser } from "./user-base"; +import { ValidationError, UnscopedValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import { AwsStack } from "../../aws-stack"; + +const ELASTICACHE_USERGROUP_SYMBOL = Symbol.for( + "@aws-cdk/aws-elasticache.UserGroup", +); + +/** + * Properties for defining an ElastiCache UserGroup + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `UserGroupProps` does not — matching the base-idiom used throughout this repo + * for cross-account/-region construct placement. + */ +export interface UserGroupProps extends AwsConstructProps { + /** + * Enforces a particular physical user group name. + * @default + */ + readonly userGroupName?: string; + /** + * The engine type for the user group + * Enum options: UserEngine.VALKEY, UserEngine.REDIS + * + * @default UserEngine.VALKEY + */ + readonly engine?: UserEngine; + /** + * List of users inside the user group + * + * @default - no users + */ + readonly users?: IUser[]; +} + +/** + * Represents an ElastiCache UserGroup + * + * TODO: omitted — upstream also extends `aws_elasticache.IUserGroupRef`, a CloudFormation + * cross-stack "Reference" marker interface generated from the CFN resource spec. + * TerraConstructs has no equivalent generated-reference layer (identical omission pattern to the + * rds/docdb `*Ref` interfaces) — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/user-group.ts#L41 + */ +export interface IUserGroup extends IAwsConstruct { + /** + * The name of the user group + * + * @attribute + */ + readonly userGroupName: string; + /** + * The engine type for the user group + */ + readonly engine?: UserEngine; + /** + * List of users in the user group + */ + readonly users?: IUser[]; + /** + * The ARN of the user group + * + * @attribute + */ + readonly userGroupArn: string; + /** + * Add a user to this user group + * + * @param user The user to add + */ + addUser(user: IUser): void; +} + +/** + * Base class for UserGroup constructs + */ +export abstract class UserGroupBase + extends AwsConstructBase + implements IUserGroup +{ + /** + * The name of the user group + * + * @attribute + */ + public abstract readonly userGroupName: string; + /** + * The engine type for the user group + */ + public abstract readonly engine?: UserEngine; + /** + * List of users in the user group + */ + public abstract readonly users?: IUser[]; + /** + * The ARN of the user group + * @attribute + */ + public abstract readonly userGroupArn: string; + /** + * Add a user to this user group + * + * @param _user The user to add + */ + public addUser(_user: IUser): void { + throw new UnscopedValidationError( + "Cannot add users to an imported UserGroup. Only UserGroups created in this stack can be modified.", + ); + } +} + +/** + * Attributes that can be specified when importing a UserGroup + */ +export interface UserGroupAttributes { + /** + * The name of the user group + * + * One of `userGroupName` or `userGroupArn` is required. + * + * @default - derived from userGroupArn + */ + readonly userGroupName?: string; + /** + * The engine type for the user group + * + * @default - engine type is unknown + */ + readonly engine?: UserEngine; + /** + * List of users in the user group + * + * @default - users are unknown + */ + readonly users?: IUser[]; + /** + * The ARN of the user group + * + * One of `userGroupName` or `userGroupArn` is required. + * + * @default - derived from userGroupName + */ + readonly userGroupArn?: string; +} + +/** + * An ElastiCache UserGroup + * + * @resource aws_elasticache_user_group + */ +export class UserGroup extends UserGroupBase { + /** + * Uniquely identifies this class + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.elasticache.UserGroup"; + + /** + * Return whether the given object is a `UserGroup` + */ + public static isUserGroup(x: any): x is UserGroup { + return ( + x !== null && typeof x === "object" && ELASTICACHE_USERGROUP_SYMBOL in x + ); + } + + /** + * Import an existing user group by name + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param userGroupName The name of the existing user group + */ + public static fromUserGroupName( + scope: Construct, + id: string, + userGroupName: string, + ): IUserGroup { + return UserGroup.fromUserGroupAttributes(scope, id, { userGroupName }); + } + + /** + * Import an existing user group by ARN + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param userGroupArn The ARN of the existing user group + */ + public static fromUserGroupArn( + scope: Construct, + id: string, + userGroupArn: string, + ): IUserGroup { + return UserGroup.fromUserGroupAttributes(scope, id, { userGroupArn }); + } + + /** + * Import an existing user group using attributes + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param attrs A `UserGroupAttributes` object + */ + public static fromUserGroupAttributes( + scope: Construct, + id: string, + attrs: UserGroupAttributes, + ): IUserGroup { + let userGroupName: string; + let userGroupArn: string; + const stack = AwsStack.ofAwsConstruct(scope); + + if (attrs.userGroupArn && attrs.userGroupName) { + throw new ValidationError( + "Only one of userGroupArn or userGroupName can be provided.", + scope, + ); + } + + if (attrs.userGroupArn) { + userGroupArn = attrs.userGroupArn; + const extractedUserGroupName = stack.splitArn( + attrs.userGroupArn, + ArnFormat.SLASH_RESOURCE_NAME, + ).resourceName; + if (!extractedUserGroupName) { + throw new ValidationError( + "Unable to extract user group name from ARN.", + scope, + ); + } + userGroupName = extractedUserGroupName; + } else if (attrs.userGroupName) { + userGroupName = attrs.userGroupName; + userGroupArn = stack.formatArn({ + service: "elasticache", + resource: "usergroup", + resourceName: attrs.userGroupName, + }); + } else { + throw new ValidationError( + "One of userGroupName or userGroupArn is required.", + scope, + ); + } + + class Import extends UserGroupBase { + public readonly engine?: UserEngine; + public readonly userGroupName: string; + public readonly userGroupArn: string; + + public get users(): IUser[] | undefined { + return attrs.users ? [...attrs.users] : undefined; + } + + public get outputs(): Record { + return { userGroupName: this.userGroupName, arn: this.userGroupArn }; + } + + constructor(_userGroupArn: string, _userGroupName: string) { + super(scope, id); + this.userGroupArn = _userGroupArn; + this.userGroupName = _userGroupName; + this.engine = attrs.engine; + } + } + + return new Import(userGroupArn, userGroupName); + } + + public readonly engine?: UserEngine; + /** + * The name of the user group + * + * TERRACONSTRUCTS DEVIATION: lowercased at synth — ElastiCache stores `UserGroupId` as a + * lowercase string server-side (verified against the `CreateUserGroup` API reference: "The ID of + * the user group. This value is stored as a lowercase string."), and emitting the original + * casing would report a perpetual Terraform diff. Mirrors the identical `dbClusterName` + * lowercasing convention in `../docdb/cluster.ts`. + */ + public readonly userGroupName: string; + private readonly _users: IUser[]; + /** + * The ARN of the user group + * + * @attribute + */ + public readonly userGroupArn: string; + + // TODO: omitted — upstream's `userGroupStatus` (`CfnUserGroup.attrStatus`, + // CloudFormation-computed `Status` attribute: 'creating' | 'active' | 'modifying' | 'deleting') + // has no Terraform-provider equivalent. The `aws_elasticache_user_group` resource does not + // expose a computed `status`/`user_group_status` attribute at all (verified against the full + // config shape in `node_modules/@cdktn/provider-aws/lib/elasticache-user-group/index.d.ts`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/lib/user-group.ts#L242-L248 + // readonly userGroupStatus: string; + + /** + * The underlying `aws_elasticache_user_group` L1. + */ + public readonly resource: elasticacheUserGroup.ElasticacheUserGroup; + + constructor(scope: Construct, id: string, props: UserGroupProps = {}) { + super(scope, id, props); + + this.engine = props.engine ?? UserEngine.VALKEY; + this.userGroupName = Token.isUnresolved(props.userGroupName) + ? (props.userGroupName as string) + : ( + props.userGroupName ?? + // ElastiCache allows only up to 40 characters for a user group id. + this.stack.uniqueResourceName(this, { maxLength: 40 }) + ).toLowerCase(); + + // TERRACONSTRUCTS DEVIATION: upstream defers `userIds` to synth time via a lazy, mutable + // `IArrayBox` (`aws-cdk-lib/core/lib/helpers-internal`, CDK-internal and not ported here) so + // that users added later via `addUser()` are still reflected when the underlying + // `CfnUserGroup.userIds` token resolves. `Lazy.listValue()` (public cdktn API) gives the same + // synth-time-deferred behavior: `_users` is captured by reference in the closure below, so + // mutations from `addUser()` before synth are picked up identically. + this._users = [...(props.users ?? [])]; + + this.resource = new elasticacheUserGroup.ElasticacheUserGroup( + this, + "Resource", + { + engine: this.engine.engineType, + userGroupId: this.userGroupName, + userIds: Lazy.listValue({ + produce: () => { + this.validateUsers(); + return this._users.map((user) => user.userId); + }, + }), + }, + ); + + if (props.users) { + props.users.forEach((user) => this.addUserDependency(user)); + } + + this.userGroupArn = this.resource.arn; + + Object.defineProperty(this, ELASTICACHE_USERGROUP_SYMBOL, { + value: true, + }); + } + + /** + * Add a CloudFormation dependency on the user resource to ensure proper creation order. + */ + private addUserDependency(user: IUser): void { + this.resource.node.addDependency(user); + } + + /** + * Array of users in the user group + * + * Do not push directly to this array. + * Use addUser() instead to ensure proper validation and dependency management. + */ + public get users(): IUser[] | undefined { + return [...this._users]; + } + + /** + * Validates users in the user group for duplicate usernames and Redis-specific requirements. + */ + private validateUsers(): void { + const users = this._users; + const userNames = users.map((user) => user.userName); + const duplicates = userNames.filter( + (name, index) => userNames.indexOf(name) !== index, + ); + if (duplicates.length > 0) { + throw new ValidationError( + "User group cannot have users with the same user name.", + this, + ); + } + + if (this.engine?.engineType === "redis") { + users.forEach((user) => { + if (user.engine?.engineType !== "redis") { + throw new ValidationError( + "Redis user group can only contain Redis users.", + this, + ); + } + }); + const hasDefaultUser = users.some((user) => user.userName === "default"); + if (!hasDefaultUser) { + throw new ValidationError( + 'Redis user groups need to contain a user with the user name "default".', + this, + ); + } + } + } + + /** + * Add a user to this user group + * + * @param user The user to add to the group + */ + public addUser(user: IUser): void { + this._users.push(user); + this.addUserDependency(user); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `../rds/cluster.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + userGroupName: this.userGroupName, + arn: this.userGroupArn, + }; + } +} diff --git a/src/aws/storage/index.ts b/src/aws/storage/index.ts index 09f9d664..b82944f5 100644 --- a/src/aws/storage/index.ts +++ b/src/aws/storage/index.ts @@ -42,3 +42,6 @@ export * as rds from "./rds"; // aws-docdb export * as docdb from "./docdb"; + +// aws-elasticache-alpha +export * as elasticache from "./elasticache"; diff --git a/test/aws/storage/elasticache/iam-user.test.ts b/test/aws/storage/elasticache/iam-user.test.ts new file mode 100644 index 00000000..c852efa0 --- /dev/null +++ b/test/aws/storage/elasticache/iam-user.test.ts @@ -0,0 +1,456 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/iam-user.test.ts + +import { elasticacheUser, dataAwsIamPolicyDocument } from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as elasticache from "../../../../src/aws/storage/elasticache"; +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("IamUser", () => { + describe("validation errors", () => { + test.each([ + { + testDescription: + "when userName differs from userId throws validation error", + userId: "test-user", + userName: "different-name", + errorMessage: + "For IAM authentication, userName must be equal to userId.", + }, + ])("$testDescription", ({ userId, userName, errorMessage }) => { + expect( + () => + new elasticache.IamUser(stack, "TestUser", { + userId, + userName, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ).toThrow(errorMessage); + }); + }); + + describe("constructor", () => { + test("creates user with minimal required properties", () => { + new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + engine: "valkey", + user_id: "test-user", + user_name: "test-user", + access_string: "on ~* +@all", + authentication_mode: { + type: "iam", + }, + no_password_required: false, + }); + }); + + test("creates user with all possible properties", () => { + new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + accessControl: elasticache.AccessControl.fromAccessString( + "on ~app:* +@read +@write", + ), + engine: elasticache.UserEngine.REDIS, + userName: "test-user", + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + engine: "redis", + user_id: "test-user", + user_name: "test-user", + access_string: "on ~app:* +@read +@write", + authentication_mode: { + type: "iam", + }, + no_password_required: false, + }); + }); + + test("creates exactly one ElastiCache user resource", () => { + new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const t = new Template(stack); + t.resourceCountIs(elasticacheUser.ElasticacheUser, 1); + }); + + // TERRACONSTRUCTS DEVIATION: not present upstream — `userId` is lowercased at synth (see the + // `TERRACONSTRUCTS DEVIATION` note on `IamUser.userId` in `../../../../src/aws/storage/ + // elasticache/iam-user.ts`). `userName` still defaults from `props.userId` in its original + // casing (byte-close to upstream), so a mixed-case `userId` supplied without an explicit, + // already-lowercased `userName` fails the `userName === userId` equality check below. + test("lowercases a mixed-case userId to avoid a perpetual Terraform diff", () => { + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "Test-User", + userName: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(user.userId).toBe("test-user"); + expect(user.userName).toBe("test-user"); + }); + + test("a mixed-case userId without a matching lowercase userName throws validation error", () => { + expect( + () => + new elasticache.IamUser(stack, "TestUser", { + userId: "Test-User", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ).toThrow( + "For IAM authentication, userName must be equal to userId. `userId` is lowercased to 'test-user' (ElastiCache stores UserId as a lowercase string), so supply an already-lowercase `userId`, or pass a matching lowercase `userName`.", + ); + }); + + // A Token-valued userId must be passed through untouched -- lowercasing it would corrupt the + // cdktn `${TfToken[...]}` marker and break Terraform's reference resolution. Since `userName` + // defaults from the same unresolved token, the `userName === userId` equality check still + // passes. + test("passes through a deploy-time (Token) userId without lowercasing it", () => { + const parameter = new TerraformVariable(stack, "Parameter", { + type: "string", + }); + const tokenValue = parameter.stringValue; + const user = new elasticache.IamUser(stack, "TestUser", { + userId: tokenValue, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(user.userId).toEqual(tokenValue); + Template.synth(stack).toHaveResourceWithProperties( + elasticacheUser.ElasticacheUser, + { + user_id: stack.resolve(tokenValue), + }, + ); + }); + }); + + describe("properties", () => { + test("exposes correct properties", () => { + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "test-user-id", + userName: "test-user-id", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~app:* +@read"), + }); + + expect(user.userId).toBe("test-user-id"); + expect(user.userName).toBe("test-user-id"); + expect(user.engine?.engineType).toBe("valkey"); + expect(user.accessString).toBe("on ~app:* +@read"); + expect(user.userArn).toBeDefined(); + // TODO: omitted — upstream asserts `user.userStatus` is defined. `userStatus` has no + // Terraform-provider equivalent and is dropped from this port — see the `TODO: omitted` note + // on `IamUser` in `../../../../src/aws/storage/elasticache/iam-user.ts`. + }); + + test("userName defaults to userId when not provided", () => { + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "my-user-id", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(user.userName).toBe("my-user-id"); + expect(user.engine?.engineType).toBe("redis"); + }); + }); + + describe("isIamUser", () => { + test("returns true for IamUser instances", () => { + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(elasticache.IamUser.isIamUser(user)).toBe(true); + }); + + test("returns false for non-IamUser objects", () => { + expect(elasticache.IamUser.isIamUser({})).toBe(false); + expect(elasticache.IamUser.isIamUser(null)).toBe(false); + expect(elasticache.IamUser.isIamUser(undefined)).toBe(false); + expect(elasticache.IamUser.isIamUser("string")).toBe(false); + expect(elasticache.IamUser.isIamUser(123)).toBe(false); + }); + + test("returns false for imported users (not actual IamUser instances)", () => { + const importedUser = elasticache.IamUser.fromUserId( + stack, + "ImportedUser", + "test-user", + ); + + expect(elasticache.IamUser.isIamUser(importedUser)).toBe(false); + }); + }); + + describe("IAM permissions", () => { + let user: elasticache.IamUser; + let role: iam.Role; + + beforeEach(() => { + user = new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + role = new iam.Role(stack, "TestRole", { + assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), + }); + }); + + test("grantConnect adds correct IAM permissions", () => { + user.grantConnect(role); + + Template.synth(stack).toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["elasticache:Connect"], + effect: "Allow", + resources: [stack.resolve(user.userArn)], + }, + ], + }, + ); + }); + + test("grant adds custom IAM permissions", () => { + user.grant(role, "elasticache:Connect", "elasticache:DescribeUsers"); + + Template.synth(stack).toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["elasticache:Connect", "elasticache:DescribeUsers"], + effect: "Allow", + resources: [stack.resolve(user.userArn)], + }, + ], + }, + ); + }); + + test("grant works with single action", () => { + user.grant(role, "elasticache:Connect"); + + Template.synth(stack).toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["elasticache:Connect"], + effect: "Allow", + resources: [stack.resolve(user.userArn)], + }, + ], + }, + ); + }); + }); + + describe("import methods", () => { + test("fromUserAttributes works with valid userArn", () => { + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userArn: "arn:aws:elasticache:us-east-1:123456789012:user:my-user", + }, + ); + + expect(user.userId).toBe("my-user"); + expect(user.userArn).toBe( + "arn:aws:elasticache:us-east-1:123456789012:user:my-user", + ); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserAttributes works with userId only", () => { + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "imported-user", + }, + ); + + expect(user.userId).toBe("imported-user"); + expect(user.userArn).toContain("imported-user"); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserAttributes preserves engine when provided", () => { + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + }, + ); + + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + }); + + test("fromUserAttributes preserves userName when provided", () => { + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + userName: "test-user", + }, + ); + + expect(user.userName).toBe("test-user"); + }); + + test("fromUserAttributes works with both engine and userName", () => { + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + userName: "test-user", + }, + ); + + expect(user.userId).toBe("test-user"); + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + expect(user.userName).toBe("test-user"); + }); + + test("fromUserAttributes with userArn preserves additional attributes", () => { + const arn = "arn:aws:elasticache:us-east-1:123456789012:user:my-user"; + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userArn: arn, + engine: elasticache.UserEngine.VALKEY, + userName: "my-user", + }, + ); + + expect(user.userArn).toBe(arn); + expect(user.userId).toBe("my-user"); + expect(user.engine?.engineType).toBe("valkey"); + expect(user.userName).toBe("my-user"); + }); + + test("fromUserId creates user with correct properties", () => { + const user = elasticache.IamUser.fromUserId( + stack, + "ImportedUser", + "my-user-id", + ); + + expect(user.userId).toBe("my-user-id"); + expect(user.userArn).toContain("my-user-id"); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserArn creates user with correct properties", () => { + const arn = "arn:aws:elasticache:us-west-2:123456789012:user:test-user"; + const user = elasticache.IamUser.fromUserArn(stack, "ImportedUser", arn); + + expect(user.userId).toBe("test-user"); + expect(user.userArn).toBe(arn); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("import methods do not validate userName equals userId constraint", () => { + // Import methods assume external user is valid + const user = elasticache.IamUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + userName: "different-name", // This is allowed for imports + }, + ); + + expect(user.userName).toBe("different-name"); + expect(user.userId).toBe("test-user"); + }); + + test.each([ + { + testDescription: + "when passing both userId and userArn throws validation error", + userArn: "arn:aws:elasticache:us-east-1:999999999999:user:test-user", + userId: "test-user", + errorMessage: "Only one of userArn or userId can be provided.", + }, + { + testDescription: + "when passing neither userId nor userArn throws validation error", + errorMessage: "One of userId or userArn is required.", + }, + { + testDescription: + "when passing invalid userArn (no user id) throws validation error", + userArn: "arn:aws:elasticache:us-east-1:999999999999:user", + errorMessage: "Unable to extract user id from ARN.", + }, + ])("$testDescription", ({ userArn, userId, errorMessage }) => { + expect(() => + elasticache.IamUser.fromUserAttributes(stack, "ImportedUser", { + userArn, + userId, + }), + ).toThrow(errorMessage); + }); + }); +}); diff --git a/test/aws/storage/elasticache/no-password-user.test.ts b/test/aws/storage/elasticache/no-password-user.test.ts new file mode 100644 index 00000000..02ac59ba --- /dev/null +++ b/test/aws/storage/elasticache/no-password-user.test.ts @@ -0,0 +1,375 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/no-password-user.test.ts + +import { elasticacheUser } from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as elasticache from "../../../../src/aws/storage/elasticache"; +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("NoPasswordUser", () => { + describe("validation errors", () => { + test("when using Valkey engine throws validation error", () => { + expect( + () => + new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ).toThrow( + "Engine 'valkey' does not support no-password authentication. Supported engines: redis.", + ); + }); + + test('UserEngine.of("valkey") produces the same validation error as UserEngine.VALKEY', () => { + expect( + () => + new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.of("valkey"), + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ).toThrow( + "Engine 'valkey' does not support no-password authentication. Supported engines: redis.", + ); + }); + }); + + describe("constructor", () => { + test("creates user with minimal required properties", () => { + new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + engine: "redis", + user_id: "test-user", + user_name: "test-user", + access_string: "on ~* +@all", + authentication_mode: { + type: "no-password-required", + }, + no_password_required: true, + }); + }); + + test("creates user with all possible properties", () => { + new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: elasticache.AccessControl.fromAccessString( + "on ~app:* +@read +@write", + ), + engine: elasticache.UserEngine.REDIS, + userName: "test-user-name", + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + engine: "redis", + user_id: "test-user", + user_name: "test-user-name", + access_string: "on ~app:* +@read +@write", + authentication_mode: { + type: "no-password-required", + }, + no_password_required: true, + }); + }); + + test("creates exactly one ElastiCache user resource", () => { + new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + engine: elasticache.UserEngine.REDIS, + }); + + const t = new Template(stack); + t.resourceCountIs(elasticacheUser.ElasticacheUser, 1); + }); + + // TERRACONSTRUCTS DEVIATION: not present upstream — `userId` is lowercased at synth (see the + // `TERRACONSTRUCTS DEVIATION` note on `NoPasswordUser.userId` in `../../../../src/aws/storage/ + // elasticache/no-password-user.ts`). `userName` still defaults from `props.userId` in its + // original casing (byte-close to upstream), so it is unaffected by the `userId` lowercasing. + test("lowercases a mixed-case userId to avoid a perpetual Terraform diff", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "Test-User", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(user.userId).toBe("test-user"); + expect(user.userName).toBe("Test-User"); + }); + + // A Token-valued userId must be passed through untouched -- lowercasing it would corrupt the + // cdktn `${TfToken[...]}` marker and break Terraform's reference resolution. + test("passes through a deploy-time (Token) userId without lowercasing it", () => { + const parameter = new TerraformVariable(stack, "Parameter", { + type: "string", + }); + const tokenValue = parameter.stringValue; + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: tokenValue, + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(user.userId).toEqual(tokenValue); + Template.synth(stack).toHaveResourceWithProperties( + elasticacheUser.ElasticacheUser, + { + user_id: stack.resolve(tokenValue), + }, + ); + }); + }); + + describe("properties", () => { + test("exposes correct properties", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user-id", + userName: "test-user-name", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~app:* +@read"), + }); + + expect(user.userId).toBe("test-user-id"); + expect(user.userName).toBe("test-user-name"); + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + expect(user.accessString).toBe("on ~app:* +@read"); + expect(user.userArn).toBeDefined(); + // TODO: omitted — upstream asserts `user.userStatus` is defined. `userStatus` has no + // Terraform-provider equivalent and is dropped from this port — see the `TODO: omitted` note + // on `NoPasswordUser` in `../../../../src/aws/storage/elasticache/no-password-user.ts`. + }); + + test("userName defaults to userId when not provided", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "my-user-id", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(user.userName).toBe("my-user-id"); + expect(user.engine?.engineType).toBe("redis"); + }); + }); + + describe("isNoPasswordUser", () => { + test("returns true for NoPasswordUser instances", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(elasticache.NoPasswordUser.isNoPasswordUser(user)).toBe(true); + }); + + test("returns false for non-NoPasswordUser objects", () => { + expect(elasticache.NoPasswordUser.isNoPasswordUser({})).toBe(false); + expect(elasticache.NoPasswordUser.isNoPasswordUser(null)).toBe(false); + expect(elasticache.NoPasswordUser.isNoPasswordUser(undefined)).toBe( + false, + ); + expect(elasticache.NoPasswordUser.isNoPasswordUser("string")).toBe(false); + expect(elasticache.NoPasswordUser.isNoPasswordUser(123)).toBe(false); + }); + + test("returns false for imported users (not actual NoPasswordUser instances)", () => { + const importedUser = elasticache.NoPasswordUser.fromUserId( + stack, + "ImportedUser", + "test-user", + ); + + expect(elasticache.NoPasswordUser.isNoPasswordUser(importedUser)).toBe( + false, + ); + }); + }); + + describe("import methods", () => { + test("fromUserAttributes works with valid userArn", () => { + const user = elasticache.NoPasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userArn: "arn:aws:elasticache:us-east-1:123456789012:user:my-user", + }, + ); + + expect(user.userId).toBe("my-user"); + expect(user.userArn).toBe( + "arn:aws:elasticache:us-east-1:123456789012:user:my-user", + ); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserAttributes works with userId only", () => { + const user = elasticache.NoPasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "imported-user", + }, + ); + + expect(user.userId).toBe("imported-user"); + expect(user.userArn).toContain("imported-user"); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserAttributes preserves engine when provided", () => { + const user = elasticache.NoPasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + }, + ); + + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + }); + + test("fromUserAttributes preserves userName when provided", () => { + const user = elasticache.NoPasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + userName: "custom-name", + }, + ); + + expect(user.userName).toBe("custom-name"); + }); + + test("fromUserAttributes works with both engine and userName", () => { + const user = elasticache.NoPasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + userName: "custom-name", + }, + ); + + expect(user.userId).toBe("test-user"); + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + expect(user.userName).toBe("custom-name"); + }); + + test("fromUserAttributes with userArn preserves additional attributes", () => { + const arn = "arn:aws:elasticache:us-east-1:123456789012:user:my-user"; + const user = elasticache.NoPasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userArn: arn, + engine: elasticache.UserEngine.REDIS, + userName: "display-name", + }, + ); + + expect(user.userArn).toBe(arn); + expect(user.userId).toBe("my-user"); + expect(user.engine?.engineType).toBe("redis"); + expect(user.userName).toBe("display-name"); + }); + + test("fromUserId creates user with correct properties", () => { + const user = elasticache.NoPasswordUser.fromUserId( + stack, + "ImportedUser", + "my-user-id", + ); + + expect(user.userId).toBe("my-user-id"); + expect(user.userArn).toContain("my-user-id"); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserArn creates user with correct properties", () => { + const arn = "arn:aws:elasticache:us-west-2:123456789012:user:test-user"; + const user = elasticache.NoPasswordUser.fromUserArn( + stack, + "ImportedUser", + arn, + ); + + expect(user.userId).toBe("test-user"); + expect(user.userArn).toBe(arn); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test.each([ + { + testDescription: + "when passing both userId and userArn throws validation error", + userArn: "arn:aws:elasticache:us-east-1:999999999999:user:test-user", + userId: "test-user", + errorMessage: "Only one of userArn or userId can be provided.", + }, + { + testDescription: + "when passing neither userId nor userArn throws validation error", + errorMessage: "One of userId or userArn is required.", + }, + { + testDescription: + "when passing invalid userArn (no user id) throws validation error", + userArn: "arn:aws:elasticache:us-east-1:999999999999:user", + errorMessage: "Unable to extract user id from ARN.", + }, + ])("$testDescription", ({ userArn, userId, errorMessage }) => { + expect(() => + elasticache.NoPasswordUser.fromUserAttributes(stack, "ImportedUser", { + userArn, + userId, + }), + ).toThrow(errorMessage); + }); + }); +}); diff --git a/test/aws/storage/elasticache/password-user.test.ts b/test/aws/storage/elasticache/password-user.test.ts new file mode 100644 index 00000000..2459f38b --- /dev/null +++ b/test/aws/storage/elasticache/password-user.test.ts @@ -0,0 +1,411 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/password-user.test.ts + +import { elasticacheUser } from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as elasticache from "../../../../src/aws/storage/elasticache"; +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("PasswordUser", () => { + describe("validation errors", () => { + test.each([ + { + testDescription: "when no passwords provided throws validation error", + passwords: [], + errorMessage: "Password authentication requires 1-2 passwords.", + }, + { + testDescription: + "when more than 2 passwords provided throws validation error", + passwords: [ + "secretvalue-1234", + "secretvalue-12345", + "secretvalue-123456", + ], + errorMessage: "Password authentication requires 1-2 passwords.", + }, + ])("$testDescription", ({ passwords, errorMessage }) => { + expect( + () => + new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords, + }), + ).toThrow(errorMessage); + }); + }); + + describe("constructor", () => { + test("creates user with minimal required properties (single password)", () => { + new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["secretvalue-1234"], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + engine: "valkey", + user_id: "test-user", + user_name: "test-user", + access_string: "on ~* +@all", + authentication_mode: { + type: "password", + passwords: ["secretvalue-1234"], + }, + no_password_required: false, + }); + }); + + test("creates user with two passwords", () => { + new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["secretvalue-1234", "secretvalue-12345"], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + authentication_mode: { + type: "password", + passwords: ["secretvalue-1234", "secretvalue-12345"], + }, + }); + }); + + test("creates user with all possible properties", () => { + new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: elasticache.AccessControl.fromAccessString( + "on ~app:* +@read +@write", + ), + engine: elasticache.UserEngine.REDIS, + userName: "test-user-name", + passwords: ["my-secret"], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + engine: "redis", + user_id: "test-user", + user_name: "test-user-name", + access_string: "on ~app:* +@read +@write", + authentication_mode: { + type: "password", + passwords: ["my-secret"], + }, + no_password_required: false, + }); + }); + + test("creates exactly one ElastiCache user resource", () => { + new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["my-secret"], + }); + + const t = new Template(stack); + t.resourceCountIs(elasticacheUser.ElasticacheUser, 1); + }); + + // TERRACONSTRUCTS DEVIATION: not present upstream — `userId` is lowercased at synth (see the + // `TERRACONSTRUCTS DEVIATION` note on `PasswordUser.userId` in `../../../../src/aws/storage/ + // elasticache/password-user.ts`). `userName` still defaults from `props.userId` in its + // original casing (byte-close to upstream), so it is unaffected by the `userId` lowercasing. + test("lowercases a mixed-case userId to avoid a perpetual Terraform diff", () => { + const user = new elasticache.PasswordUser(stack, "TestUser", { + userId: "Test-User", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["my-secret"], + }); + + expect(user.userId).toBe("test-user"); + expect(user.userName).toBe("Test-User"); + }); + + // A Token-valued userId must be passed through untouched -- lowercasing it would corrupt the + // cdktn `${TfToken[...]}` marker and break Terraform's reference resolution. + test("passes through a deploy-time (Token) userId without lowercasing it", () => { + const parameter = new TerraformVariable(stack, "Parameter", { + type: "string", + }); + const tokenValue = parameter.stringValue; + const user = new elasticache.PasswordUser(stack, "TestUser", { + userId: tokenValue, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["my-secret"], + }); + + expect(user.userId).toEqual(tokenValue); + Template.synth(stack).toHaveResourceWithProperties( + elasticacheUser.ElasticacheUser, + { + user_id: stack.resolve(tokenValue), + }, + ); + }); + }); + + describe("properties", () => { + test("exposes correct properties", () => { + const user = new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user-id", + userName: "test-user-name", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~app:* +@read"), + passwords: ["secretvalue-1234"], + }); + + expect(user.userId).toBe("test-user-id"); + expect(user.userName).toBe("test-user-name"); + expect(user.engine?.engineType).toBe("valkey"); + expect(user.accessString).toBe("on ~app:* +@read"); + expect(user.userArn).toBeDefined(); + // TODO: omitted — upstream asserts `user.userStatus` is defined. `userStatus` has no + // Terraform-provider equivalent and is dropped from this port — see the `TODO: omitted` note + // on `PasswordUser` in `../../../../src/aws/storage/elasticache/password-user.ts`. + }); + + test("userName defaults to userId when not provided", () => { + const user = new elasticache.PasswordUser(stack, "TestUser", { + userId: "my-user-id", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["my-secret"], + }); + + expect(user.userName).toBe("my-user-id"); + expect(user.engine?.engineType).toBe("redis"); + }); + + test("handles multiple passwords", () => { + new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["elasticache/user/password", "plaintext-password"], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(elasticacheUser.ElasticacheUser, { + authentication_mode: { + type: "password", + passwords: ["elasticache/user/password", "plaintext-password"], + }, + }); + }); + }); + + describe("isPasswordUser", () => { + test("returns true for PasswordUser instances", () => { + const user = new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["my-secret"], + }); + + expect(elasticache.PasswordUser.isPasswordUser(user)).toBe(true); + }); + + test("returns false for non-PasswordUser objects", () => { + expect(elasticache.PasswordUser.isPasswordUser({})).toBe(false); + expect(elasticache.PasswordUser.isPasswordUser(null)).toBe(false); + expect(elasticache.PasswordUser.isPasswordUser(undefined)).toBe(false); + expect(elasticache.PasswordUser.isPasswordUser("string")).toBe(false); + expect(elasticache.PasswordUser.isPasswordUser(123)).toBe(false); + }); + + test("returns false for imported users (not actual PasswordUser instances)", () => { + const importedUser = elasticache.PasswordUser.fromUserId( + stack, + "ImportedUser", + "test-user", + ); + + expect(elasticache.PasswordUser.isPasswordUser(importedUser)).toBe(false); + }); + }); + + describe("import methods", () => { + test("fromUserAttributes works with valid userArn", () => { + const user = elasticache.PasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userArn: "arn:aws:elasticache:us-east-1:123456789012:user:my-user", + }, + ); + + expect(user.userId).toBe("my-user"); + expect(user.userArn).toBe( + "arn:aws:elasticache:us-east-1:123456789012:user:my-user", + ); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserAttributes works with userId only", () => { + const user = elasticache.PasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "imported-user", + }, + ); + + expect(user.userId).toBe("imported-user"); + expect(user.userArn).toContain("imported-user"); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserAttributes preserves engine when provided", () => { + const user = elasticache.PasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + }, + ); + + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + }); + + test("fromUserAttributes preserves userName when provided", () => { + const user = elasticache.PasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + userName: "custom-name", + }, + ); + + expect(user.userName).toBe("custom-name"); + }); + + test("fromUserAttributes works with both engine and userName", () => { + const user = elasticache.PasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + userName: "custom-name", + }, + ); + + expect(user.userId).toBe("test-user"); + expect(user.engine).toBe(elasticache.UserEngine.REDIS); + expect(user.userName).toBe("custom-name"); + }); + + test("fromUserAttributes with userArn preserves additional attributes", () => { + const arn = "arn:aws:elasticache:us-east-1:123456789012:user:my-user"; + const user = elasticache.PasswordUser.fromUserAttributes( + stack, + "ImportedUser", + { + userArn: arn, + engine: elasticache.UserEngine.VALKEY, + userName: "display-name", + }, + ); + + expect(user.userArn).toBe(arn); + expect(user.userId).toBe("my-user"); + expect(user.engine?.engineType).toBe("valkey"); + expect(user.userName).toBe("display-name"); + }); + + test("fromUserId creates user with correct properties", () => { + const user = elasticache.PasswordUser.fromUserId( + stack, + "ImportedUser", + "my-user-id", + ); + + expect(user.userId).toBe("my-user-id"); + expect(user.userArn).toContain("my-user-id"); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test("fromUserArn creates user with correct properties", () => { + const arn = "arn:aws:elasticache:us-west-2:123456789012:user:test-user"; + const user = elasticache.PasswordUser.fromUserArn( + stack, + "ImportedUser", + arn, + ); + + expect(user.userId).toBe("test-user"); + expect(user.userArn).toBe(arn); + expect(user.userName).toBe(undefined); + expect(user.engine).toBe(undefined); + }); + + test.each([ + { + testDescription: + "when passing both userId and userArn throws validation error", + userArn: "arn:aws:elasticache:us-east-1:999999999999:user:test-user", + userId: "test-user", + errorMessage: "Only one of userArn or userId can be provided.", + }, + { + testDescription: + "when passing neither userId nor userArn throws validation error", + errorMessage: "One of userId or userArn is required.", + }, + { + testDescription: + "when passing invalid userArn (no user id) throws validation error", + userArn: "arn:aws:elasticache:us-east-1:999999999999:user", + errorMessage: "Unable to extract user id from ARN.", + }, + ])("$testDescription", ({ userArn, userId, errorMessage }) => { + expect(() => + elasticache.PasswordUser.fromUserAttributes(stack, "ImportedUser", { + userArn, + userId, + }), + ).toThrow(errorMessage); + }); + }); +}); diff --git a/test/aws/storage/elasticache/serverless-cache-base.test.ts b/test/aws/storage/elasticache/serverless-cache-base.test.ts new file mode 100644 index 00000000..5a4c3684 --- /dev/null +++ b/test/aws/storage/elasticache/serverless-cache-base.test.ts @@ -0,0 +1,241 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/serverless-cache-base.test.ts + +import { + cloudwatchMetricAlarm, + dataAwsIamPolicyDocument, +} 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 iam from "../../../../src/aws/iam"; +import * as elasticache from "../../../../src/aws/storage/elasticache"; +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", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +describe("serverless cache base", () => { + describe("metrics", () => { + let stack: AwsStack; + let vpc: compute.Vpc; + let cache: elasticache.ServerlessCache; + beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); + cache = new elasticache.ServerlessCache(stack, "Cache", { + vpc, + }); + }); + + test("creating an alarm based on metric", () => { + const metric = cache.metric("Metric", {}); + new cloudwatch.Alarm(stack, "Alarm", { + evaluationPeriods: 1, + threshold: 1, + comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, + metric: metric, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + cloudwatchMetricAlarm.CloudwatchMetricAlarm, + { + namespace: "AWS/ElastiCache", + metric_name: "Metric", + dimensions: { + ServerlessCacheName: stack.resolve(cache.serverlessCacheName), + }, + comparison_operator: "LessThanThreshold", + evaluation_periods: 1, + threshold: 1, + }, + ); + }); + + test.each([ + { + testDescription: "creating an alarm based on cache hit metric", + methodName: "metricCacheHitCount", + metricName: "CacheHits", + }, + { + testDescription: "creating an alarm based on cache miss count metric", + methodName: "metricCacheMissCount", + metricName: "CacheMisses", + }, + { + testDescription: "creating an alarm based on cache hit rate metric", + methodName: "metricCacheHitRate", + metricName: "CacheHitRate", + }, + { + testDescription: "creating an alarm based on cache data stored metric", + methodName: "metricDataStored", + metricName: "BytesUsedForCache", + }, + { + testDescription: + "creating an alarm based on cache ECPUs consumed metric", + methodName: "metricProcessingUnitsConsumed", + metricName: "ElastiCacheProcessingUnits", + }, + { + testDescription: + "creating an alarm based on cache newtork bytes in metric", + methodName: "metricNetworkBytesIn", + metricName: "NetworkBytesIn", + }, + { + testDescription: + "creating an alarm based on cache network bytes out metric", + methodName: "metricNetworkBytesOut", + metricName: "NetworkBytesOut", + }, + { + testDescription: + "creating an alarm based on cache active connections metric", + methodName: "metricActiveConnections", + metricName: "CurrConnections", + }, + { + testDescription: + "creating an alarm based on cache write request latency metric", + methodName: "metricWriteRequestLatency", + metricName: "SuccessfulWriteRequestLatency", + }, + { + testDescription: + "creating an alarm based on cache read request latency metric", + methodName: "metricReadRequestLatency", + metricName: "SuccessfulReadRequestLatency", + }, + ] as const)("$testDescription", ({ methodName, metricName }) => { + const metric = (cache as any)[methodName]({}); + new cloudwatch.Alarm(stack, "Alarm", { + evaluationPeriods: 1, + threshold: 1, + comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, + metric: metric, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + cloudwatchMetricAlarm.CloudwatchMetricAlarm, + { + namespace: "AWS/ElastiCache", + metric_name: metricName, + dimensions: { + ServerlessCacheName: stack.resolve(cache.serverlessCacheName), + }, + comparison_operator: "LessThanThreshold", + evaluation_periods: 1, + threshold: 1, + }, + ); + }); + }); + + describe("IAM permissions", () => { + let role: iam.Role; + let stack: AwsStack; + let vpc: compute.Vpc; + let cache: elasticache.ServerlessCache; + + beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); + cache = new elasticache.ServerlessCache(stack, "Cache", { + vpc, + }); + role = new iam.Role(stack, "TestRole", { + assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), + }); + }); + + test("grantConnect adds correct permissions", () => { + cache.grantConnect(role); + + Template.synth(stack).toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: [ + "elasticache:Connect", + "elasticache:DescribeServerlessCaches", + ], + effect: "Allow", + resources: [stack.resolve(cache.serverlessCacheArn)], + }, + ], + }, + ); + }); + + test("grant adds custom IAM permissions", () => { + cache.grant(role, "elasticache:Connect"); + + Template.synth(stack).toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["elasticache:Connect"], + effect: "Allow", + resources: [stack.resolve(cache.serverlessCacheArn)], + }, + ], + }, + ); + }); + + // TERRACONSTRUCTS DEVIATION: upstream's "grant adds custom IAM permissions to L1" case passes a + // raw `CfnServerlessCache` (the CloudFormation L1) to `ServerlessCacheGrants.fromServerlessCache` + // to exercise the grants-collection's structural-typing contract against the generated L1 + // directly. There is no CFN-generated L1 analog in TerraConstructs -- instead this exercises the + // same structural-typing contract against a bare object satisfying `IServerlessCacheRef` + // (`{ serverlessCacheArn }`), which is exactly what the reconstructed + // `elasticache-grants.generated.ts` claims to accept in place of the stripped + // `elasticache.IServerlessCacheRef` marker interface. + test("grant adds custom IAM permissions via ServerlessCacheGrants.fromServerlessCache", () => { + const bareRef = { + serverlessCacheArn: + "arn:aws:elasticache:us-east-1:123456789012:serverlesscache/bare-cache", + }; + elasticache.ServerlessCacheGrants.fromServerlessCache(bareRef).actions( + role, + ["elasticache:Connect"], + ); + + Template.synth(stack).toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["elasticache:Connect"], + effect: "Allow", + resources: [bareRef.serverlessCacheArn], + }, + ], + }, + ); + }); + }); +}); diff --git a/test/aws/storage/elasticache/serverless-cache.test.ts b/test/aws/storage/elasticache/serverless-cache.test.ts new file mode 100644 index 00000000..4661153a --- /dev/null +++ b/test/aws/storage/elasticache/serverless-cache.test.ts @@ -0,0 +1,730 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/serverless-cache.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { + elasticacheServerlessCache, + vpcSecurityGroupIngressRule, +} from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as events from "../../../../src/aws/notify"; +import * as elasticache from "../../../../src/aws/storage/elasticache"; +import { Size } from "../../../../src/size"; +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", +}; + +// TERRACONSTRUCTS DEVIATION: upstream's `beforeEach` also calls +// `Validations.of(stack).acknowledge({ id: 'CloudFormation-Validate::F3032', ... })` (a CFN-template +// linting-rule acknowledgement, `aws-cdk-lib/core` `Validations`). This is a CFN/CDK-CLI-synth-time +// mechanism with no TerraConstructs equivalent (there is no template-linting `Validations` registry +// in this repo) -- identical omission pattern to `testStack()` in `../rds/cluster.test.ts` and +// `../docdb/cluster.test.ts`. +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +describe("ServerlessCache", () => { + describe("isServerlessCache", () => { + let stack: AwsStack; + let vpc: compute.Vpc; + beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); + }); + + test("returns true for ServerlessCache instances", () => { + const cache = new elasticache.ServerlessCache(stack, "Cache", { vpc }); + + expect(elasticache.ServerlessCache.isServerlessCache(cache)).toBe(true); + }); + + test("returns false for non-ServerlessCache objects", () => { + expect(elasticache.ServerlessCache.isServerlessCache({})).toBe(false); + expect(elasticache.ServerlessCache.isServerlessCache(null)).toBe(false); + expect(elasticache.ServerlessCache.isServerlessCache(undefined)).toBe( + false, + ); + expect(elasticache.ServerlessCache.isServerlessCache("string")).toBe( + false, + ); + expect(elasticache.ServerlessCache.isServerlessCache(123)).toBe(false); + }); + + test("returns false for imported serverless caches (not actual ServerlessCache instances)", () => { + const importedCache = elasticache.ServerlessCache.fromServerlessCacheName( + stack, + "ImportedCache", + "my-serverless-cache", + ); + + expect(elasticache.ServerlessCache.isServerlessCache(importedCache)).toBe( + false, + ); + }); + }); + + describe("create valid templates", () => { + let stack: AwsStack; + let vpc: compute.Vpc; + beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); + }); + + test("import serverless cache", () => { + const cache = elasticache.ServerlessCache.fromServerlessCacheAttributes( + stack, + "ImportedCache", + { + serverlessCacheName: "my-serverless-cache", + }, + ); + + expect(cache.serverlessCacheArn).toEqual( + `arn:${stack.partition}:elasticache:${stack.region}:${stack.account}:serverlesscache/my-serverless-cache`, + ); + }); + + test.each([ + [elasticache.CacheEngine.VALKEY_LATEST], + [elasticache.CacheEngine.VALKEY_8], + [elasticache.CacheEngine.VALKEY_7], + [elasticache.CacheEngine.REDIS_LATEST], + [elasticache.CacheEngine.REDIS_7], + [elasticache.CacheEngine.MEMCACHED_LATEST], + [elasticache.CacheEngine.MEMCACHED_1_6], + ])("import serverless cache for %s", (cacheEngine) => { + const cache = elasticache.ServerlessCache.fromServerlessCacheAttributes( + stack, + "ImportedCache", + { + serverlessCacheName: "my-serverless-cache", + engine: cacheEngine, + }, + ); + + expect(cache.serverlessCacheArn).toEqual( + `arn:${stack.partition}:elasticache:${stack.region}:${stack.account}:serverlesscache/my-serverless-cache`, + ); + }); + + test("create serverless cache with full props", () => { + const key = new encryption.Key(stack, "Key"); + const securityGroup = new compute.SecurityGroup(stack, "SecurityGroup", { + vpc, + }); + const userGroup = new elasticache.UserGroup(stack, "UserGroup", {}); + + const cache = new elasticache.ServerlessCache(stack, "Cache", { + description: "Serverless cache", + vpc, + engine: elasticache.CacheEngine.VALKEY_8, + serverlessCacheName: "serverelessCache", + kmsKey: key, + vpcSubnets: { subnetType: compute.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [securityGroup], + userGroup, + backup: { + backupRetentionLimit: 2, + }, + cacheUsageLimits: { + dataStorageMinimumSize: Size.gibibytes(1), + dataStorageMaximumSize: Size.gibibytes(1), + requestRateLimitMinimum: 1_000, + requestRateLimitMaximum: 1_000, + }, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheServerlessCache.ElasticacheServerlessCache, + { + description: "Serverless cache", + engine: "valkey", + major_engine_version: "8", + // key ARN, not bare id -- provider read-back parity (see the DEVIATION in + // serverless-cache.ts; live-caught inconsistent-result error otherwise) + kms_key_id: stack.resolve(key.keyArn), + name: "serverelesscache", + snapshot_retention_limit: 2, + user_group_id: userGroup.userGroupName, + cache_usage_limits: [ + { + data_storage: [ + { + minimum: 1, + maximum: 1, + unit: "GB", + }, + ], + ecpu_per_second: [ + { + minimum: 1_000, + maximum: 1_000, + }, + ], + }, + ], + }, + ); + // TERRACONSTRUCTS DEVIATION: upstream also asserts `FinalSnapshotName: 'last-snapshot-name'` + // (`backup.backupNameBeforeDeletion`) -- omitted here, see the TODO on `BackupSettings` in + // `../../../../src/aws/storage/elasticache/serverless-cache.ts` (no Terraform-provider + // equivalent for `aws_elasticache_serverless_cache`). + expect(cache.serverlessCacheName).toEqual("serverelesscache"); + // `backup.backupArnsToRestore` was not supplied above, so `backupArnsToRestore` must reflect + // that -- it must NOT read the provider's `snapshot_arns_to_restore` computed attribute + // getter, which always returns a non-empty token list regardless of input. + expect(cache.backupArnsToRestore).toBeUndefined(); + }); + + test("backupArnsToRestore reflects the supplied backup.backupArnsToRestore", () => { + const arns = ["arn:aws:s3:::my-bucket/snapshot1"]; + const cache = new elasticache.ServerlessCache(stack, "Cache", { + vpc, + backup: { + backupArnsToRestore: arns, + }, + }); + + expect(cache.backupArnsToRestore).toEqual(arns); + }); + + test("serverlessCacheStatus resolves to the resource's computed status attribute", () => { + const cache = new elasticache.ServerlessCache(stack, "Cache", { + vpc, + }); + + expect(stack.resolve(cache.serverlessCacheStatus)).toEqual( + stack.resolve(cache.resource.status), + ); + }); + + test("correctly creates a serverless cache with a deploy-time value for its name", () => { + const parameter = new TerraformVariable(stack, "Parameter", { + type: "string", + }); + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + serverlessCacheName: parameter.stringValue, + }); + + // A Token-valued name must be passed through untouched -- lowercasing it would corrupt the + // cdktn `${TfToken[...]}` marker and break Terraform's reference resolution. + Template.synth(stack).toHaveResourceWithProperties( + elasticacheServerlessCache.ElasticacheServerlessCache, + { + name: stack.resolve(parameter.stringValue), + }, + ); + }); + + test.each([ + [elasticache.CacheEngine.VALKEY_LATEST, "valkey", undefined], + [elasticache.CacheEngine.VALKEY_8, "valkey", "8"], + [elasticache.CacheEngine.VALKEY_7, "valkey", "7"], + [elasticache.CacheEngine.REDIS_LATEST, "redis", undefined], + [elasticache.CacheEngine.REDIS_7, "redis", "7"], + [elasticache.CacheEngine.MEMCACHED_LATEST, "memcached", undefined], + [elasticache.CacheEngine.MEMCACHED_1_6, "memcached", "1.6"], + ])( + "test serverless cache version for %s", + (cacheEngine, engine, version) => { + new elasticache.ServerlessCache(stack, "Cache", { + description: "Serverless cache", + vpc, + engine: cacheEngine, + }); + + const t = new Template(stack); + const [resource] = t.resourceTypeArray( + elasticacheServerlessCache.ElasticacheServerlessCache, + ) as any[]; + expect(resource.description).toEqual("Serverless cache"); + expect(resource.engine).toEqual(engine); + expect(resource.major_engine_version).toEqual(version); + }, + ); + + test("backup.backupTime formats an hour/minute cron schedule to daily_snapshot_time", () => { + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + backup: { + backupTime: events.Schedule.cron({ hour: "12", minute: "5" }), + }, + }); + + Template.synth(stack).toHaveResourceWithProperties( + elasticacheServerlessCache.ElasticacheServerlessCache, + { + daily_snapshot_time: "12:05", + }, + ); + }); + + test("backup.backupTime throws when the schedule specifies more than hour/minute", () => { + expect( + () => + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + backup: { + backupTime: events.Schedule.cron({ + hour: "12", + minute: "0", + weekDay: "MON", + }), + }, + }), + ).toThrow( + "For now, only daily backup time is available (supports just hour and minute). Day, month, year, and weekDay are not allowed", + ); + }); + + test("allow security group ingress with endpoint port", () => { + const cache = new elasticache.ServerlessCache(stack, "Cache", { + vpc, + }); + new compute.SecurityGroup(stack, "SecurityGroup", { + vpc, + }).connections.allowToDefaultPort(cache); + + const t = new Template(stack); + // TERRACONSTRUCTS DEVIATION: upstream asserts `AWS::EC2::SecurityGroupIngress` with + // `FromPort`/`ToPort` set to `{ 'Fn::GetAtt': ['Cache18F6EE16', 'Endpoint.Port'] }` -- the + // Terraform AWS provider represents security group ingress rules as a standalone + // `aws_vpc_security_group_ingress_rule` resource with `from_port`/`to_port` fields. + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupIngressRule.VpcSecurityGroupIngressRule, + { + from_port: stack.resolve(cache.serverlessCacheEndpointPort), + to_port: stack.resolve(cache.serverlessCacheEndpointPort), + }, + ); + }); + }); + + describe("validation errors", () => { + let stack: AwsStack; + let vpc: compute.Vpc; + beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); + }); + + const descriptionLength = 256; + test.each([ + { + testDescription: + "when the description is longer than the max(255) characters throws validation error", + description: "A".repeat(descriptionLength), + errorMessage: `Description must not exceed 255 characters, currently has ${descriptionLength}`, + }, + { + testDescription: + "when the description has < characters throws validation error", + description: "<", + errorMessage: "Description must not contain < or > characters", + }, + { + testDescription: + "when the description has > characters throws validation error", + description: ">", + errorMessage: "Description must not contain < or > characters", + }, + ])("$testDescription", ({ description, errorMessage }) => { + expect( + () => + new elasticache.ServerlessCache(stack, "cache", { + description, + vpc, + }), + ).toThrow(errorMessage); + }); + + const invalidMinStorageSize = Size.bytes(0); + const invalidMaxStorageSize = Size.gibibytes(5_001); + const dataStorageTest = [ + { + testDescription: + "when the minimum data storage is smaller than the minimum(1 GB) throws validation error", + cacheUsageLimits: { dataStorageMinimumSize: invalidMinStorageSize }, + errorMessage: "Data storage minimum must be between 1 and 5000 GB.", + }, + { + testDescription: + "when the minimum data storage is larger than the maximum(5000 GB) throws validation error", + cacheUsageLimits: { dataStorageMinimumSize: invalidMaxStorageSize }, + errorMessage: "Data storage minimum must be between 1 and 5000 GB.", + }, + { + testDescription: + "when the maximum data storage is larger than the maximum(5000 GB) throws validation error", + cacheUsageLimits: { dataStorageMaximumSize: invalidMaxStorageSize }, + errorMessage: "Data storage maximum must be between 1 and 5000 GB.", + }, + { + testDescription: + "when the maximum data storage is smaller than the minimum(1 GB) throws validation error", + cacheUsageLimits: { dataStorageMaximumSize: invalidMinStorageSize }, + errorMessage: "Data storage maximum must be between 1 and 5000 GB.", + }, + { + testDescription: + "when the minimum data storage is larger than maximum data storage throws validation error", + cacheUsageLimits: { + dataStorageMinimumSize: Size.gibibytes(100), + dataStorageMaximumSize: Size.gibibytes(99), + }, + errorMessage: "Data storage minimum cannot be greater than maximum", + }, + ]; + + const invalidMinRequestRate = 999; + const invalidMaxRequestRate = 15_000_001; + const requestRateLimitTests = [ + { + testDescription: + "when the minimum request rate is smaller than minimum(1,000) throws validation error", + cacheUsageLimits: { + requestRateLimitMinimum: invalidMinRequestRate, + }, + errorMessage: + "Request rate minimum must be between 1,000 and 15,000,000 ECPUs per second", + }, + { + testDescription: + "when the minimum request rate is larger than maximum(15,000,000) throws validation error", + cacheUsageLimits: { + requestRateLimitMinimum: invalidMaxRequestRate, + }, + errorMessage: + "Request rate minimum must be between 1,000 and 15,000,000 ECPUs per second", + }, + { + testDescription: + "when the maximum request rate is smaller than minimum(1,000) throws validation error", + cacheUsageLimits: { + requestRateLimitMaximum: invalidMinRequestRate, + }, + errorMessage: + "Request rate maximum must be between 1,000 and 15,000,000 ECPUs per second", + }, + { + testDescription: `when the maximum request rate is ${invalidMaxRequestRate} throws validation error`, + cacheUsageLimits: { + requestRateLimitMaximum: invalidMaxRequestRate, + }, + errorMessage: + "Request rate maximum must be between 1,000 and 15,000,000 ECPUs per second", + }, + { + testDescription: + "when the minimum request rate is larger than maximum request rate throws validation error", + cacheUsageLimits: { + requestRateLimitMinimum: 2_000, + requestRateLimitMaximum: 1_000, + }, + errorMessage: "Request rate minimum cannot be greater than maximum", + }, + ]; + + test.each([...dataStorageTest, ...requestRateLimitTests])( + "$testDescription", + ({ cacheUsageLimits, errorMessage }) => { + expect( + () => + new elasticache.ServerlessCache(stack, "cache", { + vpc, + cacheUsageLimits, + }), + ).toThrow(errorMessage); + }, + ); + + test.each([ + { + testDescription: + "when the backup retention limit is smaller than minimum(1) throws validation error", + backup: { + backupRetentionLimit: 0, + }, + errorMessage: "Backup retention limit must be between 1 and 35 days", + }, + { + testDescription: + "when the backup retention limit is larger than maximum(35) throws validation error", + backup: { + backupRetentionLimit: 36, + }, + errorMessage: "Backup retention limit must be between 1 and 35 days", + }, + // TERRACONSTRUCTS DEVIATION: upstream also has four `backupNameBeforeDeletion`-format test + // cases here (name starting with a number / ending with a hyphen / consecutive hyphens / + // containing a dollar sign) -- omitted, see the TODO on `BackupSettings` in + // `../../../../src/aws/storage/elasticache/serverless-cache.ts` (`backupNameBeforeDeletion` + // has no Terraform-provider equivalent for `aws_elasticache_serverless_cache`, so its + // format-validation logic was not ported either). + ])("$testDescription", ({ backup, errorMessage }) => { + expect( + () => + new elasticache.ServerlessCache(stack, "cache", { + vpc, + backup, + }), + ).toThrow(errorMessage); + }); + + test.each([ + { + testDescription: + "when a user group passed to Mem cache throws validation error", + engine: elasticache.CacheEngine.MEMCACHED_LATEST, + userGroupProps: {}, + errorMessage: + "User groups cannot be used with Memcached engines. Only Redis and Valkey engines support user groups.", + }, + { + testDescription: + "when a valkey user group passed to Redis cache throws validation error", + engine: elasticache.CacheEngine.REDIS_LATEST, + userGroupProps: { + engine: elasticache.UserEngine.VALKEY, + }, + errorMessage: "Redis cache can only use Redis user groups.", + }, + ])("$testDescription", ({ engine, userGroupProps, errorMessage }) => { + const userGroup = new elasticache.UserGroup( + stack, + "UserGroup", + userGroupProps, + ); + expect( + () => + new elasticache.ServerlessCache(stack, "cache", { + vpc, + engine, + userGroup, + }), + ).toThrow(errorMessage); + }); + + test.each([ + { + testDescription: + "when passing cache name & cache arn throws validation error", + serverlessCacheArn: + "arn:aws:elasticache:us-east-1:999999999999:serverlesscache:cachename", + serverlessCacheName: "cachename", + errorMessage: + "Only one of serverlessCacheArn or serverlessCacheName can be provided.", + }, + { + testDescription: + "when passing none of cache name or cache arn throws validation error", + errorMessage: + "One of serverlessCacheName or serverlessCacheArn is required.", + }, + { + testDescription: + "when passing cache arn invalid(has no cache name) throws validation error", + serverlessCacheArn: + "arn:aws:elasticache:us-east-1:999999999999:serverlesscache", + errorMessage: "Unable to extract serverless cache name from ARN.", + }, + ])( + "$testDescription", + ({ serverlessCacheName, serverlessCacheArn, errorMessage }) => { + expect(() => + elasticache.ServerlessCache.fromServerlessCacheAttributes( + stack, + "ImportedCache", + { serverlessCacheArn, serverlessCacheName }, + ), + ).toThrow(errorMessage); + }, + ); + }); + + describe("CacheEngine class", () => { + let stack: AwsStack; + let vpc: compute.Vpc; + beforeEach(() => { + stack = testStack(); + vpc = new compute.Vpc(stack, "VPC"); + }); + + test("of() returns an instance with the expected engineType and majorEngineVersion", () => { + const engine = elasticache.CacheEngine.of("valkey", "8"); + expect(engine.engineType).toBe("valkey"); + expect(engine.majorEngineVersion).toBe("8"); + }); + + test("of() accepts an explicit engineType and majorEngineVersion", () => { + const engine = elasticache.CacheEngine.of("valkey", "9"); + expect(engine.engineType).toBe("valkey"); + expect(engine.majorEngineVersion).toBe("9"); + }); + + test("of() accepts an undefined majorEngineVersion", () => { + const engine = elasticache.CacheEngine.of("valkey"); + expect(engine.engineType).toBe("valkey"); + expect(engine.majorEngineVersion).toBeUndefined(); + }); + + test("named static members expose the correct engineType and majorEngineVersion", () => { + expect(elasticache.CacheEngine.VALKEY_8.engineType).toBe("valkey"); + expect(elasticache.CacheEngine.VALKEY_8.majorEngineVersion).toBe("8"); + expect( + elasticache.CacheEngine.VALKEY_LATEST.majorEngineVersion, + ).toBeUndefined(); + expect(elasticache.CacheEngine.MEMCACHED_1_6.engineType).toBe( + "memcached", + ); + expect(elasticache.CacheEngine.MEMCACHED_1_6.majorEngineVersion).toBe( + "1.6", + ); + }); + + test("VALKEY_9 maps to engineType=valkey and majorEngineVersion=9", () => { + expect(elasticache.CacheEngine.VALKEY_9.engineType).toBe("valkey"); + expect(elasticache.CacheEngine.VALKEY_9.majorEngineVersion).toBe("9"); + }); + + test("toString() renders engineType_majorEngineVersion for versioned engines", () => { + expect(elasticache.CacheEngine.VALKEY_8.toString()).toBe("valkey_8"); + expect(elasticache.CacheEngine.MEMCACHED_1_6.toString()).toBe( + "memcached_1.6", + ); + }); + + test("toString() renders only engineType when majorEngineVersion is undefined", () => { + expect(elasticache.CacheEngine.VALKEY_LATEST.toString()).toBe("valkey"); + expect(elasticache.CacheEngine.REDIS_LATEST.toString()).toBe("redis"); + }); + + test("named static members synthesize with the expected Terraform properties", () => { + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + engine: elasticache.CacheEngine.VALKEY_9, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheServerlessCache.ElasticacheServerlessCache, + { + engine: "valkey", + major_engine_version: "9", + }, + ); + }); + + test("of() for an unknown major version synthesizes correctly", () => { + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + engine: elasticache.CacheEngine.of("valkey", "9"), + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheServerlessCache.ElasticacheServerlessCache, + { + engine: "valkey", + major_engine_version: "9", + }, + ); + }); + + test("MEMCACHED_1_6 with a user group throws MemcachedUserGroupNotSupported", () => { + const userGroup = new elasticache.UserGroup(stack, "UserGroup", {}); + + expect( + () => + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + engine: elasticache.CacheEngine.MEMCACHED_1_6, + userGroup, + }), + ).toThrow("User groups cannot be used with Memcached engines."); + }); + + test("REDIS_7 with a Valkey user group throws RedisUserGroupMismatch", () => { + const userGroup = new elasticache.UserGroup(stack, "UserGroup", { + engine: elasticache.UserEngine.VALKEY, + }); + + expect( + () => + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + engine: elasticache.CacheEngine.REDIS_7, + userGroup, + }), + ).toThrow("Redis cache can only use Redis user groups."); + }); + + test("imported cache with VALKEY_9 resolves the correct default port", () => { + const cache = elasticache.ServerlessCache.fromServerlessCacheAttributes( + stack, + "ImportedCache", + { + serverlessCacheName: "my-cache", + engine: elasticache.CacheEngine.VALKEY_9, + securityGroups: [new compute.SecurityGroup(stack, "SG", { vpc })], + }, + ); + + expect(cache.connections.defaultPort?.toString()).toContain("6379"); + }); + + test("of() does not return the same instance as a named static member", () => { + expect(elasticache.CacheEngine.of("valkey", "8")).not.toBe( + elasticache.CacheEngine.VALKEY_8, + ); + expect(elasticache.CacheEngine.of("valkey")).not.toBe( + elasticache.CacheEngine.VALKEY_LATEST, + ); + }); + + test("VALKEY_9 with a Redis user group synthesizes without error", () => { + const userGroup = new elasticache.UserGroup(stack, "UserGroup", { + engine: elasticache.UserEngine.REDIS, + users: [ + new elasticache.NoPasswordUser(stack, "DefaultUser", { + userId: "default", + userName: "default", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ], + }); + + expect( + () => + new elasticache.ServerlessCache(stack, "Cache", { + vpc, + engine: elasticache.CacheEngine.VALKEY_9, + userGroup, + }), + ).not.toThrow(); + }); + }); +}); diff --git a/test/aws/storage/elasticache/user-group.test.ts b/test/aws/storage/elasticache/user-group.test.ts new file mode 100644 index 00000000..a094faa3 --- /dev/null +++ b/test/aws/storage/elasticache/user-group.test.ts @@ -0,0 +1,743 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/user-group.test.ts + +import { elasticacheUserGroup } from "@cdktn/provider-aws"; +import { App, TerraformVariable, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as elasticache from "../../../../src/aws/storage/elasticache"; +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, + }); + // TODO: omitted — upstream's `beforeEach` also acknowledges the CDK `Validations` aspect + // `CloudFormation-Validate::F3032` ("Required array is empty") via `Validations.of(stack) + // .acknowledge(...)`. This is a CFN-template cfn-lint-integration validation aspect with no + // TerraConstructs equivalent (Terraform has no analogous synth-time template linter), so it is + // dropped rather than mapped — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-elasticache-alpha/test/user-group.test.ts#L9-L12 +}); + +describe("UserGroup", () => { + describe("validation errors", () => { + test.each([ + { + testDescription: + "when Redis user group contains non-Redis user throws validation error", + engine: elasticache.UserEngine.REDIS, + userEngine: elasticache.UserEngine.VALKEY, + errorMessage: "Redis user group can only contain Redis users.", + }, + ])("$testDescription", ({ engine, userEngine, errorMessage }) => { + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + engine: userEngine, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(() => { + new elasticache.UserGroup(stack, "TestUserGroup", { + engine, + users: [user], + }); + Template.fromStack(stack); + }).toThrow(errorMessage); + }); + + test("when Redis user group does not contain default user throws validation error", () => { + const users = [ + new elasticache.IamUser(stack, "TestUser1", { + userId: "user1", + userName: "user1", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ]; + + expect(() => { + new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.REDIS, + users: users, + }); + Template.fromStack(stack); + }).toThrow( + 'Redis user groups need to contain a user with the user name "default".', + ); + }); + + test("when Redis user group does not contain any users throws validation error", () => { + expect(() => { + new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.REDIS, + users: [], + }); + Template.fromStack(stack); + }).toThrow( + 'Redis user groups need to contain a user with the user name "default".', + ); + }); + + test("when Redis user group have users prop as undefined throws validation error", () => { + expect(() => { + new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.REDIS, + }); + Template.fromStack(stack); + }).toThrow( + 'Redis user groups need to contain a user with the user name "default".', + ); + }); + + test("when user group has duplicate usernames throws validation error", () => { + const users = [ + new elasticache.PasswordUser(stack, "TestUser1", { + userId: "user1", + userName: "duplicate-name", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["newpasswordforuser1"], + }), + new elasticache.PasswordUser(stack, "TestUser2", { + userId: "user2", + userName: "duplicate-name", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["newpasswordforuser2"], + }), + ]; + + expect(() => { + new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.VALKEY, + users: users, + }); + Template.fromStack(stack); + }).toThrow("User group cannot have users with the same user name."); + }); + + test("when REDIS user group has duplicate usernames and do not contain default user throws validation error", () => { + const users = [ + new elasticache.NoPasswordUser(stack, "TestUser1", { + userId: "user1", + userName: "duplicate-name", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + new elasticache.NoPasswordUser(stack, "TestUser2", { + userId: "user2", + userName: "duplicate-name", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }), + ]; + + expect(() => { + new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.REDIS, + users: users, + }); + Template.fromStack(stack); + }).toThrow("User group cannot have users with the same user name."); + }); + + test.each([ + { + testDescription: + "when passing both userGroupName and userGroupArn throws validation error", + userGroupArn: + "arn:aws:elasticache:us-east-1:999999999999:usergroup:test-group", + userGroupName: "test-group", + errorMessage: + "Only one of userGroupArn or userGroupName can be provided.", + }, + { + testDescription: + "when passing neither userGroupName nor userGroupArn throws validation error", + errorMessage: "One of userGroupName or userGroupArn is required.", + }, + { + testDescription: + "when passing invalid userGroupArn (no group name) throws validation error", + userGroupArn: "arn:aws:elasticache:us-east-1:999999999999:usergroup", + errorMessage: "Unable to extract user group name from ARN.", + }, + ])("$testDescription", ({ userGroupArn, userGroupName, errorMessage }) => { + expect(() => + elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { userGroupArn, userGroupName }, + ), + ).toThrow(errorMessage); + }); + + test("when adding non-Redis user to Redis group throws validation error", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.REDIS, + }); + const valkeyUser = new elasticache.IamUser(stack, "ValkeyUser", { + userId: "valkey-user", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(() => { + userGroup.addUser(valkeyUser); + Template.fromStack(stack); + }).toThrow("Redis user group can only contain Redis users."); + }); + }); + + describe("constructor", () => { + test("creates Valkey user group with minimal required properties", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup"); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + engine: "valkey", + user_group_id: stack.resolve(userGroup.userGroupName), + }, + ); + }); + + test("creates Redis user group with empty UserIds array when the input users property is empty", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + users: [], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + engine: "valkey", + user_group_id: stack.resolve(userGroup.userGroupName), + user_ids: [], + }, + ); + }); + + test("creates Redis user group with minimal required properties", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "default", + engine: elasticache.UserEngine.REDIS, + accessControl: elasticache.AccessControl.fromAccessString( + "on ~app:* +@read +@write", + ), + }); + + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.REDIS, + users: [user], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + engine: "redis", + user_group_id: stack.resolve(userGroup.userGroupName), + user_ids: [stack.resolve(user.userId)], + }, + ); + }); + + test("creates user group with all possible properties", () => { + const user = new elasticache.PasswordUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["secretvalue-123456"], + }); + + new elasticache.UserGroup(stack, "TestUserGroup", { + userGroupName: "my-user-group", + engine: elasticache.UserEngine.VALKEY, + users: [user], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + engine: "valkey", + user_group_id: "my-user-group", + user_ids: ["test-user"], + }, + ); + }); + + test("creates Valkey user group with both Redis and Valkey users", () => { + const redisUser = new elasticache.NoPasswordUser(stack, "RedisUser", { + userId: "redis-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const valkeyUser1 = new elasticache.PasswordUser(stack, "ValkeyUser1", { + userId: "valkey-user1", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + passwords: ["secretvalue-123456"], + }); + + const valkeyUser2 = new elasticache.IamUser(stack, "ValkeyUser2", { + userId: "valkey-user2", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + users: [redisUser, valkeyUser1, valkeyUser2], + }); + + expect(userGroup.users).toHaveLength(3); + expect(userGroup.users![0].userId).toBe("redis-user"); + expect(userGroup.users![1].userId).toBe("valkey-user1"); + expect(userGroup.users![2].userId).toBe("valkey-user2"); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + engine: "valkey", + user_ids: [ + stack.resolve(redisUser.userId), + stack.resolve(valkeyUser1.userId), + stack.resolve(valkeyUser2.userId), + ], + }, + ); + }); + + test("creates exactly one ElastiCache user group resource", () => { + new elasticache.UserGroup(stack, "TestUserGroup"); + + const t = new Template(stack); + t.resourceCountIs(elasticacheUserGroup.ElasticacheUserGroup, 1); + }); + }); + + describe("properties", () => { + test("exposes correct properties", () => { + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.VALKEY, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + userGroupName: "my-group", + engine: elasticache.UserEngine.VALKEY, + users: [user], + }); + + expect(userGroup.userGroupName).toBe("my-group"); + expect(userGroup.engine?.engineType).toBe("valkey"); + expect(userGroup.users).toHaveLength(1); + expect(userGroup.users![0].userId).toBe("test-user"); + expect(userGroup.userGroupArn).toBeDefined(); + // TODO: omitted — upstream asserts `userGroup.userGroupStatus` is defined. + // `userGroupStatus` has no Terraform-provider equivalent and is dropped from this port — see + // the `TODO: omitted` note on `UserGroup` in `../../../../src/aws/storage/elasticache/ + // user-group.ts`. + }); + + test("defaults to Valkey engine when not specified", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup"); + + expect(userGroup.engine).toBe(elasticache.UserEngine.VALKEY); + }); + + test("generates userGroupName when not provided", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup"); + + expect(userGroup.userGroupName).toBeDefined(); + expect(typeof userGroup.userGroupName).toBe("string"); + }); + + // TERRACONSTRUCTS DEVIATION: upstream's "show what the token actually contains" test resolves + // a plain-CDK `Names.uniqueResourceName()`-derived Token to the exact string `'testusergroup'`. + // This repo's `stack.uniqueResourceName()` uses a different (gridUUID/path-hash-based) + // algorithm — see the `TERRACONSTRUCTS DEVIATION` note on `UserGroup.userGroupName` in + // `../../../../src/aws/storage/elasticache/user-group.ts` — so the resolved value only needs + // to be a lowercase, deterministic string derived from the construct path, not the literal + // upstream value. + test("generated userGroupName is a lowercase, deterministic value", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup"); + + const resolved = stack.resolve(userGroup.userGroupName); + expect(resolved).toEqual(resolved.toLowerCase()); + expect(resolved.length).toBeLessThanOrEqual(40); + + // deterministic: an identically-named construct in a fresh stack resolves the same way + const otherApp = Testing.app(); + const otherStack = new AwsStack(otherApp, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + const otherUserGroup = new elasticache.UserGroup( + otherStack, + "TestUserGroup", + ); + expect(otherStack.resolve(otherUserGroup.userGroupName)).toEqual( + resolved, + ); + }); + + // TERRACONSTRUCTS DEVIATION: upstream only lowercases the GENERATED name (`Names + // .uniqueResourceName(...).toLocaleLowerCase()`); an explicit `userGroupName` is passed through + // verbatim. This port lowercases BOTH cases -- see the `TERRACONSTRUCTS DEVIATION` note on + // `UserGroup.userGroupName` in `../../../../src/aws/storage/elasticache/user-group.ts` -- to + // avoid a perpetual Terraform diff against the provider-side lowercased `UserGroupId`. + test("lowercases an explicit mixed-case userGroupName to avoid a perpetual Terraform diff", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + userGroupName: "MyUserGroup", + }); + + expect(userGroup.userGroupName).toEqual("myusergroup"); + Template.synth(stack).toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + user_group_id: "myusergroup", + }, + ); + }); + + test("correctly creates a user group with a deploy-time value for its name", () => { + const parameter = new TerraformVariable(stack, "Parameter", { + type: "string", + }); + new elasticache.UserGroup(stack, "TestUserGroup", { + userGroupName: parameter.stringValue, + }); + + // A Token-valued name must be passed through untouched -- lowercasing it would corrupt the + // cdktn `${TfToken[...]}` marker and break Terraform's reference resolution. + Template.synth(stack).toHaveResourceWithProperties( + elasticacheUserGroup.ElasticacheUserGroup, + { + user_group_id: stack.resolve(parameter.stringValue), + }, + ); + }); + }); + + describe("addUser", () => { + test("adds user to group successfully", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.VALKEY, + }); + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + userGroup.addUser(user); + + expect(userGroup.users).toHaveLength(1); + expect(userGroup.users![0].userId).toBe(user.userId); + }); + + test("adds second user to group that already has one user", () => { + const existingUser = new elasticache.NoPasswordUser( + stack, + "ExistingUser", + { + userId: "existing-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }, + ); + + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup", { + engine: elasticache.UserEngine.VALKEY, + users: [existingUser], + }); + + const newUser = new elasticache.NoPasswordUser(stack, "NewUser", { + userId: "new-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + userGroup.addUser(newUser); + + expect(userGroup.users).toHaveLength(2); + expect(userGroup.users![0].userId).toBe("existing-user"); + expect(userGroup.users![1].userId).toBe("new-user"); + }); + }); + + describe("isUserGroup", () => { + test("returns true for UserGroup instances", () => { + const userGroup = new elasticache.UserGroup(stack, "TestUserGroup"); + + expect(elasticache.UserGroup.isUserGroup(userGroup)).toBe(true); + }); + + test("returns false for non-UserGroup objects", () => { + expect(elasticache.UserGroup.isUserGroup({})).toBe(false); + expect(elasticache.UserGroup.isUserGroup(null)).toBe(false); + expect(elasticache.UserGroup.isUserGroup(undefined)).toBe(false); + expect(elasticache.UserGroup.isUserGroup("string")).toBe(false); + expect(elasticache.UserGroup.isUserGroup(123)).toBe(false); + }); + + test("returns false for imported user groups (not actual UserGroup instances)", () => { + const importedUserGroup = elasticache.UserGroup.fromUserGroupName( + stack, + "ImportedUserGroup", + "test-group", + ); + + expect(elasticache.UserGroup.isUserGroup(importedUserGroup)).toBe(false); + }); + }); + + describe("import methods", () => { + test("fromUserGroupAttributes works with valid userGroupArn", () => { + const userGroup = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { + userGroupArn: + "arn:aws:elasticache:us-east-1:123456789012:usergroup:my-group", + }, + ); + + expect(userGroup.userGroupName).toBe("my-group"); + expect(userGroup.userGroupArn).toBe( + "arn:aws:elasticache:us-east-1:123456789012:usergroup:my-group", + ); + expect(userGroup.engine).toBe(undefined); + expect(userGroup.users).toBe(undefined); + }); + + test("fromUserGroupAttributes works with userGroupName only", () => { + const userGroup = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { + userGroupName: "imported-group", + }, + ); + + expect(userGroup.userGroupName).toBe("imported-group"); + expect(userGroup.userGroupArn).toContain("imported-group"); + expect(userGroup.engine).toBe(undefined); + expect(userGroup.users).toBe(undefined); + }); + + test("fromUserGroupAttributes preserves engine when provided", () => { + const userGroup = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { + userGroupName: "test-group", + engine: elasticache.UserEngine.REDIS, + }, + ); + + expect(userGroup.engine).toBe(elasticache.UserEngine.REDIS); + }); + + test("fromUserGroupAttributes preserves users when provided", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const userGroup = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { + userGroupName: "test-group", + users: [user], + }, + ); + + expect(userGroup.users).toHaveLength(1); + expect(userGroup.users![0].userId).toBe(user.userId); + }); + + test("fromUserGroupAttributes works with both engine and users", () => { + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const userGroup = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { + userGroupName: "test-group", + engine: elasticache.UserEngine.VALKEY, + users: [user], + }, + ); + + expect(userGroup.userGroupName).toBe("test-group"); + expect(userGroup.userGroupArn).toContain("test-group"); + expect(userGroup.engine).toBe(elasticache.UserEngine.VALKEY); + expect(userGroup.users).toHaveLength(1); + expect(userGroup.users![0].userId).toBe(user.userId); + }); + + test("fromUserGroupAttributes with userGroupArn preserves additional attributes", () => { + const arn = + "arn:aws:elasticache:us-east-1:123456789012:usergroup:my-group"; + const user = new elasticache.NoPasswordUser(stack, "TestUser", { + userId: "test-user", + engine: elasticache.UserEngine.REDIS, + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + const userGroup = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "ImportedUserGroup", + { + userGroupArn: arn, + engine: elasticache.UserEngine.VALKEY, + users: [user], + }, + ); + + expect(userGroup.userGroupArn).toBe(arn); + expect(userGroup.userGroupName).toBe("my-group"); + expect(userGroup.engine?.engineType).toBe("valkey"); + expect(userGroup.users).toHaveLength(1); + expect(userGroup.users![0].userId).toBe(user.userId); + }); + + test("fromUserGroupName creates user group with correct properties", () => { + const userGroup = elasticache.UserGroup.fromUserGroupName( + stack, + "ImportedUserGroup", + "my-group-name", + ); + + expect(userGroup.userGroupName).toBe("my-group-name"); + expect(userGroup.userGroupArn).toContain("my-group-name"); + expect(userGroup.engine).toBe(undefined); + expect(userGroup.users).toBe(undefined); + }); + + test("fromUserGroupArn creates user group with correct properties", () => { + const arn = + "arn:aws:elasticache:us-west-2:123456789012:usergroup:test-group"; + const userGroup = elasticache.UserGroup.fromUserGroupArn( + stack, + "ImportedUserGroup", + arn, + ); + + expect(userGroup.userGroupName).toBe("test-group"); + expect(userGroup.userGroupArn).toBe(arn); + expect(userGroup.engine).toBe(undefined); + expect(userGroup.users).toBe(undefined); + }); + + test("imported user groups cannot add users", () => { + const importedUserGroup = elasticache.UserGroup.fromUserGroupName( + stack, + "ImportedUserGroup", + "test-group", + ); + const user = new elasticache.IamUser(stack, "TestUser", { + userId: "test-user", + accessControl: + elasticache.AccessControl.fromAccessString("on ~* +@all"), + }); + + expect(() => importedUserGroup.addUser(user)).toThrow( + "Cannot add users to an imported UserGroup. Only UserGroups created in this stack can be modified.", + ); + }); + }); + + describe("UserEngine class", () => { + test("of() returns an instance with the expected engineType", () => { + const engine = elasticache.UserEngine.of("redis"); + expect(engine.engineType).toBe("redis"); + }); + + test("of() supports arbitrary engines", () => { + const engine = elasticache.UserEngine.of("futureengine"); + expect(engine.engineType).toBe("futureengine"); + }); + + test("named static members expose the correct engineType", () => { + expect(elasticache.UserEngine.VALKEY.engineType).toBe("valkey"); + expect(elasticache.UserEngine.REDIS.engineType).toBe("redis"); + }); + + test("toString() returns the engineType", () => { + expect(elasticache.UserEngine.VALKEY.toString()).toBe("valkey"); + expect(elasticache.UserEngine.REDIS.toString()).toBe("redis"); + }); + + test("of() does not return the same instance as a named static member", () => { + expect(elasticache.UserEngine.of("redis")).not.toBe( + elasticache.UserEngine.REDIS, + ); + expect(elasticache.UserEngine.of("valkey")).not.toBe( + elasticache.UserEngine.VALKEY, + ); + }); + + test("fromUserGroupAttributes preserves engine when constructed via UserEngine.of()", () => { + const customEngine = elasticache.UserEngine.of("redis"); + const imported = elasticache.UserGroup.fromUserGroupAttributes( + stack, + "Imported", + { + userGroupName: "my-group", + engine: customEngine, + }, + ); + + expect(imported.engine).toBe(customEngine); + expect(imported.engine?.engineType).toBe("redis"); + }); + }); +});