diff --git a/examples/aws-ec2-privatelink/main.tf b/examples/aws-ec2-privatelink/main.tf index 27b1d37..1ac7ff1 100644 --- a/examples/aws-ec2-privatelink/main.tf +++ b/examples/aws-ec2-privatelink/main.tf @@ -39,7 +39,7 @@ module "privatelink" { vpc_id = module.rdi_quickstart_postgres.vpc_id subnets = module.rdi_quickstart_postgres.vpc_public_subnets target_type = "instance" - target = module.rdi_quickstart_postgres.instance_id + targets = [module.rdi_quickstart_postgres.instance_id] security_groups = [module.rdi_quickstart_postgres.security_group_id] allowed_principals = [var.redis_privatelink_arn] } diff --git a/examples/aws-rds-privatelink/.gitignore b/examples/aws-rds-privatelink/.gitignore new file mode 100644 index 0000000..a8711e0 --- /dev/null +++ b/examples/aws-rds-privatelink/.gitignore @@ -0,0 +1,8 @@ +.terraform +.idea +producer +producer-west +userdata.tgz +scripts +mysql.log +*.tfstate.*.backup diff --git a/examples/aws-rds-privatelink/.terraform.lock.hcl b/examples/aws-rds-privatelink/.terraform.lock.hcl new file mode 100644 index 0000000..46343a3 --- /dev/null +++ b/examples/aws-rds-privatelink/.terraform.lock.hcl @@ -0,0 +1,83 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + hashes = [ + "h1:A7EnRBVm4h9ryO9LwxYnKr4fy7ExPMwD5a1DsY7m1Y0=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = ">= 4.66.0, ~> 5.0, >= 5.30.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.4" + hashes = [ + "h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=", + "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", + "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", + "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", + "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", + "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", + "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", + "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", + "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", + "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", + "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + constraints = "~> 3.0" + hashes = [ + "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} diff --git a/examples/aws-rds-privatelink/README.md b/examples/aws-rds-privatelink/README.md new file mode 100644 index 0000000..54a772d --- /dev/null +++ b/examples/aws-rds-privatelink/README.md @@ -0,0 +1,37 @@ +# AWS RDI RDS PrivateLink Demo + +This directory contains example Terraform to connect Redis Cloud RDI to an Aurora Postgres RDS database and handle failover. + +This blog post from AWS documents the architecture: https://aws.amazon.com/blogs/database/access-amazon-rds-across-vpcs-using-aws-privatelink-and-network-load-balancer/ + +## Setup + +To use the example Terraform you must have: +- [Terraform](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli) +- [AWS CLI](https://aws.amazon.com/cli/) +- [AWS credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html) + +Run `terraform init` to initialize the Terraform repository. This is only necessary the first time you use the repo. + +## Usage + +Copy the values from the Redis Cloud RDI UI into `example.tfvars`. + +Run `terraform apply -var-file example.tfvars` + +## Connecting to the database + +You can connect to the postgres database directly from your laptop by running `./psql.sh`. + +## Tearing down + +Run `terraform destroy -var-file example.tfvars` to destroy the resources. + +## Submodules + +There are 4 submodules which can be reused: + +- `aws-rds-chinook` - creates a VPC, Security Group and RDS database with 2 instances +- `aws-rds-lambda` - creates a Lambda function to update the Load Balancer target group based on SNS events from RDS +- `aws-privatelink` - creates a Network Load Balancer and PrivateLink Service Endpoint to permit connectivity from Redis Cloud to the database +- `aws-secret-manager` - creates a Secret Manager secret with IAM permissions to work with Redis Cloud diff --git a/examples/aws-rds-privatelink/example.tfvars b/examples/aws-rds-privatelink/example.tfvars new file mode 100644 index 0000000..08335a4 --- /dev/null +++ b/examples/aws-rds-privatelink/example.tfvars @@ -0,0 +1,6 @@ +region = "us-east-1" +azs = ["use1-az2", "use1-az4", "use1-az6"] +port = 5432 +name = "rdi-rds" +redis_secrets_arn = "" +redis_privatelink_arn = "" diff --git a/examples/aws-rds-privatelink/inputs.tf b/examples/aws-rds-privatelink/inputs.tf new file mode 100644 index 0000000..b4c214a --- /dev/null +++ b/examples/aws-rds-privatelink/inputs.tf @@ -0,0 +1,31 @@ +variable "region" { + type = string +} + +variable "port" { + type = number +} + +variable "name" { + type = string +} + +variable "redis_secrets_arn" { + type = string + validation { + condition = var.redis_secrets_arn != "" + error_message = "redis_secrets_arn must be configured with the ARN from the UI" + } +} + +variable "redis_privatelink_arn" { + type = string + validation { + condition = var.redis_privatelink_arn != "" + error_message = "redis_privatelink_arn must be configured with the ARN from the UI" + } +} + +variable "azs" { + type = list(string) +} diff --git a/examples/aws-rds-privatelink/main.tf b/examples/aws-rds-privatelink/main.tf new file mode 100644 index 0000000..e92caa3 --- /dev/null +++ b/examples/aws-rds-privatelink/main.tf @@ -0,0 +1,76 @@ +terraform { + required_version = ">= 1.5.7" + + backend "local" { + path = "producer/terraform.tfstate" + } + + required_providers { + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "aws" { + # Configure the region for the resources + region = var.region +} + +# Create an RDI quickstart Postgres database on RDS +module "rdi_quickstart_postgres" { + source = "../../modules/aws-rds-chinook" + + identifier = var.name + db_password = random_password.pg_password.result + db_port = var.port + azs = var.azs +} + +module "rds_lambda" { + source = "../../modules/aws-rds-lambda" + depends_on = [module.rdi_quickstart_postgres] + + identifier = var.name + elb_tg_arn = module.privatelink.tg_arn + db_endpoint = module.rdi_quickstart_postgres.rds_endpoint + rds_arn = module.rdi_quickstart_postgres.rds_arn + db_port = var.port +} + +# Create an NLB and PrivateLink Endpoint Service which allows secure connection to the database from Redis Cloud. +# This has no targets but we will add a Lambda function to update the target. +module "privatelink" { + source = "../../modules/aws-privatelink" + + identifier = var.name + port = var.port + vpc_id = module.rdi_quickstart_postgres.vpc_id + subnets = module.rdi_quickstart_postgres.vpc_public_subnets + target_type = "ip" + targets = [] + security_groups = [module.rdi_quickstart_postgres.security_group_id] + allowed_principals = [var.redis_privatelink_arn] +} + +# Create a secret in AWS Secret Manager with the database credentials +module "secret_manager" { + source = "../../modules/aws-secret-manager" + + # Because Secret Manager secrets are soft-deleted, add a random suffix to make the name unique. + # Otherwise running multiple apply-destroy cycles will fail because of the names conflicting. + identifier = "${var.name}-${random_id.secret_suffix.hex}" + allowed_principals = [var.redis_secrets_arn] + username = "postgres" + password = random_password.pg_password.result +} + +resource "random_id" "secret_suffix" { + byte_length = 8 +} + +resource "random_password" "pg_password" { + length = 16 + special = false +} diff --git a/examples/aws-rds-privatelink/outputs.tf b/examples/aws-rds-privatelink/outputs.tf new file mode 100644 index 0000000..7bfa46e --- /dev/null +++ b/examples/aws-rds-privatelink/outputs.tf @@ -0,0 +1,29 @@ +output "vpc_endpoint_service_name" { + value = module.privatelink.vpc_endpoint_service_name + description = "The VPC Endpoint service name for the database, to be configured in Redis Cloud" +} + +output "secret_arn" { + value = module.secret_manager.secret_arn + description = "The Secret Manager secret ARN, to be configured in Redis Cloud" +} + +output "database" { + value = "chinook" + description = "The name of the Postgres reference database" +} + +output "port" { + value = var.port + description = "The port for the NLB" +} + +output "password" { + value = random_password.pg_password.result + sensitive = true + description = "The postgres password. This is not used for RDI setup, only to connect to the DB with psql" +} + +output "psql_host" { + value = module.privatelink.lb_hostname +} diff --git a/examples/aws-rds-privatelink/psql.sh b/examples/aws-rds-privatelink/psql.sh new file mode 100755 index 0000000..40ce1b4 --- /dev/null +++ b/examples/aws-rds-privatelink/psql.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +psql "postgresql://postgres:$(terraform output -raw password)@$(terraform output -raw psql_host)/chinook" diff --git a/examples/aws-rds-privatelink/psql_setup.tf b/examples/aws-rds-privatelink/psql_setup.tf new file mode 100644 index 0000000..6590130 --- /dev/null +++ b/examples/aws-rds-privatelink/psql_setup.tf @@ -0,0 +1,19 @@ +resource "null_resource" "setup_chinook" { + depends_on = [ + module.rdi_quickstart_postgres, + module.rds_lambda + ] + provisioner "local-exec" { + environment = { + PGPASSWORD : nonsensitive(random_password.pg_password.result) + } + command = < mysql.log +EOF + } +} diff --git a/modules/aws-privatelink/inputs.tf b/modules/aws-privatelink/inputs.tf index 21b82b2..aa15213 100644 --- a/modules/aws-privatelink/inputs.tf +++ b/modules/aws-privatelink/inputs.tf @@ -8,9 +8,9 @@ variable "port" { description = "The port to listen on and forward to in the target group" } -variable "target" { - type = string - description = "The identifier of the load balancer target - this can be an ip or EC2 instance ID" +variable "targets" { + type = list(string) + description = "The identifier of the load balancer targets - this can be an ip or EC2 instance ID" } variable "target_type" { diff --git a/modules/aws-privatelink/load-balancer.tf b/modules/aws-privatelink/load-balancer.tf index c54c108..8fc702d 100644 --- a/modules/aws-privatelink/load-balancer.tf +++ b/modules/aws-privatelink/load-balancer.tf @@ -51,7 +51,9 @@ resource "aws_lb_target_group" "producer_tg" { # Attach the EC2 instance to the target group resource "aws_lb_target_group_attachment" "producer_tga" { + for_each = toset(var.targets) + target_group_arn = aws_lb_target_group.producer_tg.arn - target_id = var.target + target_id = each.value port = var.port } diff --git a/modules/aws-privatelink/output.tf b/modules/aws-privatelink/output.tf index 8c7aa79..6ffea73 100644 --- a/modules/aws-privatelink/output.tf +++ b/modules/aws-privatelink/output.tf @@ -12,3 +12,11 @@ output "vpc_endpoint_service_id" { value = aws_vpc_endpoint_service.producer_service.id description = "The ID of the VPC endpoint service - source Private Link service ID" } + +output "lb_hostname" { + value = aws_lb.producer_nlb.dns_name +} + +output "tg_arn" { + value = aws_lb_target_group.producer_tg.arn +} diff --git a/modules/aws-rds-chinook/data.tf b/modules/aws-rds-chinook/data.tf new file mode 100644 index 0000000..122e28d --- /dev/null +++ b/modules/aws-rds-chinook/data.tf @@ -0,0 +1,4 @@ +# Needed for subnet creation +data "aws_availability_zones" "available" { + state = "available" +} diff --git a/modules/aws-rds-chinook/input.tf b/modules/aws-rds-chinook/input.tf new file mode 100644 index 0000000..45f3997 --- /dev/null +++ b/modules/aws-rds-chinook/input.tf @@ -0,0 +1,44 @@ +variable "vpc_cidr" { + description = "The CIDR block for the VPC" + type = string + default = "10.0.0.0/20" +} + +variable "azs" { + description = "A list of availability zones to deploy the EKS cluster into" + type = list(string) + default = ["use1-az2", "use1-az4", "use1-az6"] +} + +variable "public_subnet_cidr" { + description = "The CIDR block for the public subnet" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] +} + +variable "private_subnet_cidr" { + description = "The CIDR block for the public subnet" + type = list(string) + default = ["10.0.7.0/24", "10.0.8.0/24", "10.0.9.0/24"] +} + +variable "database_subnet_cidr" { + description = "The CIDR block for the public subnet" + type = list(string) + default = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"] +} + +variable "identifier" { + description = "The identifier for the resources/test" + type = string +} + +variable "db_password" { + description = "The password for connecting to the Producer Source Database" + type = string +} + +variable "db_port" { + description = "The port for connecting to the Producer Source Database" + type = number +} diff --git a/modules/aws-rds-chinook/locals.tf b/modules/aws-rds-chinook/locals.tf new file mode 100644 index 0000000..1e701e2 --- /dev/null +++ b/modules/aws-rds-chinook/locals.tf @@ -0,0 +1,4 @@ +locals { + az_map = zipmap(data.aws_availability_zones.available.zone_ids, data.aws_availability_zones.available.names) + azs = [for az_id in var.azs : local.az_map[az_id]] +} diff --git a/modules/aws-rds-chinook/main.tf b/modules/aws-rds-chinook/main.tf new file mode 100644 index 0000000..34e9671 --- /dev/null +++ b/modules/aws-rds-chinook/main.tf @@ -0,0 +1,34 @@ +resource "aws_rds_cluster" "postgresql" { + cluster_identifier = "aurora-${var.identifier}" + engine = "aurora-postgresql" + database_name = "chinook" + master_username = "postgres" + master_password = var.db_password + backup_retention_period = 5 + preferred_backup_window = "07:00-09:00" + skip_final_snapshot = true + db_subnet_group_name = module.vpc.database_subnet_group_name + vpc_security_group_ids = [aws_security_group.producer_sg.id] + db_instance_parameter_group_name = aws_db_parameter_group.default.name +} + +resource "aws_db_parameter_group" "default" { + name = var.identifier + family = "postgres17" + + parameter { + name = "rds.logical_replication" + value = "1" + apply_method = "pending-reboot" + } +} + +resource "aws_rds_cluster_instance" "cluster_instances" { + count = 2 + identifier = "${var.identifier}-${count.index}" + cluster_identifier = aws_rds_cluster.postgresql.id + instance_class = "db.t4g.medium" + engine = aws_rds_cluster.postgresql.engine + engine_version = aws_rds_cluster.postgresql.engine_version + db_subnet_group_name = module.vpc.database_subnet_group_name +} diff --git a/modules/aws-rds-chinook/output.tf b/modules/aws-rds-chinook/output.tf new file mode 100644 index 0000000..7284afd --- /dev/null +++ b/modules/aws-rds-chinook/output.tf @@ -0,0 +1,19 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "vpc_public_subnets" { + value = module.vpc.public_subnets +} + +output "rds_arn" { + value = aws_rds_cluster.postgresql.arn +} + +output "rds_endpoint" { + value = aws_rds_cluster.postgresql.endpoint +} + +output "security_group_id" { + value = aws_security_group.producer_sg.id +} diff --git a/modules/aws-rds-chinook/security.tf b/modules/aws-rds-chinook/security.tf new file mode 100644 index 0000000..2ed4a7a --- /dev/null +++ b/modules/aws-rds-chinook/security.tf @@ -0,0 +1,27 @@ +# Creating the security group for the producer instance +resource "aws_security_group" "producer_sg" { + vpc_id = module.vpc.vpc_id + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + # To be able to connect to postgres from the LB + ingress { + from_port = var.db_port + to_port = var.db_port + protocol = "tcp" + self = true + } + # To be able to connect to postgres from the demo machine + ingress { + from_port = var.db_port + to_port = var.db_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + tags = { + Name = "producer-sg-${var.identifier}" + } +} diff --git a/modules/aws-rds-chinook/vpc.tf b/modules/aws-rds-chinook/vpc.tf new file mode 100644 index 0000000..d9204e8 --- /dev/null +++ b/modules/aws-rds-chinook/vpc.tf @@ -0,0 +1,18 @@ +module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "5.8.1" + + name = "producer-vpc-${var.identifier}" + + cidr = var.vpc_cidr + map_public_ip_on_launch = true + + azs = local.azs + public_subnets = var.public_subnet_cidr + private_subnets = var.private_subnet_cidr + database_subnets = var.database_subnet_cidr + + tags = { + Name = "producer-vpc-${var.identifier}" + } +} diff --git a/modules/aws-rds-lambda/.gitignore b/modules/aws-rds-lambda/.gitignore new file mode 100644 index 0000000..5405084 --- /dev/null +++ b/modules/aws-rds-lambda/.gitignore @@ -0,0 +1 @@ +function.zip diff --git a/modules/aws-rds-lambda/iam.tf b/modules/aws-rds-lambda/iam.tf new file mode 100644 index 0000000..04ad814 --- /dev/null +++ b/modules/aws-rds-lambda/iam.tf @@ -0,0 +1,59 @@ +# Lambda Execution Role +resource "aws_iam_role" "lambda_execution_role" { + name = "${var.identifier}-lambda-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy" "ec2_elb_lambda_execution_role_policy" { + name = "${var.identifier}-ec2-elb-lambda-execution-role-policy" + role = aws_iam_role.lambda_execution_role.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ec2:CreateNetworkInterface", + "ec2:DeleteNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DeregisterTargets", + "elasticloadbalancing:RegisterTargets" + ] + Resource = "*" + } + ] + }) +} + +resource "aws_iam_role_policy" "log_group_lambda_execution_role_policy" { + name = "${var.identifier}-log-group-lambda-execution-role-policy" + role = aws_iam_role.lambda_execution_role.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "*" + } + ] + }) +} + diff --git a/modules/aws-rds-lambda/inputs.tf b/modules/aws-rds-lambda/inputs.tf new file mode 100644 index 0000000..3c5d09e --- /dev/null +++ b/modules/aws-rds-lambda/inputs.tf @@ -0,0 +1,20 @@ +variable "identifier" { + description = "The identifier for the resources/test" + type = string +} + +variable "db_endpoint" { + type = string +} + +variable "db_port" { + type = number +} + +variable "elb_tg_arn" { + type = string +} + +variable "rds_arn" { + type = string +} diff --git a/modules/aws-rds-lambda/lambda.py b/modules/aws-rds-lambda/lambda.py new file mode 100644 index 0000000..7724398 --- /dev/null +++ b/modules/aws-rds-lambda/lambda.py @@ -0,0 +1,80 @@ +import os +import json +import socket +import sys +import boto3 + +# Get Environment varilables +vCluster = os.environ.get('Cluster_EndPoint') +vELB_arn = os.environ.get('NLB_TG_ARN') +vNewPort = os.environ.get('RDS_Port') +client = boto3.client('elbv2') + +def lambda_handler(event, context): + + # DeRegister old IP from NLB + def deregister_oldip(vOldIp, vOldPort, vOldAZ): + response = client.deregister_targets( + TargetGroupArn=vELB_arn, + Targets=[ + { + 'Id': vOldIp, + 'Port': vOldPort, + 'AvailabilityZone': vOldAZ + }, + ] + ) + +# Register new IP to NLB + def register_newip(vNewIP, vNewPort): + response = client.register_targets( + TargetGroupArn=vELB_arn, + Targets=[ + { + 'Id': vNewIP, + 'Port': int(vNewPort) + }, + ] + ) + +# Get Master Node IP address + vNewIP = socket.gethostbyname_ex(vCluster) + IPs = vNewIP[2] + print('IP list from DNS: ', IPs) + +# Get Registered IP detail from NLB + dictNLB = client.describe_target_health( + TargetGroupArn=vELB_arn + ) + + ip_list = [] + for i in dictNLB['TargetHealthDescriptions']: + ip = i.get('Target').get('Id') + ip_list.append(ip) + + if not ip_list: + for nIP in IPs: + print('Register New IP ', nIP, 'Port: ', vNewPort) + register_newip(nIP, vNewPort) + + DeRegisterIP = set(ip_list) - set(IPs) + RegisterIP = set(IPs) - set(ip_list) + + if DeRegisterIP: + print('IP: ', str(DeRegisterIP), ' will be DeRegistered from NLB Target') + + if RegisterIP: + print('IP: ', str(RegisterIP), ' will be registered to NLB Target') + + for nIP in RegisterIP: + print('Registering New IP ', nIP, 'Port: ', vNewPort) + register_newip(nIP, vNewPort) + + for oIP in dictNLB['TargetHealthDescriptions']: + vOldIp = oIP.get('Target').get('Id') + vOldPort = oIP.get('Target').get('Port') + vOldAZ = oIP.get('Target').get('AvailabilityZone') + print('IP list from NLB Target Group: ', vOldIp) + if vOldIp in DeRegisterIP: + print('DeRegister IP: ', vOldIp, 'Port; ', vOldPort, 'AZ: ', vOldAZ) + deregister_oldip(vOldIp, vOldPort, vOldAZ) diff --git a/modules/aws-rds-lambda/lambda.tf b/modules/aws-rds-lambda/lambda.tf new file mode 100644 index 0000000..9f0b813 --- /dev/null +++ b/modules/aws-rds-lambda/lambda.tf @@ -0,0 +1,29 @@ +resource "aws_lambda_function" "rdi_failover_lambda" { + filename = data.archive_file.rdi_failover_lambda.output_path + function_name = var.identifier + role = aws_iam_role.lambda_execution_role.arn + handler = "lambda.lambda_handler" + environment { + variables = { + Cluster_EndPoint = var.db_endpoint + RDS_Port = var.db_port + NLB_TG_ARN = var.elb_tg_arn + } + } + runtime = "python3.12" + timeout = 300 + memory_size = 128 + + depends_on = [aws_cloudwatch_log_group.rdi] +} + +resource "aws_cloudwatch_log_group" "rdi" { + name = "/aws/lambda/${var.identifier}" + retention_in_days = 14 +} + +data "archive_file" "rdi_failover_lambda" { + type = "zip" + source_file = "${path.module}/lambda.py" + output_path = "${path.module}/function.zip" +} diff --git a/modules/aws-rds-lambda/sns.tf b/modules/aws-rds-lambda/sns.tf new file mode 100644 index 0000000..d1db5e8 --- /dev/null +++ b/modules/aws-rds-lambda/sns.tf @@ -0,0 +1,60 @@ +# SNS Topic +resource "aws_sns_topic" "rdi_failover_topic" { + name = var.identifier + display_name = "RDI Failover Topic" +} + +resource "aws_sns_topic_policy" "rdi_failover_topic_policy" { + arn = aws_sns_topic.rdi_failover_topic.arn + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = "*" + } + Action = [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish", + "SNS:Receive" + ] + Resource = aws_sns_topic.rdi_failover_topic.arn + } + ] + }) +} + +resource "aws_sns_topic_subscription" "rdi_failover_subscription" { + topic_arn = aws_sns_topic.rdi_failover_topic.arn + protocol = "lambda" + endpoint = aws_lambda_function.rdi_failover_lambda.arn +} + +resource "aws_db_event_subscription" "rds_cluster_failover_event" { + name = "${var.identifier}-rds-cluster-events" + sns_topic = aws_sns_topic.rdi_failover_topic.arn + event_categories = ["creation", "failover", "failure"] + source_type = "db-cluster" + source_ids = [split(".", var.db_endpoint)[0]] + enabled = true +} + +resource "aws_lambda_invocation" "initial" { + function_name = aws_lambda_function.rdi_failover_lambda.function_name + input = "{}" +} + +resource "aws_lambda_permission" "with_sns" { + statement_id = "AllowExecutionFromSNS" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.rdi_failover_lambda.arn + principal = "sns.amazonaws.com" + source_arn = aws_sns_topic.rdi_failover_topic.arn +}