diff --git a/elasticache/common.go b/elasticache/common.go index fb2a15f..67cd181 100644 --- a/elasticache/common.go +++ b/elasticache/common.go @@ -1,8 +1,9 @@ package elasticache import ( + "crypto/md5" + "encoding/hex" "os" - "strings" "github.com/aws/aws-cdk-go/awscdk/v2/awsec2" "github.com/aws/aws-cdk-go/awscdk/v2/awselasticache" @@ -11,15 +12,19 @@ import ( ) // ResourceID returns an ID that can be used to uniquely identify resources built with the given prefix -func ResourceID(prefix string) string { - id := os.Getenv("ACORN_EXTERNAL_ID") - id = strings.ReplaceAll(id, ".", "") - - if len(id) > 40 { - return id[:40] - } else { - return id +func ResourceID(clusterName string, prefix string) *string { + externalIdHash := md5.Sum([]byte(os.Getenv("ACORN_EXTERNAL_ID"))) + clusterName = clusterName + "-" + hex.EncodeToString(externalIdHash[:]) + + if prefix != "" { + clusterName = prefix + "-" + clusterName + } + + if len(clusterName) > 40 { + clusterName = clusterName[:40] } + + return jsii.String(clusterName) } // GetPrivateSubnetGroup returns a new subnet group for the given elasticache stack @@ -31,7 +36,7 @@ func GetPrivateSubnetGroup(scope constructs.Construct, name *string, vpc awsec2. } subnetGroup := awselasticache.NewCfnSubnetGroup(scope, name, &awselasticache.CfnSubnetGroupProps{ - CacheSubnetGroupName: jsii.String(ResourceID("Sg")), + CacheSubnetGroupName: name, Description: jsii.String("Acorn created Elasticache subnet group."), SubnetIds: &privateSubnetIDs, }) diff --git a/elasticache/common_test.go b/elasticache/common_test.go new file mode 100644 index 0000000..83e0d30 --- /dev/null +++ b/elasticache/common_test.go @@ -0,0 +1,23 @@ +package elasticache + +import ( + "os" + "testing" +) + +func TestResourceID(t *testing.T) { + err := os.Setenv("ACORN_EXTERNAL_ID", "totally-real-and-cool-external-id-123") + if err != nil { + t.Fatal(err) + } + + id := ResourceID("Redis", "Sng") + if len(*id) == 0 || len(*id) > 40 { + t.Errorf("invalid ID %s", *id) + } + + idAgain := ResourceID("Redis", "Sng") + if *id != *idAgain { + t.Error("expected matching IDs") + } +} diff --git a/elasticache/redis/Acornfile b/elasticache/redis/Acornfile index 4b64120..a6baab1 100644 --- a/elasticache/redis/Acornfile +++ b/elasticache/redis/Acornfile @@ -1,29 +1,10 @@ name: "AWS Elasticache Redis" description: "Amazon's Redis compatible Elasticache cluster" info: "\(localData.info)" -readme: "./README.md" - -localData: info: """ - ## How To Use ([examples](https://github.com/acorn-io/aws/tree/main/elasticache/redis/examples)) - - 1) Link your app with this acorn via an `external` service named "redis". - - ```typescript - services: redis: { - external: "@{acorn.name}" - } - containers: app: { - build: context: "./" - ports: publish: ["8080/http"] - env: { - REDIS_URL: "redis://:@{@{service.}redis.secrets.admin.token}@{@{service.}redis.address}:@{@{service.}redis.port}/0" - } - } - ``` - """ +readme: "./README.md" args: { - // Name assigned to the cluster during creation. Default value is "RedisCluster". + // Name assigned to the cluster during creation alongside a unique ID. Default value is "Redis". clusterName: "Redis" // Key value pairs to apply to all resources. @@ -37,6 +18,9 @@ args: { // The number of cache nodes used in the elasticache cluster. Default value is 1. Automatic failover is enabled for values >1. Cluster mode is disabled so its a single primary with read replicas. numNodes: 1 + + // Do not take a final snapshot on delete or update and replace operations. Default value is false. If skip is enabled your data since last snapshot will be gone forever if deleted or replaced. + skipSnapshotOnDelete: false } services: admin: { @@ -113,3 +97,24 @@ secrets: "aws-context": { "aws-region": "" } } + +localData: info: """ + ## How To Use ([examples](https://github.com/acorn-io/aws/tree/main/elasticache/redis/examples)) + + 1) Link your app with this acorn via an `external` service named "redis". + + ```typescript + services: redis: { + external: "@{acorn.name}" + } + containers: app: { + build: context: "./" + ports: publish: ["8080/http"] + env: { + REDIS_HOST: "@{@{service.}redis.address}" + REDIS_PORT: "@{@{service.}redis.data.port}" + REDIS_PASSWORD: "@{@{service.}redis.secrets.admin.token}" + } + } + ``` + """ diff --git a/elasticache/redis/examples/echo-app/Acornfile b/elasticache/redis/examples/echo-app/Acornfile index 4c55fae..294b152 100644 --- a/elasticache/redis/examples/echo-app/Acornfile +++ b/elasticache/redis/examples/echo-app/Acornfile @@ -1,22 +1,16 @@ services: redis: build: { - context: "../../" + context: "../../" acornfile: "../../Acornfile" } -containers: { - app: { - build: { - context: "." - } - ports: publish: ["5000/http"] - if args.dev { - dirs: { - "/src": "./" - } - } - env: { - REDIS_HOST: "@{service.redis.address}" - REDIS_PASSWORD: "@{service.redis.secrets.admin.token}" - } - } +containers: app: { + build: context: "." + ports: publish: ["5000/http"] + if args.dev { + dirs: "/src": "./" + } + env: { + REDIS_HOST: "@{service.redis.address}" + REDIS_PASSWORD: "@{service.redis.secrets.admin.token}" + } } diff --git a/elasticache/redis/examples/echo-app/GettingStarted.md b/elasticache/redis/examples/echo-app/GettingStarted.md deleted file mode 100644 index b4e5a6c..0000000 --- a/elasticache/redis/examples/echo-app/GettingStarted.md +++ /dev/null @@ -1,11 +0,0 @@ -# Welcome To Acorn Getting Started - -## Login - -First lets login to Acorn hub, open the terminal below, and run: - -```bash -acorn login acorn.io -``` - -This will prompt you to follow a URL to complete the login process. Once logged in, the page will tell you to return here. diff --git a/elasticache/redis/redis.go b/elasticache/redis/redis.go index 9ed73ef..22eed25 100644 --- a/elasticache/redis/redis.go +++ b/elasticache/redis/redis.go @@ -14,10 +14,11 @@ import ( type redisStackProps struct { awscdk.StackProps - ClusterName string `json:"clusterName" yaml:"clusterName"` - UserTags map[string]string `json:"tags" yaml:"tags"` - NodeType string `json:"nodeType" yaml:"nodeType"` - NumNodes int `json:"numNodes" yaml:"numNodes"` + ClusterName string `json:"clusterName" yaml:"clusterName"` + UserTags map[string]string `json:"tags" yaml:"tags"` + NodeType string `json:"nodeType" yaml:"nodeType"` + NumNodes int `json:"numNodes" yaml:"numNodes"` + SkipSnapshotOnDelete bool `json:"skipSnapshotOnDelete" yaml:"skipSnapshotOnDelete"` } // NewRedisStack creates the new Redis stack @@ -36,10 +37,10 @@ func NewRedisStack(scope constructs.Construct, id string, props *redisStackProps }) // get the subnet group - subnetGroup := elasticache.GetPrivateSubnetGroup(stack, jsii.String(props.ClusterName+"SubnetGroup"), vpc) + subnetGroup := elasticache.GetPrivateSubnetGroup(stack, elasticache.ResourceID(props.ClusterName, "Sng"), vpc) // get the security group - sg := common.GetAllowAllVPCSecurityGroup(stack, jsii.String(props.ClusterName+"SecurityGroup"), jsii.String("Acorn generated Elasticache security group"), vpc, 6379) + sg := common.GetAllowAllVPCSecurityGroup(stack, elasticache.ResourceID(props.ClusterName, "Scg"), jsii.String("Acorn generated Elasticache security group"), vpc, 6379) vpcSecurityGroupIDs := make([]*string, 0) vpcSecurityGroupIDs = append(vpcSecurityGroupIDs, sg.SecurityGroupId()) @@ -58,7 +59,7 @@ func NewRedisStack(scope constructs.Construct, id string, props *redisStackProps // it might seem like creating a replication group is not the same as creating a cluster // but actually it creates the cluster and the replication group in one go redisRG := awselasticache.NewCfnReplicationGroup(stack, jsii.String(props.ClusterName), &awselasticache.CfnReplicationGroupProps{ - ReplicationGroupId: jsii.String(elasticache.ResourceID("Rg")), + ReplicationGroupId: elasticache.ResourceID(props.ClusterName, ""), ReplicationGroupDescription: jsii.String("Acorn created Redis replication group"), Engine: jsii.String("redis"), CacheNodeType: jsii.String(props.NodeType), @@ -71,8 +72,20 @@ func NewRedisStack(scope constructs.Construct, id string, props *redisStackProps SecurityGroupIds: &vpcSecurityGroupIDs, AuthToken: token.SecretValue().ToString(), Port: jsii.Number(6379), + SnapshotRetentionLimit: jsii.Number(1), // how many days to retain snapshots }) + // indicate that the subnet group depends on the cluster + // this prevents deletion errors caused by attempted subnet group deletes while the cluster still exists + redisRG.AddDependency(subnetGroup) + + if !props.SkipSnapshotOnDelete { + // indicate that the cluster should be backed up before deletion + redisRG.ApplyRemovalPolicy(awscdk.RemovalPolicy_SNAPSHOT, &awscdk.RemovalPolicyOptions{ + ApplyToUpdateReplacePolicy: jsii.Bool(true), + }) + } + // output the cluster details awscdk.NewCfnOutput(stack, jsii.String("clustername"), &awscdk.CfnOutputProps{ Value: jsii.String(props.ClusterName),