diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index d6a09d2..cf8c2a5 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,7 +5,19 @@ "packages": { "": { "dependencies": { - "@opencode-ai/plugin": "1.16.2" + "@opencode-ai/plugin": "1.18.14" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { @@ -87,19 +99,20 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.16.2.tgz", - "integrity": "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg==", + "version": "1.18.14", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.14.tgz", + "integrity": "sha512-M7gmzsQTE6z81fMZb8AaNd3u/FdQ8h7hhtQ59rWHnZMFMWusHXZM2S2BL1Z3E8kGWeveVluOVfTKtxJhCurXtw==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.16.2", - "effect": "4.0.0-beta.74", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.14", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.3.2", - "@opentui/keymap": ">=0.3.2", - "@opentui/solid": ">=0.3.2" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -114,9 +127,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.16.2.tgz", - "integrity": "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg==", + "version": "1.18.14", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.14.tgz", + "integrity": "sha512-Yd8vPDT5DW++Hs2904Q/Vvk/m1U79aKFSRusgAfBQ+AGhfoT40LdeL5eiJtVy0xBUIZF/8DEZjkWaeof9Y59PA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -153,9 +166,9 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.74", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.74.tgz", - "integrity": "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA==", + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -171,9 +184,9 @@ } }, "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { "type": "individual", @@ -213,6 +226,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", @@ -220,9 +239,9 @@ "license": "Apache-2.0" }, "node_modules/msgpackr": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.2.tgz", - "integrity": "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", "license": "MIT", "optionalDependencies": { "msgpackr-extract": "^3.0.4" @@ -251,9 +270,9 @@ } }, "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", "license": "MIT" }, "node_modules/node-gyp-build-optional-packages": { @@ -281,9 +300,9 @@ } }, "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", "funding": [ { "type": "individual", @@ -318,18 +337,18 @@ } }, "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", "license": "MIT", "engines": { "node": ">=20" } }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/README.md b/README.md index 6c32d19..9916659 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This repository contains reusable infrastructure modules designed for enterprise | `database/` | `aurora` | AWS Aurora clusters (MySQL, PostgreSQL, Serverless v2, Global Database) (includes `rvn-aurora` module definition) | v1.1.0 | | `database/` | `dynamodb` | AWS DynamoDB tables | v1.0.0 | | `database/` | `rds` | AWS RDS instances | v1.1.0 | +| `database/` | `rds-proxy` | AWS RDS Proxy for connection pooling in front of RDS instances or Aurora clusters (standalone or via the `rds`/`aurora` modules) (includes `rvn-rds-proxy` module definition) | v1.0.0 | | `hosting/` | `static_site` | Composite static site hosting (S3 + CloudFront + OAC, optional CloudFront Function / Lambda@Edge) | v1.0.0 | | `kubernetes/` | `eks_cluster` | AWS EKS clusters with OIDC, KMS-encrypted secrets, control plane logging, core add-ons, EBS CSI / Pod Identity Agent, LB Controller Pod Identity role, and access entries | v1.0.0 | | `kubernetes/` | `eks_node_group` | AWS EKS managed node groups (one per module) with IAM, optional launch template, taints, labels, and SPOT/ON_DEMAND capacity | v1.0.0 | @@ -59,7 +60,7 @@ sync by `node tools/ravion-modules/dist/src/cli.js readme` (enforced in CI, and | Definition | Name | Version | Module path | | ---------- | ---- | ------- | ----------- | | `rvn-acm-certificate` | ACM Certificate | v1.0.1 | `security/acm_certificate/` | -| `rvn-aurora` | Aurora Database | v1.1.1 | `database/aurora/` | +| `rvn-aurora` | Aurora Database | v1.2.0 | `database/aurora/` | | `rvn-aws-alb` | AWS Application Load Balancer | v1.0.1 | `networking/alb/` | | `rvn-aws-iam-policy` | AWS IAM Policy | v1.0.1 | `security/iam_policy/` | | `rvn-aws-iam-role` | AWS IAM Role | v1.0.1 | `security/iam/` | @@ -74,7 +75,8 @@ sync by `node tools/ravion-modules/dist/src/cli.js readme` (enforced in CI, and | `rvn-efs` | EFS File System | v1.0.1 | `storage/efs/` | | `rvn-elasticache` | ElastiCache | v1.0.1 | `cache/elasticache/` | | `rvn-lambda` | Lambda Function | v1.0.1 | `compute/lambda/` | -| `rvn-rds` | RDS Database | v1.1.1 | `database/rds/` | +| `rvn-rds` | RDS Database | v1.2.0 | `database/rds/` | +| `rvn-rds-proxy` | RDS Proxy | v0.1.0 | `database/rds-proxy/` | | `rvn-route53` | Route 53 DNS | v1.0.1 | `networking/route53/` | | `rvn-s3` | S3 Bucket | v1.0.1 | `storage/s3/` | | `rvn-stack` | Terraform Stack | v1.2.4 | `stack/terraform/` | diff --git a/database/aurora/.terraform.lock.hcl b/database/aurora/.terraform.lock.hcl index f4f76e7..e3d141e 100644 --- a/database/aurora/.terraform.lock.hcl +++ b/database/aurora/.terraform.lock.hcl @@ -3,9 +3,17 @@ provider "registry.opentofu.org/hashicorp/aws" { version = "6.31.0" - constraints = ">= 5.0.0" + constraints = ">= 6.0.0" hashes = [ + "h1:/NZ7N5KZ4AtbADMedSXuYDJ7UN1ID//Vm5Sg1FpJ01w=", "h1:GOVFUXeawJxB/a2xXeC8KysTDELEGo66HmHBnBiB9lk=", + "h1:MUX4jV+ZiFMjzUlOkWUbN8MBr0zTKHm+5HjKfvIUJVk=", + "h1:SQs/AJ+vD/NdenGjrwMv6lpVNvTtpkp2SDAVeo6QbGk=", + "h1:aVArahOuhTSr9mbzQjJ4TvHUl2u668P0/X8VJ4P47lg=", + "h1:d6Lvb7ZuKZkUXowkVDd3sNr3a/BoEFQe63YtfjtxD2U=", + "h1:jfvyLm1+0yRZGpXU9a1VAvPcq7DrZfADduCMmGkfMic=", + "h1:kPjuvs3kMhzZXw3X0bx3+AHgkWT+xRPz+vuL6BPhpA4=", + "h1:yNj5rY2CtV8331DHyQkCsBrgt8rAY/H8YESEm3df27c=", "zh:15038bcfac8cad8f1422d4e5e6d02aebed9b8cb67df5f2a9fec6823fbfefa6b1", "zh:5662e6801a6c5c0d9ac76b70ab77395702cf9ece871cd5c714038dfd97e58962", "zh:a4dbbe440e2bcc6d4770c96769963bf4b81a484ff9783251633a4564da2cf56b", diff --git a/database/aurora/README.md b/database/aurora/README.md index a458f3f..1a75be5 100644 --- a/database/aurora/README.md +++ b/database/aurora/README.md @@ -423,6 +423,18 @@ module "aurora" { | activity_stream_mode | Activity stream mode: sync or async. | `string` | `"async"` | no | | activity_stream_kms_key_id | KMS key ARN for activity stream (required when enabled). | `string` | `null` | no | | iam_role_associations | Map of IAM role associations (S3_IMPORT, S3_EXPORT, LAMBDA_INVOKE, etc.). | `map(object)` | `{}` | no | +| proxy_creation_enabled | Whether to create an RDS Proxy in front of the cluster. | `bool` | `false` | no | +| proxy_auth_secret_arns | Secrets Manager secret ARNs for proxy auth (defaults to the managed master user secret). | `list(string)` | `[]` | no | +| proxy_secret_kms_key_arns | KMS key ARNs used to encrypt the proxy auth secrets (customer-managed keys). | `list(string)` | `[]` | no | +| proxy_iam_auth_enabled | Require IAM authentication for proxy connections. | `bool` | `false` | no | +| proxy_tls_requirement_enabled | Require TLS for proxy connections. | `bool` | `true` | no | +| proxy_debug_logging_enabled | Log detailed proxy connection information to CloudWatch Logs. | `bool` | `false` | no | +| proxy_idle_client_timeout | Seconds a client connection can be idle before the proxy disconnects it. | `number` | `1800` | no | +| proxy_connection_borrow_timeout | Seconds the proxy waits for an available connection in the pool. | `number` | `120` | no | +| proxy_init_query | SQL statements the proxy runs when opening each new database connection. | `string` | `null` | no | +| proxy_max_connections_percent | Max proxy connection pool size (% of database max_connections). | `number` | `100` | no | +| proxy_max_idle_connections_percent | Max idle proxy connections (% of database max_connections). | `number` | `50` | no | +| proxy_session_pinning_filters | Session pinning filters (EXCLUDE_VARIABLE_SETS). | `list(string)` | `[]` | no | ## Outputs @@ -459,6 +471,9 @@ module "aurora" { | activity_stream_kinesis_stream_name | The Kinesis data stream name for activity stream. | | activity_stream_kms_key_id | The KMS key ID for activity stream. | | autoscaling_target_arn | The Application Auto Scaling target ARN. | +| proxy_endpoint | The RDS Proxy endpoint (null when no proxy is created). | +| proxy_arn | The RDS Proxy ARN (null when no proxy is created). | +| proxy_security_group_id | The RDS Proxy security group ID (null when no proxy is created). | ## Security Considerations diff --git a/database/aurora/cluster.tf b/database/aurora/cluster.tf index 4a2ddbc..77ccbfd 100644 --- a/database/aurora/cluster.tf +++ b/database/aurora/cluster.tf @@ -155,6 +155,16 @@ resource "aws_rds_cluster" "this" { condition = !var.activity_stream_enabled || var.activity_stream_kms_key_id != null error_message = "activity_stream_kms_key_id is required when activity_stream_enabled is true." } + + precondition { + condition = !var.proxy_creation_enabled || var.master_user_password_management_enabled || length(var.proxy_auth_secret_arns) > 0 + error_message = "proxy_auth_secret_arns is required when proxy_creation_enabled is true and master_user_password_management_enabled is false." + } + + precondition { + condition = !var.proxy_creation_enabled || length(var.subnet_ids) >= 2 + error_message = "At least 2 subnet_ids are required when proxy_creation_enabled is true." + } } depends_on = [ diff --git a/database/aurora/locals.tf b/database/aurora/locals.tf index 90c6adf..be12786 100644 --- a/database/aurora/locals.tf +++ b/database/aurora/locals.tf @@ -101,4 +101,16 @@ locals { ) instances = length(var.instances) > 0 ? var.instances : local.generated_instances + + # RDS Proxy + create_proxy = var.proxy_creation_enabled + proxy_engine_family = local.is_mysql ? "MYSQL" : "POSTGRESQL" + proxy_auth_secret_arns = ( + length(var.proxy_auth_secret_arns) > 0 ? var.proxy_auth_secret_arns : + var.master_user_password_management_enabled ? [for s in aws_rds_cluster.this.master_user_secret : s.secret_arn] : [] + ) + proxy_secret_kms_key_arns = distinct(concat( + var.proxy_secret_kms_key_arns, + try(startswith(var.master_user_secret_kms_key_id, "arn:"), false) ? [var.master_user_secret_kms_key_id] : [] + )) } diff --git a/database/aurora/outputs.tf b/database/aurora/outputs.tf index b798b55..5d19fd5 100644 --- a/database/aurora/outputs.tf +++ b/database/aurora/outputs.tf @@ -218,3 +218,22 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +################################################################################ +# RDS Proxy Outputs +################################################################################ + +output "proxy_endpoint" { + description = "The endpoint of the RDS Proxy. Null when no proxy is created." + value = local.create_proxy ? module.proxy[0].endpoint : null +} + +output "proxy_arn" { + description = "The ARN of the RDS Proxy. Null when no proxy is created." + value = local.create_proxy ? module.proxy[0].proxy_arn : null +} + +output "proxy_security_group_id" { + description = "The ID of the RDS Proxy security group. Null when no proxy is created." + value = local.create_proxy ? module.proxy[0].security_group_id : null +} diff --git a/database/aurora/rds_proxy.tf b/database/aurora/rds_proxy.tf new file mode 100644 index 0000000..05338cf --- /dev/null +++ b/database/aurora/rds_proxy.tf @@ -0,0 +1,43 @@ +################################################################################ +# RDS Proxy (optional) +################################################################################ + +module "proxy" { + count = local.create_proxy ? 1 : 0 + + source = "../rds-proxy" + + name = var.name + engine_family = local.proxy_engine_family + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + port = local.port + tags = var.tags + + db_cluster_identifier = aws_rds_cluster.this.cluster_identifier + + auth = [ + for arn in local.proxy_auth_secret_arns : { + secret_arn = arn + iam_auth = var.proxy_iam_auth_enabled ? "REQUIRED" : "DISABLED" + } + ] + secret_kms_key_arns = local.proxy_secret_kms_key_arns + + tls_requirement_enabled = var.proxy_tls_requirement_enabled + debug_logging_enabled = var.proxy_debug_logging_enabled + idle_client_timeout = var.proxy_idle_client_timeout + + connection_borrow_timeout = var.proxy_connection_borrow_timeout + init_query = var.proxy_init_query + max_connections_percent = var.proxy_max_connections_percent + max_idle_connections_percent = var.proxy_max_idle_connections_percent + session_pinning_filters = var.proxy_session_pinning_filters + + allowed_security_group_ids = var.allowed_security_group_ids + allowed_cidr_blocks = var.allowed_cidr_blocks + + # Registering an Aurora cluster as a proxy target requires the cluster to + # already have at least one instance. + depends_on = [aws_rds_cluster_instance.this] +} diff --git a/database/aurora/rvn-aurora-definition.yml b/database/aurora/rvn-aurora-definition.yml index c299709..4f0f42c 100644 --- a/database/aurora/rvn-aurora-definition.yml +++ b/database/aurora/rvn-aurora-definition.yml @@ -4,8 +4,8 @@ definition: description: Creates and manages an Amazon Aurora cluster. Supports Aurora PostgreSQL and Aurora MySQL with provisioned or Serverless v2 capacity. release: - version: 1.1.1 - description: Update Terraform module sources and documentation links to the renamed `ravionhq` GitHub organization. + version: 1.2.0 + description: Add optional RDS Proxy connection pooling. module: inputs: - id: network @@ -322,6 +322,115 @@ module: show_when: security_group_creation_enabled: true type: string_array + - id: section_proxy + label: Connection pooling + type: section + - default: false + description: Create an RDS Proxy in front of the cluster for connection pooling and improved failover handling. Applications keep connecting to the cluster endpoint until you update their connection string to the proxy endpoint output. + id: proxy_creation_enabled + label: RDS Proxy + type: boolean + - add_button_label: Add secret ARN + collapsible: true + description: Secrets Manager secrets containing database credentials for the proxy. Leave blank to use the managed master user secret. + id: proxy_auth_secret_arns + label: Auth secret ARNs + placeholder: arn:aws:secretsmanager:... + required: false + show_when: + proxy_creation_enabled: true + type: string_array + - add_button_label: Add KMS key ARN + collapsible: true + description: KMS keys used to encrypt the auth secrets when using customer-managed keys. The master user secret KMS key is included automatically. + id: proxy_secret_kms_key_arns + label: Auth secret KMS key ARNs + placeholder: arn:aws:kms:... + required: false + show_when: + proxy_creation_enabled: true + type: string_array + - collapsible: true + default: false + description: Require IAM authentication for client connections to the proxy. + id: proxy_iam_auth_enabled + label: IAM authentication + show_when: + proxy_creation_enabled: true + type: boolean + - collapsible: true + default: true + description: Require TLS for client connections to the proxy. + id: proxy_tls_requirement_enabled + label: Require TLS + show_when: + proxy_creation_enabled: true + type: boolean + - collapsible: true + default: false + description: Log detailed connection information, including SQL statements, to CloudWatch Logs. + id: proxy_debug_logging_enabled + label: Debug logging + show_when: + proxy_creation_enabled: true + type: boolean + - collapsible: true + default: 1800 + id: proxy_idle_client_timeout + label: Idle client timeout (seconds) + max: 28800 + min: 1 + show_when: + proxy_creation_enabled: true + type: number + - collapsible: true + default: 120 + id: proxy_connection_borrow_timeout + label: Connection borrow timeout (seconds) + min: 0 + show_when: + proxy_creation_enabled: true + type: number + - collapsible: true + default: 100 + description: Maximum size of the connection pool as a percentage of the database max_connections setting. + id: proxy_max_connections_percent + label: Max connections (%) + max: 100 + min: 1 + show_when: + proxy_creation_enabled: true + type: number + - collapsible: true + default: 50 + description: Maximum idle connections kept open, as a percentage of the database max_connections setting. + id: proxy_max_idle_connections_percent + label: Max idle connections (%) + max: 100 + min: 0 + show_when: + proxy_creation_enabled: true + type: number + - add_button_label: Add filter + collapsible: true + id: proxy_session_pinning_filters + label: Session pinning filters + required: false + show_when: + proxy_creation_enabled: true + type: string_array + values: + - label: EXCLUDE_VARIABLE_SETS + value: EXCLUDE_VARIABLE_SETS + - collapsible: true + description: SQL statements the proxy runs when opening each new database connection. + id: proxy_init_query + label: Initialization query + placeholder: SET time_zone = 'UTC' + required: false + show_when: + proxy_creation_enabled: true + type: string - id: section_readers label: Readers type: section @@ -1018,6 +1127,18 @@ module: preferred_backup_window: << module.input.preferred_backup_window >> preferred_maintenance_window: << module.input.preferred_maintenance_window >> promotion_tier: << module.input.promotion_tier >> + proxy_auth_secret_arns: << module.input.proxy_auth_secret_arns >> + proxy_connection_borrow_timeout: << module.input.proxy_connection_borrow_timeout >> + proxy_creation_enabled: << module.input.proxy_creation_enabled >> + proxy_debug_logging_enabled: << module.input.proxy_debug_logging_enabled >> + proxy_iam_auth_enabled: << module.input.proxy_iam_auth_enabled >> + proxy_idle_client_timeout: << module.input.proxy_idle_client_timeout >> + proxy_init_query: << module.input.proxy_init_query >> + proxy_max_connections_percent: << module.input.proxy_max_connections_percent >> + proxy_max_idle_connections_percent: << module.input.proxy_max_idle_connections_percent >> + proxy_secret_kms_key_arns: << module.input.proxy_secret_kms_key_arns >> + proxy_session_pinning_filters: << module.input.proxy_session_pinning_filters >> + proxy_tls_requirement_enabled: << module.input.proxy_tls_requirement_enabled >> public_access_enabled: << module.input.public_access_enabled >> reader_count: << module.input.reader_count >> reader_instance_class: << module.input.reader_instance_class >> @@ -1290,6 +1411,12 @@ module: The Ravion UI charts Aurora CloudWatch metrics for CPU utilization, freeable memory, database connections, serverless capacity and ACU utilization, storage used, read and write latency, replica lag, buffer cache hit ratio, commit latency, deadlocks, network throughput, and volume IOPS. + ## Connection pooling + + Enable RDS Proxy to place a managed connection pool in front of the cluster. The proxy authenticates with the Secrets Manager master user secret by default, requires TLS, and allows the same security group and CIDR sources configured for the cluster. Pool sizing, idle timeouts, session pinning, IAM authentication, and custom auth secrets are configurable in the Connection pooling section. + + Creating the proxy does not change how applications reach the cluster. Update your application connection string to the proxy endpoint output to route traffic through the pool. + ## Configuration | Field | Required | Default | Notes | @@ -1313,6 +1440,7 @@ module: | Deletion protection | No | true | Helps prevent accidental deletion. | | Performance insights | No | true | Enables AWS Performance Insights. | | CloudWatch alarms | No | true | Adds CPU, memory, and connection alarms. | + | RDS Proxy | No | false | Creates an RDS Proxy connection pool in front of the cluster. | | Global database | No | false | Create or join an Aurora global database. | | Activity stream | No | false | Enables Database Activity Streams with a required KMS key. | | Tags | No | Blank | Merged with Ravion standard tags. | diff --git a/database/aurora/security_group.tf b/database/aurora/security_group.tf index d834431..66c4217 100644 --- a/database/aurora/security_group.tf +++ b/database/aurora/security_group.tf @@ -48,3 +48,20 @@ module "security_group" { } ] } + +# Separate from the security group module so the cluster can depend on the SG +# without creating a cycle through the proxy target registration. +resource "aws_vpc_security_group_ingress_rule" "proxy" { + count = local.create_security_group && local.create_proxy ? 1 : 0 + + security_group_id = module.security_group[0].security_group_id + description = "Allow ${var.engine} traffic from the RDS Proxy" + from_port = local.port + to_port = local.port + ip_protocol = "tcp" + referenced_security_group_id = module.proxy[0].security_group_id + + tags = merge(local.tags, { + Name = "${var.name}-aurora-proxy-ingress" + }) +} diff --git a/database/aurora/tests/proxy.tftest.hcl b/database/aurora/tests/proxy.tftest.hcl new file mode 100644 index 0000000..4244ec0 --- /dev/null +++ b/database/aurora/tests/proxy.tftest.hcl @@ -0,0 +1,172 @@ +################################################################################ +# Aurora RDS Proxy Integration Tests +# +# Covers the optional RDS Proxy wiring: engine family mapping, auth secret +# resolution, preconditions, and that the combined plan graph (standalone +# proxy ingress rule, proxy targeting the cluster, proxy depending on cluster +# instances) resolves without dependency cycles. +################################################################################ + +mock_provider "aws" { + override_data { + target = data.aws_caller_identity.current + values = { + account_id = "123456789012" + } + } + + override_data { + target = data.aws_region.current + values = { + id = "us-east-1" + name = "us-east-1" + } + } + + override_data { + target = data.aws_vpc.this + values = { + cidr_block = "10.0.0.0/16" + } + } + + override_data { + target = module.proxy.data.aws_vpc.this + values = { + cidr_block = "10.0.0.0/16" + } + } + + # The proxy security group ID feeds the cluster security group's ingress + # rule validation, which requires an sg- prefix. + override_resource { + target = module.proxy.module.security_group.aws_security_group.this + values = { + id = "sg-proxy123456" + } + } + + override_resource { + target = module.proxy.aws_iam_role.this + values = { + arn = "arn:aws:iam::123456789012:role/test-aurora-rds-proxy" + } + } + + override_resource { + target = module.proxy.aws_db_proxy_default_target_group.this + values = { + name = "default" + } + } + + # The mock provider materializes computed lists as empty; provide the managed + # master user secret the proxy auth defaults to. + override_resource { + target = aws_rds_cluster.this + values = { + master_user_secret = [{ + kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-master-abc123" + secret_status = "active" + }] + } + } +} + +variables { + name = "test-aurora" + engine = "aurora-postgresql" + engine_version = "16.4" + instance_class = "db.t4g.medium" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + master_username = "dbadmin" +} + +run "test_proxy_not_created_by_default" { + command = plan + + assert { + condition = local.create_proxy == false + error_message = "Proxy should not be created by default." + } +} + +# Full plan with the proxy enabled. This exercises the complete dependency +# graph, including the proxy module's depends_on on cluster instances. +run "test_proxy_created_when_enabled" { + command = plan + + variables { + proxy_creation_enabled = true + } + + assert { + condition = local.create_proxy == true + error_message = "Proxy should be created when proxy_creation_enabled is true." + } + + assert { + condition = local.proxy_engine_family == "POSTGRESQL" + error_message = "aurora-postgresql should map to the POSTGRESQL engine family." + } +} + +run "test_proxy_engine_family_mysql" { + command = plan + + variables { + engine = "aurora-mysql" + proxy_creation_enabled = true + } + + assert { + condition = local.proxy_engine_family == "MYSQL" + error_message = "aurora-mysql should map to the MYSQL engine family." + } +} + +run "test_proxy_requires_secret_when_password_management_disabled" { + command = plan + + variables { + proxy_creation_enabled = true + master_user_password_management_enabled = false + master_password = "dummy-password-123" + } + + expect_failures = [ + aws_rds_cluster.this, + ] +} + +run "test_proxy_explicit_auth_secrets" { + command = plan + + variables { + proxy_creation_enabled = true + master_user_password_management_enabled = false + master_password = "dummy-password-123" + proxy_auth_secret_arns = ["arn:aws:secretsmanager:us-east-1:123456789012:secret:custom-abc123"] + } + + assert { + condition = local.proxy_auth_secret_arns[0] == "arn:aws:secretsmanager:us-east-1:123456789012:secret:custom-abc123" + error_message = "Explicit proxy auth secrets should be used." + } +} + +run "test_proxy_master_secret_kms_key_passthrough" { + command = plan + + variables { + proxy_creation_enabled = true + master_user_secret_kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + } + + assert { + condition = contains(local.proxy_secret_kms_key_arns, "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012") + error_message = "Master user secret KMS key ARN should be passed to the proxy." + } +} diff --git a/database/aurora/variables.tf b/database/aurora/variables.tf index 38b901c..f83a9fa 100644 --- a/database/aurora/variables.tf +++ b/database/aurora/variables.tf @@ -847,6 +847,117 @@ variable "iam_role_associations" { default = {} } +################################################################################ +# RDS Proxy +################################################################################ + +variable "proxy_creation_enabled" { + type = bool + description = "Whether to create an RDS Proxy in front of the cluster. Requires master_user_password_management_enabled or proxy_auth_secret_arns." + default = false +} + +variable "proxy_auth_secret_arns" { + type = list(string) + description = "List of Secrets Manager secret ARNs containing database credentials for the proxy. Defaults to the managed master user secret when master_user_password_management_enabled is true." + default = [] + + validation { + condition = alltrue([for arn in var.proxy_auth_secret_arns : startswith(arn, "arn:")]) + error_message = "All proxy_auth_secret_arns must be valid Secrets Manager secret ARNs." + } +} + +variable "proxy_secret_kms_key_arns" { + type = list(string) + description = "List of KMS key ARNs used to encrypt the proxy auth secrets when using customer-managed keys. The master user secret KMS key is included automatically." + default = [] + + validation { + condition = alltrue([for arn in var.proxy_secret_kms_key_arns : startswith(arn, "arn:")]) + error_message = "All proxy_secret_kms_key_arns must be valid KMS key ARNs." + } +} + +variable "proxy_iam_auth_enabled" { + type = bool + description = "Whether clients must use IAM authentication to connect to the proxy." + default = false +} + +variable "proxy_tls_requirement_enabled" { + type = bool + description = "Whether Transport Layer Security (TLS) encryption is required for connections to the proxy." + default = true +} + +variable "proxy_debug_logging_enabled" { + type = bool + description = "Whether the proxy logs detailed connection information, including SQL statements, to CloudWatch Logs." + default = false +} + +variable "proxy_idle_client_timeout" { + type = number + description = "The number of seconds a client connection to the proxy can be idle before the proxy disconnects it." + default = 1800 + + validation { + condition = var.proxy_idle_client_timeout >= 1 && var.proxy_idle_client_timeout <= 28800 + error_message = "The proxy_idle_client_timeout must be between 1 and 28800 seconds." + } +} + +variable "proxy_connection_borrow_timeout" { + type = number + description = "The number of seconds the proxy waits for a connection to become available in the connection pool before returning a timeout error." + default = 120 + + validation { + condition = var.proxy_connection_borrow_timeout >= 0 + error_message = "The proxy_connection_borrow_timeout must be greater than or equal to 0." + } +} + +variable "proxy_init_query" { + type = string + description = "One or more SQL statements for the proxy to run when opening each new database connection." + default = null +} + +variable "proxy_max_connections_percent" { + type = number + description = "The maximum size of the proxy connection pool as a percentage of the max_connections setting of the target database." + default = 100 + + validation { + condition = var.proxy_max_connections_percent >= 1 && var.proxy_max_connections_percent <= 100 + error_message = "The proxy_max_connections_percent must be between 1 and 100." + } +} + +variable "proxy_max_idle_connections_percent" { + type = number + description = "The maximum percentage of idle database connections the proxy keeps open, as a percentage of the max_connections setting of the target database." + default = 50 + + validation { + condition = var.proxy_max_idle_connections_percent >= 0 && var.proxy_max_idle_connections_percent <= 100 + error_message = "The proxy_max_idle_connections_percent must be between 0 and 100." + } +} + +variable "proxy_session_pinning_filters" { + type = list(string) + description = "Session pinning filters for the proxy. Valid value: EXCLUDE_VARIABLE_SETS." + default = [] + + validation { + condition = alltrue([for f in var.proxy_session_pinning_filters : f == "EXCLUDE_VARIABLE_SETS"]) + error_message = "The only valid session pinning filter is EXCLUDE_VARIABLE_SETS." + } +} + variable "region" { type = string description = "AWS region. When null, the provider's configured region is used." diff --git a/database/rds-proxy/.terraform.lock.hcl b/database/rds-proxy/.terraform.lock.hcl new file mode 100644 index 0000000..3b6cb03 --- /dev/null +++ b/database/rds-proxy/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.28.0" + constraints = ">= 6.0.0" + hashes = [ + "h1:HRIef5R96dZbCL7AtPbGsdJnr6bGjB0P3TnI+N/lEuU=", + "h1:JW/6X82H742ltNqdi1Z+o3Y+diA6fluOH805IOI7kqg=", + "h1:a04viexyZH7XCg13UIibwzkFMfQvMWA//8f9okDFKIs=", + "h1:gls3u4DZH97fapaPejImJ0ITIZo9epFFVhhJZUSuDbs=", + "h1:i0G7vt2sNy0oz84IiuG4gplonNVyOLRdprKLurU8pe4=", + "h1:k967tIoJHNgEt72UJDzlE6ngWN91Ke96zrpgnn2PvUA=", + "h1:kjmhRbLdowE0T9x6hGzO5fCM01Qz+8GntpsaXqe9HiE=", + "h1:tcau98fkhZ2RhbPHo8LdiiUk2RGpZUgT/t06sdMLids=", + "h1:wek8vEEZpTPulbLi9xCf2wnxvc97JXAN4qcOhduSg7k=", + "zh:38d58305206953783c150fb96d5c4f3ea5fe0b9e0987d927c884a6b0f2adf7a9", + "zh:43fd483251165f98b7a44360b41b437d309b007ef2bfff818eedcf3730e3f5cb", + "zh:4753decc5a718cb74b08244a02d00c150f0ddd6ebf2e1227f6a985c647c03ce9", + "zh:5956525650554bd3fbc4b695eb5250193f0ebf94c45862a7730457ab6a315069", + "zh:76d98fa1146750c01f607bae4421952ee9cd14ed3a4a59deb7136749adb9e0ae", + "zh:792c29e5ec91356baddb6219ac7f6f1df09c251cbe4ab6e089fc25d64270b22a", + "zh:856424380caa7c1536dc00515d12beac2693db1a8425da654eed5530abeb17d9", + "zh:e8982ec2bc692efa7236e3565e7094a09f52c5b71d8860a570a36fb31a40f27f", + "zh:f5e7ff825dc3f7356fb80936bfe7bb1b54a728ccf429cb753cfe590932f0403b", + ] +} diff --git a/database/rds-proxy/README.md b/database/rds-proxy/README.md new file mode 100644 index 0000000..99970df --- /dev/null +++ b/database/rds-proxy/README.md @@ -0,0 +1,149 @@ +# RDS Proxy + +Creates an Amazon RDS Proxy that sits in front of an RDS instance or Aurora cluster to provide connection pooling, improved failover handling, and optional IAM authentication for database connections. + +The module creates: + +- An RDS Proxy with Secrets Manager based authentication +- The default proxy target group with configurable connection pool settings +- A proxy target registration for an RDS instance or Aurora cluster +- An IAM role allowing the proxy to read credentials from Secrets Manager (optional, with least-privilege access to only the referenced secrets) +- A security group for the proxy (optional) + +This module can be used standalone, or deployed automatically by the `database/rds` and `database/aurora` modules by setting `proxy_creation_enabled = true` on those modules. + +## Usage + +### Standalone, in front of an existing RDS instance + +```hcl +module "rds_proxy" { + source = "git::https://github.com/flightcontrolhq/modules.git//database/rds-proxy?ref=v1.0.0" + + name = "myapp" + engine_family = "POSTGRESQL" + + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + + db_instance_identifier = "myapp-db" + + auth = [ + { + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp-db-credentials-AbCdEf" + } + ] + + allowed_security_group_ids = ["sg-12345678"] + + tags = { + Environment = "production" + } +} +``` + +### Standalone, in front of an Aurora cluster + +```hcl +module "rds_proxy" { + source = "git::https://github.com/flightcontrolhq/modules.git//database/rds-proxy?ref=v1.0.0" + + name = "myapp" + engine_family = "MYSQL" + + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + + db_cluster_identifier = "myapp-aurora" + + auth = [ + { + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp-db-credentials-AbCdEf" + iam_auth = "REQUIRED" + } + ] + + allowed_security_group_ids = ["sg-12345678"] +} +``` + +### Via the rds or aurora modules + +```hcl +module "rds" { + source = "git::https://github.com/flightcontrolhq/modules.git//database/rds?ref=v1.0.0" + + # ... regular rds inputs ... + + proxy_creation_enabled = true +} +``` + +When deployed through the `rds` or `aurora` modules, the proxy authenticates with the managed master user secret (requires `master_user_password_management_enabled = true`, the default) or with explicitly provided `proxy_auth_secret_arns`. The database security group automatically allows ingress from the proxy security group, and the proxy security group allows ingress from the module's `allowed_security_group_ids` and `allowed_cidr_blocks`. + +## Requirements + +| Name | Version | +| ------------------ | --------- | +| opentofu/terraform | >= 1.10.0 | +| aws | >= 6.0 | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|----------| +| name | Name prefix for all resources created by this module. Also used as the DB proxy name. | `string` | n/a | yes | +| engine_family | The kind of database engine the proxy connects to: MYSQL (also used for MariaDB), POSTGRESQL, or SQLSERVER. | `string` | n/a | yes | +| vpc_id | The ID of the VPC where the proxy will be created. | `string` | n/a | yes | +| subnet_ids | A list of subnet IDs for the proxy (minimum 2, in different AZs). | `list(string)` | n/a | yes | +| auth | List of authentication configurations. Each entry references a Secrets Manager secret and supports optional description, username, iam_auth (DISABLED, REQUIRED, ENABLED), and client_password_auth_type. | `list(object)` | n/a | yes | +| tags | A map of tags to assign to all resources. | `map(string)` | `{}` | no | +| tls_requirement_enabled | Require TLS for connections to the proxy. | `bool` | `true` | no | +| debug_logging_enabled | Log detailed connection information, including SQL statements, to CloudWatch Logs. | `bool` | `false` | no | +| idle_client_timeout | Seconds a client connection can be idle before the proxy disconnects it (1-28800). | `number` | `1800` | no | +| secret_kms_key_arns | KMS key ARNs used to encrypt the auth secrets (needed for customer-managed keys only). | `list(string)` | `[]` | no | +| db_instance_identifier | RDS instance identifier to register as the proxy target (exactly one of db_instance_identifier or db_cluster_identifier). | `string` | `null` | no | +| db_cluster_identifier | Aurora cluster identifier to register as the proxy target (exactly one of db_instance_identifier or db_cluster_identifier). | `string` | `null` | no | +| connection_borrow_timeout | Seconds the proxy waits for an available connection in the pool. | `number` | `120` | no | +| init_query | SQL statements the proxy runs when opening each new database connection. | `string` | `null` | no | +| max_connections_percent | Max connection pool size as a percentage of the database max_connections (1-100). | `number` | `100` | no | +| max_idle_connections_percent | Max idle connections as a percentage of the database max_connections (0-100). | `number` | `50` | no | +| session_pinning_filters | Session pinning filters. Valid value: EXCLUDE_VARIABLE_SETS. | `list(string)` | `[]` | no | +| port | The port used for security group rules (defaults per engine_family: 3306, 5432, 1433). | `number` | `null` | no | +| security_group_creation_enabled | Whether to create a security group for the proxy. | `bool` | `true` | no | +| security_group_id | Existing security group ID to use when creation is disabled. | `string` | `null` | no | +| allowed_security_group_ids | Security group IDs allowed to connect to the proxy. | `list(string)` | `[]` | no | +| allowed_cidr_blocks | CIDR blocks allowed to connect to the proxy. | `list(string)` | `[]` | no | +| iam_role_creation_enabled | Whether to create the IAM role the proxy uses to read secrets. | `bool` | `true` | no | +| iam_role_arn | Existing IAM role ARN to use when role creation is disabled. | `string` | `null` | no | +| region | AWS region. When null, the provider's configured region is used. | `string` | `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| proxy_id | The ID of the RDS Proxy. | +| proxy_arn | The ARN of the RDS Proxy. | +| proxy_name | The name of the RDS Proxy. | +| endpoint | The endpoint that applications use to connect through the proxy. | +| port | The port on which the proxy accepts connections. | +| default_target_group_name | The name of the default proxy target group. | +| default_target_group_arn | The ARN of the default proxy target group. | +| iam_role_arn | The ARN of the IAM role the proxy uses to read Secrets Manager secrets. | +| security_group_id | The security group ID. | +| security_group_arn | The security group ARN. | +| aws_account_id | The AWS account ID where the resources are deployed. | +| region | The AWS region where the resources are deployed. | + +## Security Considerations + +- **Secrets Manager**: The proxy authenticates to the database using Secrets Manager secrets; no plaintext credentials. +- **Least privilege IAM**: The created IAM role can only read the specific secrets referenced in `auth` (plus `kms:Decrypt` scoped to the provided keys via Secrets Manager). +- **TLS**: Required by default for client connections to the proxy. +- **VPC only**: The proxy is deployed within your VPC; the security group only allows the sources you list. + +## Notes + +- RDS Proxy supports MySQL, MariaDB (via the MYSQL engine family), PostgreSQL, and SQL Server. Oracle engines are not supported. +- Registering an Aurora cluster as a proxy target requires the cluster to have at least one instance. +- The secrets referenced in `auth` must be in the same region as the proxy. diff --git a/database/rds-proxy/data.tf b/database/rds-proxy/data.tf new file mode 100644 index 0000000..af88cb6 --- /dev/null +++ b/database/rds-proxy/data.tf @@ -0,0 +1,11 @@ +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +data "aws_vpc" "this" { + id = var.vpc_id +} diff --git a/database/rds-proxy/db_proxy.tf b/database/rds-proxy/db_proxy.tf new file mode 100644 index 0000000..7f4f722 --- /dev/null +++ b/database/rds-proxy/db_proxy.tf @@ -0,0 +1,83 @@ +################################################################################ +# RDS Proxy +################################################################################ + +resource "aws_db_proxy" "this" { + name = var.name + engine_family = var.engine_family + role_arn = local.iam_role_arn + vpc_subnet_ids = var.subnet_ids + + vpc_security_group_ids = [local.security_group_id] + + require_tls = var.tls_requirement_enabled + debug_logging = var.debug_logging_enabled + idle_client_timeout = var.idle_client_timeout + + dynamic "auth" { + for_each = var.auth + content { + auth_scheme = "SECRETS" + secret_arn = auth.value.secret_arn + description = auth.value.description + username = auth.value.username + iam_auth = auth.value.iam_auth + client_password_auth_type = auth.value.client_password_auth_type + } + } + + tags = merge(local.tags, { + Name = var.name + }) + + lifecycle { + precondition { + condition = var.security_group_creation_enabled || var.security_group_id != null + error_message = "security_group_id is required when security_group_creation_enabled is false." + } + + precondition { + condition = var.iam_role_creation_enabled || var.iam_role_arn != null + error_message = "iam_role_arn is required when iam_role_creation_enabled is false." + } + } + + depends_on = [ + aws_iam_role_policy.secrets_access + ] +} + +################################################################################ +# Default Target Group (connection pool configuration) +################################################################################ + +resource "aws_db_proxy_default_target_group" "this" { + db_proxy_name = aws_db_proxy.this.name + + connection_pool_config { + connection_borrow_timeout = var.connection_borrow_timeout + init_query = var.init_query + max_connections_percent = var.max_connections_percent + max_idle_connections_percent = var.max_idle_connections_percent + session_pinning_filters = var.session_pinning_filters + } +} + +################################################################################ +# Proxy Target +################################################################################ + +resource "aws_db_proxy_target" "this" { + db_proxy_name = aws_db_proxy.this.name + target_group_name = aws_db_proxy_default_target_group.this.name + + db_instance_identifier = var.db_instance_identifier + db_cluster_identifier = var.db_cluster_identifier + + lifecycle { + precondition { + condition = (var.db_instance_identifier != null) != (var.db_cluster_identifier != null) + error_message = "Exactly one of db_instance_identifier or db_cluster_identifier must be provided." + } + } +} diff --git a/database/rds-proxy/iam_role.tf b/database/rds-proxy/iam_role.tf new file mode 100644 index 0000000..a4a3a1d --- /dev/null +++ b/database/rds-proxy/iam_role.tf @@ -0,0 +1,61 @@ +################################################################################ +# Proxy IAM Role +# +# Allows the RDS Proxy service to read database credentials from Secrets +# Manager (and decrypt them when a customer-managed KMS key is used). +################################################################################ + +resource "aws_iam_role" "this" { + count = local.create_iam_role ? 1 : 0 + + name = "${var.name}-rds-proxy" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "rds.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = local.tags +} + +resource "aws_iam_role_policy" "secrets_access" { + count = local.create_iam_role ? 1 : 0 + + name = "secrets-access" + role = aws_iam_role.this[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [ + { + Sid = "GetSecretValue" + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = local.auth_secret_arns + } + ], + length(var.secret_kms_key_arns) > 0 ? [ + { + Sid = "DecryptSecrets" + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = var.secret_kms_key_arns + Condition = { + StringEquals = { + "kms:ViaService" = "secretsmanager.${local.region}.amazonaws.com" + } + } + } + ] : [] + ) + }) +} diff --git a/database/rds-proxy/locals.tf b/database/rds-proxy/locals.tf new file mode 100644 index 0000000..739c820 --- /dev/null +++ b/database/rds-proxy/locals.tf @@ -0,0 +1,35 @@ +locals { + region = coalesce(var.region, data.aws_region.current.region) +} + +################################################################################ +# Local Values +################################################################################ + +locals { + # Tags + default_tags = { + ManagedBy = "terraform" + Module = "database/rds-proxy" + } + tags = merge(local.default_tags, var.tags) + + # Port defaults based on engine family + default_port = ( + var.engine_family == "POSTGRESQL" ? 5432 : + var.engine_family == "SQLSERVER" ? 1433 : + 3306 + ) + port = coalesce(var.port, local.default_port) + + # Resource creation flags + create_security_group = var.security_group_creation_enabled + create_iam_role = var.iam_role_creation_enabled + + # Resolved resource references + security_group_id = local.create_security_group ? module.security_group[0].security_group_id : var.security_group_id + iam_role_arn = local.create_iam_role ? aws_iam_role.this[0].arn : var.iam_role_arn + + # Secrets the proxy IAM role must be able to read + auth_secret_arns = distinct([for a in var.auth : a.secret_arn]) +} diff --git a/database/rds-proxy/outputs.tf b/database/rds-proxy/outputs.tf new file mode 100644 index 0000000..fe11ff0 --- /dev/null +++ b/database/rds-proxy/outputs.tf @@ -0,0 +1,83 @@ +################################################################################ +# Proxy Outputs +################################################################################ + +output "proxy_id" { + description = "The ID of the RDS Proxy." + value = aws_db_proxy.this.id +} + +output "proxy_arn" { + description = "The ARN of the RDS Proxy." + value = aws_db_proxy.this.arn +} + +output "proxy_name" { + description = "The name of the RDS Proxy." + value = aws_db_proxy.this.name +} + +################################################################################ +# Connection Outputs +################################################################################ + +output "endpoint" { + description = "The endpoint that applications use to connect through the proxy." + value = aws_db_proxy.this.endpoint +} + +output "port" { + description = "The port on which the proxy accepts connections." + value = local.port +} + +################################################################################ +# Target Group Outputs +################################################################################ + +output "default_target_group_name" { + description = "The name of the default proxy target group." + value = aws_db_proxy_default_target_group.this.name +} + +output "default_target_group_arn" { + description = "The ARN of the default proxy target group." + value = aws_db_proxy_default_target_group.this.arn +} + +################################################################################ +# IAM Outputs +################################################################################ + +output "iam_role_arn" { + description = "The ARN of the IAM role the proxy uses to read Secrets Manager secrets." + value = local.iam_role_arn +} + +################################################################################ +# Security Group Outputs +################################################################################ + +output "security_group_id" { + description = "The ID of the security group." + value = local.security_group_id +} + +output "security_group_arn" { + description = "The ARN of the security group." + value = local.create_security_group ? module.security_group[0].security_group_arn : null +} + +################################################################################ +# Account & Region +################################################################################ + +output "aws_account_id" { + description = "The AWS account ID where the resources are deployed." + value = data.aws_caller_identity.current.account_id +} + +output "region" { + description = "The AWS region where the resources are deployed." + value = local.region +} diff --git a/database/rds-proxy/rvn-rds-proxy-definition.yml b/database/rds-proxy/rvn-rds-proxy-definition.yml new file mode 100644 index 0000000..c7987af --- /dev/null +++ b/database/rds-proxy/rvn-rds-proxy-definition.yml @@ -0,0 +1,440 @@ +definition: + type: rvn-rds-proxy + name: RDS Proxy + description: Creates and manages an Amazon RDS Proxy that provides connection pooling in front of an RDS instance or Aurora cluster. + +release: + version: 0.1.0 + description: Initial release of the RDS Proxy module definition. +module: + inputs: + - id: network + immutable: true + label: VPC network + mapped_inputs: + - id: section_aws + label: AWS account & region + type: section + - default: << ref.input.aws_account_id >> + id: aws_account_id + immutable: true + label: AWS account + type: string + values: $values:ravion/aws_accounts + - default: << ref.input.aws_region >> + id: aws_region + immutable: true + label: Region + type: string + values: $values:aws/regions + - collapsible: true + default: << ref.input.execution_environment_id >> + description: Override the VPC, subnet, and security group for Pipeline Terraform runners. Must use the same AWS account as selected above. + id: execution_environment_id + label: Terraform execution environment + type: string + values: $values:ravion/execution_environments + - id: section_vpc + label: VPC + type: section + - default: <> + id: vpc_id + immutable: true + label: VPC ID + required: true + type: string + - add_button_label: Add private subnet ID + default: <> + description: Subnets for the RDS Proxy. Use private subnets for normal deployments. + id: subnet_ids + immutable: true + label: Private subnet IDs + required: true + type: string_array + required: true + type: $ref:rvn-aws-network + - id: section_proxy + label: Proxy + type: section + - default: <>-<>-proxy + description: Name prefix for all proxy resources. + id: name + immutable: true + label: Name slug + patterns: + - message: 1-40 lowercase letters, numbers, and hyphens. Start and end with a letter or number. + pattern: ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ + required: true + type: string + - default: POSTGRESQL + description: Must match the engine of the target database. + id: engine_family + immutable: true + label: Engine family + required: true + type: string + values: + - label: PostgreSQL + value: POSTGRESQL + - label: MySQL / MariaDB + value: MYSQL + - label: SQL Server + value: SQLSERVER + - id: section_target + label: Target database + type: section + - default: db_instance + description: Register an RDS instance or an Aurora cluster as the proxy target. + id: target_type + label: Target type + required: true + type: string + values: + - label: RDS instance + value: db_instance + - label: Aurora cluster + value: db_cluster + - description: RDS database module to register as the proxy target. Add this proxy's security group to that module's allowed security groups so the database accepts proxy connections. + id: rds_database + label: RDS database + required: false + show_when: + target_type: db_instance + type: $ref:rvn-rds + mapped_inputs: + - default: <> + description: Instance identifier from the selected RDS module, or the identifier of an existing RDS instance. + id: rds_db_instance_identifier + label: DB instance identifier + placeholder: my-db + required: true + type: string + - default: <> + description: Managed master user secret from the selected RDS module. Blank when that database does not manage its password in Secrets Manager; add auth secret ARNs instead. + id: rds_master_user_secret_arn + label: Master user secret ARN + placeholder: arn:aws:secretsmanager:... + required: false + type: string + - default: <> + description: Database port from the selected RDS module. Leave blank to use the engine family default port. + id: rds_port + label: Database port + required: false + type: number + - description: Aurora cluster module to register as the proxy target. Add this proxy's security group to that module's allowed security groups so the cluster accepts proxy connections. + id: aurora_database + label: Aurora cluster + required: false + show_when: + target_type: db_cluster + type: $ref:rvn-aurora + mapped_inputs: + - default: <> + description: Cluster identifier from the selected Aurora module, or the identifier of an existing Aurora cluster. + id: aurora_cluster_identifier + label: DB cluster identifier + placeholder: my-aurora + required: true + type: string + - default: <> + description: Managed master user secret from the selected Aurora module. Blank when that cluster does not manage its password in Secrets Manager; add auth secret ARNs instead. + id: aurora_master_user_secret_arn + label: Master user secret ARN + placeholder: arn:aws:secretsmanager:... + required: false + type: string + - default: <> + description: Cluster port from the selected Aurora module. Leave blank to use the engine family default port. + id: aurora_port + label: Database port + required: false + type: number + - id: section_auth + label: Authentication + type: section + - add_button_label: Add secret ARN + description: Secrets Manager secrets containing database credentials the proxy uses to connect to the target database. Leave blank to use the managed master user secret of the selected database module. + id: auth_secret_arns + label: Auth secret ARNs + placeholder: arn:aws:secretsmanager:... + required: false + type: string_array + - add_button_label: Add KMS key ARN + collapsible: true + description: KMS keys used to encrypt the auth secrets when using customer-managed keys. + id: secret_kms_key_arns + label: Auth secret KMS key ARNs + placeholder: arn:aws:kms:... + required: false + type: string_array + - collapsible: true + default: false + description: Require IAM authentication for client connections to the proxy. + id: iam_auth_enabled + label: IAM authentication + type: boolean + - collapsible: true + default: true + description: Require TLS for client connections to the proxy. + id: tls_requirement_enabled + label: Require TLS + type: boolean + - collapsible: true + default: true + description: Create the IAM role the proxy uses to read credentials from Secrets Manager. Disable this only when you want to provide an existing role. + id: iam_role_creation_enabled + immutable: true + label: IAM role creation + type: boolean + - collapsible: true + id: iam_role_arn + label: Existing IAM role ARN + placeholder: arn:aws:iam::... + required: false + show_when: + iam_role_creation_enabled: false + type: string + - id: section_access + label: Network access + type: section + - collapsible: true + description: "Leave blank to use the port of the selected database module, or the engine family default port: 5432 for PostgreSQL, 3306 for MySQL, or 1433 for SQL Server." + id: port + label: Port + max: 65535 + min: 1 + required: false + type: number + - collapsible: true + default: true + id: security_group_creation_enabled + immutable: true + label: Security group creation + type: boolean + - collapsible: true + id: security_group_id + label: Existing security group ID + placeholder: sg-... + required: false + show_when: + security_group_creation_enabled: false + type: string + - add_button_label: Add security group ID + collapsible: true + id: allowed_security_group_ids + label: Allowed security groups + placeholder: sg-... + required: false + show_when: + security_group_creation_enabled: true + type: string_array + - add_button_label: Add CIDR block + collapsible: true + id: allowed_cidr_blocks + label: Allowed CIDR blocks + placeholder: 10.0.0.0/16 + required: false + show_when: + security_group_creation_enabled: true + type: string_array + - id: section_pool + label: Connection pool + type: section + - collapsible: true + default: 1800 + id: idle_client_timeout + label: Idle client timeout (seconds) + max: 28800 + min: 1 + type: number + - collapsible: true + default: 120 + id: connection_borrow_timeout + label: Connection borrow timeout (seconds) + min: 0 + type: number + - collapsible: true + default: 100 + description: Maximum size of the connection pool as a percentage of the database max_connections setting. + id: max_connections_percent + label: Max connections (%) + max: 100 + min: 1 + type: number + - collapsible: true + default: 50 + description: Maximum idle connections kept open, as a percentage of the database max_connections setting. + id: max_idle_connections_percent + label: Max idle connections (%) + max: 100 + min: 0 + type: number + - add_button_label: Add filter + collapsible: true + id: session_pinning_filters + label: Session pinning filters + required: false + type: string_array + values: + - label: EXCLUDE_VARIABLE_SETS + value: EXCLUDE_VARIABLE_SETS + - collapsible: true + description: SQL statements the proxy runs when opening each new database connection. + id: init_query + label: Initialization query + placeholder: SET time_zone = 'UTC' + required: false + type: string + - collapsible: true + default: false + description: Log detailed connection information, including SQL statements, to CloudWatch Logs. + id: debug_logging_enabled + label: Debug logging + type: boolean + - $include: ../../partials/inputs/misc-section.yml + - $include: ../../partials/inputs/tags.yml + - $include: ../../partials/inputs/terraform-settings.yml + stack: + $template: ../../partials/templates/opentofu-stack.yml + with: + base_path: database/rds-proxy + terraform_variables: + ...overrides: << module.input.advanced_terraform_variables >> + allowed_cidr_blocks: << module.input.allowed_cidr_blocks >> + allowed_security_group_ids: << module.input.allowed_security_group_ids >> + auth: '<< map(len(module.input.auth_secret_arns) > 0 ? module.input.auth_secret_arns : ((module.input.rds_master_user_secret_arn || module.input.aurora_master_user_secret_arn) ? [module.input.rds_master_user_secret_arn || module.input.aurora_master_user_secret_arn] : []), {"secret_arn": #, "iam_auth": module.input.iam_auth_enabled ? "REQUIRED" : "DISABLED"}) >>' + connection_borrow_timeout: << module.input.connection_borrow_timeout >> + db_cluster_identifier: << module.input.aurora_cluster_identifier || null >> + db_instance_identifier: << module.input.rds_db_instance_identifier || null >> + debug_logging_enabled: << module.input.debug_logging_enabled >> + engine_family: << module.input.engine_family >> + iam_role_arn: << module.input.iam_role_arn >> + iam_role_creation_enabled: << module.input.iam_role_creation_enabled >> + idle_client_timeout: << module.input.idle_client_timeout >> + init_query: << module.input.init_query >> + max_connections_percent: << module.input.max_connections_percent >> + max_idle_connections_percent: << module.input.max_idle_connections_percent >> + name: << module.input.name >> + port: << module.input.port || module.input.rds_port || module.input.aurora_port || null >> + region: << module.input.aws_region >> + secret_kms_key_arns: << module.input.secret_kms_key_arns >> + security_group_creation_enabled: << module.input.security_group_creation_enabled >> + security_group_id: << module.input.security_group_id >> + session_pinning_filters: << module.input.session_pinning_filters >> + subnet_ids: << module.input.subnet_ids >> + tags: + $include: ../stack/ravion-tags.yml + tls_requirement_enabled: << module.input.tls_requirement_enabled >> + vpc_id: << module.input.vpc_id >> + ui: + metrics: + - id: client_connections + name: Client connections + source: + aws_account_id: << module.input.aws_account_id >> + dimensions: + ProxyName: << stack.output.proxy_name >> + name: ClientConnections + namespace: AWS/RDS + region: << stack.output.region >> + statistic: Average + type: cloudwatch + type: line + - id: database_connections + name: Database connections + source: + aws_account_id: << module.input.aws_account_id >> + dimensions: + ProxyName: << stack.output.proxy_name >> + name: DatabaseConnections + namespace: AWS/RDS + region: << stack.output.region >> + statistic: Average + type: cloudwatch + type: line + - id: query_requests + name: Query requests + source: + aws_account_id: << module.input.aws_account_id >> + dimensions: + ProxyName: << stack.output.proxy_name >> + name: QueryRequests + namespace: AWS/RDS + region: << stack.output.region >> + statistic: Sum + type: cloudwatch + type: line + readme: |- + Creates and manages an Amazon RDS Proxy that provides connection pooling in front of an RDS instance or Aurora cluster. + + ## Overview + + Use this module to place a managed connection pool in front of an existing database. The proxy holds a pool of database connections open and multiplexes application connections onto them, which protects the database from connection storms and makes failovers faster for clients. + + The RDS and Aurora modules can create a proxy directly with their RDS Proxy toggle. Use this standalone module when the target database is not managed by Ravion, or when you want to manage the proxy lifecycle separately from the database. + + ## Target database + + Target type selects whether the proxy registers an RDS instance or an Aurora cluster, and shows the matching database module selector. + + Selecting an RDS or Aurora module fills the database identifier, managed master user secret, and port from that module's stack outputs. For a database managed outside Ravion, leave the module unselected and enter the identifier, and any auth secret ARNs, directly. + + When a Ravion module supplies the target, add this proxy's security group ID to that module's allowed security groups. The proxy connects to the database through the database security group, and the database module owns those ingress rules. + + Applications do not use the proxy until you point their connection string at the proxy endpoint output. + + ## Use cases + + | Scenario | Benefit | + | --- | --- | + | Serverless or many-worker applications | Reuse pooled connections instead of opening one per worker. | + | Existing database | Add pooling in front of a database created outside Ravion. | + | Faster failover | Clients stay connected to the proxy while the database fails over. | + | IAM authentication | Require IAM auth for clients without changing database credentials. | + + ## Networking + + Select a VPC network first. Ravion maps the network's AWS account, region, VPC ID, and private subnet IDs into this module. The proxy must be in the same VPC as the target database, and the database's security group must allow ingress from the proxy security group. + + Allowed security groups and CIDR blocks control which sources can connect to the proxy. + + ## Authentication + + The proxy reads database credentials from the Secrets Manager secrets listed in auth secret ARNs. Leave that field blank when a selected database module manages its master password in Secrets Manager; the proxy then uses that module's master user secret. Add auth secret ARNs explicitly for databases identified by identifier, for databases whose password is managed outside Secrets Manager, or to authenticate as a different database user. + + The module creates an IAM role that can read only the resolved auth secrets; provide KMS key ARNs when the secrets use customer-managed keys. TLS is required by default, and IAM authentication for clients is optional. + + ## Configuration + + | Field | Required | Default | Notes | + | --- | --- | --- | --- | + | VPC network | Yes | None | Supplies AWS account, region, VPC ID, and private subnet IDs. | + | Name slug | Yes | Project and environment given IDs + proxy | Proxy name and prefix for related resources. | + | Engine family | Yes | PostgreSQL | PostgreSQL, MySQL / MariaDB, or SQL Server. Must match the target database engine. | + | Target type | Yes | RDS instance | Register an RDS instance or an Aurora cluster as the proxy target. | + | RDS database / Aurora cluster | No | None | Fills the database identifier, master user secret, and port from the selected module. | + | DB instance / cluster identifier | Yes | None | Identifier of the target database. | + | Auth secret ARNs | No | Blank | Secrets Manager secrets with database credentials. Required when the target does not provide a managed master user secret. | + | Require TLS | No | true | Requires TLS for client connections. | + | IAM authentication | No | false | Requires IAM auth for client connections. | + | Security group creation | No | true | Creates ingress rules from allowed security groups and CIDRs. | + | Max connections (%) | No | 100 | Pool size as a percentage of the database max_connections. | + | Idle client timeout | No | 1800 | Seconds before idle client connections are closed. | + | Tags | No | Blank | Merged with Ravion standard tags. | + + ## Advanced configuration + + Connection pool settings control borrow timeouts, idle connection limits, session pinning filters, and an initialization query. Debug logging sends detailed connection information to CloudWatch Logs. Use advanced Terraform variables for one-off overrides not represented directly in the UI. + + Terraform settings let you override the OpenTofu version, Terraform execution environment inherited from the VPC network, and Ravion state backend workspace name. + + ## Design decisions + + The proxy is placed in private subnets by default through the VPC network reference. TLS enforcement and Secrets Manager based authentication default on. The created IAM role follows least privilege and can read only the listed auth secrets. + + ## Learn more + + - [Amazon RDS Proxy documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html) + - [RDS Proxy managing connections](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-managing.html) + - [Terraform source](https://github.com/ravionhq/modules/tree/$local.module_tag/database/rds-proxy) diff --git a/database/rds-proxy/security_group.tf b/database/rds-proxy/security_group.tf new file mode 100644 index 0000000..512851b --- /dev/null +++ b/database/rds-proxy/security_group.tf @@ -0,0 +1,50 @@ +################################################################################ +# Security Group +################################################################################ + +module "security_group" { + count = local.create_security_group ? 1 : 0 + + source = "../../networking/security-groups" + + name = var.name + name_suffix = "rds-proxy" + description = "Security group for ${var.name} RDS Proxy" + vpc_id = var.vpc_id + tags = var.tags + + ingress_rules = concat( + # Security group sources + [ + for sg_id in var.allowed_security_group_ids : { + description = "Allow database traffic from ${sg_id}" + from_port = local.port + to_port = local.port + ip_protocol = "tcp" + referenced_security_group_id = sg_id + } + ], + # IPv4 CIDR sources + [ + for cidr in var.allowed_cidr_blocks : { + description = "Allow database traffic from ${cidr}" + from_port = local.port + to_port = local.port + ip_protocol = "tcp" + cidr_ipv4 = cidr + } + ] + ) + + # Egress to VPC only. For ip_protocol="-1" (all protocols), AWS requires + # from_port/to_port to be omitted; use -1 here for caller clarity. + egress_rules = [ + { + description = "Allow outbound traffic within VPC" + from_port = -1 + to_port = -1 + ip_protocol = "-1" + cidr_ipv4 = data.aws_vpc.this.cidr_block + } + ] +} diff --git a/database/rds-proxy/tests/basic.tftest.hcl b/database/rds-proxy/tests/basic.tftest.hcl new file mode 100644 index 0000000..93b6a48 --- /dev/null +++ b/database/rds-proxy/tests/basic.tftest.hcl @@ -0,0 +1,263 @@ +################################################################################ +# RDS Proxy Module Unit Tests +################################################################################ + +# Mock AWS provider with overridden data sources +mock_provider "aws" { + override_data { + target = data.aws_caller_identity.current + values = { + account_id = "123456789012" + } + } + + override_data { + target = data.aws_region.current + values = { + id = "us-east-1" + name = "us-east-1" + } + } + + override_data { + target = data.aws_vpc.this + values = { + cidr_block = "10.0.0.0/16" + } + } + + override_resource { + target = aws_iam_role.this + values = { + arn = "arn:aws:iam::123456789012:role/test-proxy-rds-proxy" + } + } + + override_resource { + target = aws_db_proxy_default_target_group.this + values = { + name = "default" + } + } +} + +# Defaults shared by all runs; individual runs override as needed. +variables { + name = "test-proxy" + engine_family = "POSTGRESQL" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + auth = [ + { secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-abc123" } + ] + db_instance_identifier = "test-db" +} + +#------------------------------------------------------------------------------- +# Basic Plan & Defaults +#------------------------------------------------------------------------------- + +run "test_basic_plan" { + command = plan + + assert { + condition = aws_db_proxy.this.name == "test-proxy" + error_message = "Proxy should use var.name as its name." + } + + assert { + condition = aws_db_proxy.this.require_tls == true + error_message = "TLS should be required by default." + } + + assert { + condition = aws_db_proxy.this.engine_family == "POSTGRESQL" + error_message = "Engine family should be passed through." + } + + assert { + condition = local.port == 5432 + error_message = "POSTGRESQL should default to port 5432." + } + + assert { + condition = local.tags["Module"] == "database/rds-proxy" + error_message = "Module default tag should be set." + } +} + +run "test_mysql_port_default" { + command = plan + + variables { + engine_family = "MYSQL" + } + + assert { + condition = local.port == 3306 + error_message = "MYSQL should default to port 3306." + } +} + +run "test_sqlserver_port_default" { + command = plan + + variables { + engine_family = "SQLSERVER" + } + + assert { + condition = local.port == 1433 + error_message = "SQLSERVER should default to port 1433." + } +} + +run "test_custom_port_override" { + command = plan + + variables { + port = 6543 + } + + assert { + condition = local.port == 6543 + error_message = "Custom port should override the engine family default." + } +} + +#------------------------------------------------------------------------------- +# Validation Tests +#------------------------------------------------------------------------------- + +run "test_invalid_engine_family" { + command = plan + + variables { + engine_family = "ORACLE" + } + + expect_failures = [ + var.engine_family, + ] +} + +run "test_auth_empty" { + command = plan + + variables { + auth = [] + } + + expect_failures = [ + var.auth, + ] +} + +run "test_auth_invalid_secret_arn" { + command = plan + + variables { + auth = [{ secret_arn = "not-an-arn" }] + } + + expect_failures = [ + var.auth, + ] +} + +run "test_max_idle_above_max_connections" { + command = plan + + variables { + max_connections_percent = 50 + max_idle_connections_percent = 80 + } + + expect_failures = [ + var.max_idle_connections_percent, + ] +} + +#------------------------------------------------------------------------------- +# Target Exclusivity Tests +#------------------------------------------------------------------------------- + +run "test_both_targets_rejected" { + command = plan + + variables { + db_cluster_identifier = "test-cluster" + } + + expect_failures = [ + aws_db_proxy_target.this, + ] +} + +run "test_no_target_rejected" { + command = plan + + variables { + db_instance_identifier = null + } + + expect_failures = [ + aws_db_proxy_target.this, + ] +} + +#------------------------------------------------------------------------------- +# Security Group & IAM Role Preconditions +#------------------------------------------------------------------------------- + +run "test_sg_disabled_requires_id" { + command = plan + + variables { + security_group_creation_enabled = false + } + + expect_failures = [ + aws_db_proxy.this, + ] +} + +run "test_sg_disabled_with_id" { + command = plan + + variables { + security_group_creation_enabled = false + security_group_id = "sg-12345678" + } + + assert { + condition = local.security_group_id == "sg-12345678" + error_message = "Provided security group ID should be used." + } +} + +run "test_iam_role_disabled_requires_arn" { + command = plan + + variables { + iam_role_creation_enabled = false + } + + expect_failures = [ + aws_db_proxy.this, + ] +} + +run "test_iam_role_disabled_with_arn" { + command = plan + + variables { + iam_role_creation_enabled = false + iam_role_arn = "arn:aws:iam::123456789012:role/custom-proxy-role" + } + + assert { + condition = local.iam_role_arn == "arn:aws:iam::123456789012:role/custom-proxy-role" + error_message = "Provided IAM role ARN should be used." + } +} diff --git a/database/rds-proxy/variables.tf b/database/rds-proxy/variables.tf new file mode 100644 index 0000000..b2d2a27 --- /dev/null +++ b/database/rds-proxy/variables.tf @@ -0,0 +1,291 @@ +################################################################################ +# General +################################################################################ + +variable "name" { + type = string + description = "Name prefix for all resources created by this module. Also used as the DB proxy name." + + validation { + condition = length(var.name) > 0 && length(var.name) <= 60 + error_message = "The name must be between 1 and 60 characters." + } + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9-]*$", var.name)) + error_message = "The name must start with a letter and contain only alphanumeric characters and hyphens." + } +} + +variable "tags" { + type = map(string) + description = "A map of tags to assign to all resources." + default = {} +} + +################################################################################ +# Proxy +################################################################################ + +variable "engine_family" { + type = string + description = "The kind of database engine the proxy connects to: MYSQL (also used for MariaDB), POSTGRESQL, or SQLSERVER." + + validation { + condition = contains(["MYSQL", "POSTGRESQL", "SQLSERVER"], var.engine_family) + error_message = "The engine_family must be one of: MYSQL, POSTGRESQL, SQLSERVER." + } +} + +variable "tls_requirement_enabled" { + type = bool + description = "Whether Transport Layer Security (TLS) encryption is required for connections to the proxy." + default = true +} + +variable "debug_logging_enabled" { + type = bool + description = "Whether the proxy logs detailed connection information, including SQL statements, to CloudWatch Logs." + default = false +} + +variable "idle_client_timeout" { + type = number + description = "The number of seconds a client connection can be idle before the proxy disconnects it." + default = 1800 + + validation { + condition = var.idle_client_timeout >= 1 && var.idle_client_timeout <= 28800 + error_message = "The idle_client_timeout must be between 1 and 28800 seconds." + } +} + +################################################################################ +# Authentication +################################################################################ + +variable "auth" { + type = list(object({ + secret_arn = string + description = optional(string) + username = optional(string) + iam_auth = optional(string, "DISABLED") + client_password_auth_type = optional(string) + })) + description = "List of authentication configurations for the proxy. Each entry references a Secrets Manager secret containing database credentials. iam_auth may be DISABLED, REQUIRED, or ENABLED." + + validation { + condition = length(var.auth) > 0 + error_message = "At least one auth configuration is required." + } + + validation { + condition = alltrue([for a in var.auth : startswith(a.secret_arn, "arn:")]) + error_message = "Each auth secret_arn must be a valid Secrets Manager secret ARN." + } + + validation { + condition = alltrue([for a in var.auth : contains(["DISABLED", "REQUIRED", "ENABLED"], a.iam_auth)]) + error_message = "Each auth iam_auth must be one of: DISABLED, REQUIRED, ENABLED." + } +} + +variable "secret_kms_key_arns" { + type = list(string) + description = "List of KMS key ARNs used to encrypt the auth secrets. Required for the proxy IAM role to decrypt secrets encrypted with a customer-managed key. Not needed for the default aws/secretsmanager key." + default = [] + + validation { + condition = alltrue([for arn in var.secret_kms_key_arns : startswith(arn, "arn:")]) + error_message = "All secret_kms_key_arns must be valid KMS key ARNs." + } +} + +################################################################################ +# Target +################################################################################ + +variable "db_instance_identifier" { + type = string + description = "The identifier of an RDS DB instance to register as the proxy target. Exactly one of db_instance_identifier or db_cluster_identifier must be provided." + default = null +} + +variable "db_cluster_identifier" { + type = string + description = "The identifier of an Aurora DB cluster to register as the proxy target. Exactly one of db_instance_identifier or db_cluster_identifier must be provided." + default = null +} + +################################################################################ +# Connection Pool +################################################################################ + +variable "connection_borrow_timeout" { + type = number + description = "The number of seconds the proxy waits for a connection to become available in the connection pool before returning a timeout error." + default = 120 + + validation { + condition = var.connection_borrow_timeout >= 0 + error_message = "The connection_borrow_timeout must be greater than or equal to 0." + } +} + +variable "init_query" { + type = string + description = "One or more SQL statements for the proxy to run when opening each new database connection." + default = null +} + +variable "max_connections_percent" { + type = number + description = "The maximum size of the connection pool as a percentage of the max_connections setting of the target database." + default = 100 + + validation { + condition = var.max_connections_percent >= 1 && var.max_connections_percent <= 100 + error_message = "The max_connections_percent must be between 1 and 100." + } +} + +variable "max_idle_connections_percent" { + type = number + description = "The maximum percentage of idle database connections the proxy keeps open, as a percentage of the max_connections setting of the target database." + default = 50 + + validation { + condition = var.max_idle_connections_percent >= 0 && var.max_idle_connections_percent <= 100 + error_message = "The max_idle_connections_percent must be between 0 and 100." + } + + validation { + condition = var.max_idle_connections_percent <= var.max_connections_percent + error_message = "The max_idle_connections_percent must be less than or equal to max_connections_percent." + } +} + +variable "session_pinning_filters" { + type = list(string) + description = "Each item in the list identifies a class of SQL operations that normally cause all later statements in a session to be pinned to the same database connection. Valid value: EXCLUDE_VARIABLE_SETS." + default = [] + + validation { + condition = alltrue([for f in var.session_pinning_filters : f == "EXCLUDE_VARIABLE_SETS"]) + error_message = "The only valid session pinning filter is EXCLUDE_VARIABLE_SETS." + } +} + +################################################################################ +# Network +################################################################################ + +variable "vpc_id" { + type = string + description = "The ID of the VPC where the proxy will be created." + + validation { + condition = can(regex("^vpc-", var.vpc_id)) + error_message = "The vpc_id must be a valid VPC ID starting with 'vpc-'." + } +} + +variable "subnet_ids" { + type = list(string) + description = "A list of subnet IDs for the proxy. At least two subnets in different AZs are required." + + validation { + condition = length(var.subnet_ids) >= 2 + error_message = "At least 2 subnet IDs in different availability zones are required." + } + + validation { + condition = alltrue([for s in var.subnet_ids : can(regex("^subnet-", s))]) + error_message = "All subnet_ids must be valid subnet IDs starting with 'subnet-'." + } +} + +variable "port" { + type = number + description = "The port on which the proxy accepts connections. If not specified, defaults based on engine_family (3306 for MYSQL, 5432 for POSTGRESQL, 1433 for SQLSERVER). Used for security group rules." + default = null + + validation { + condition = var.port == null || (var.port >= 1 && var.port <= 65535) + error_message = "The port must be between 1 and 65535." + } +} + +################################################################################ +# Security Group +################################################################################ + +variable "security_group_creation_enabled" { + type = bool + description = "Whether to create a new security group for the proxy." + default = true +} + +variable "security_group_id" { + type = string + description = "The ID of an existing security group to use for the proxy. Required if security_group_creation_enabled is false." + default = null + + validation { + condition = var.security_group_id == null || can(regex("^sg-", var.security_group_id)) + error_message = "The security_group_id must be a valid security group ID starting with 'sg-'." + } +} + +variable "allowed_security_group_ids" { + type = list(string) + description = "List of security group IDs allowed to connect to the proxy." + default = [] + + validation { + condition = alltrue([for sg in var.allowed_security_group_ids : can(regex("^sg-", sg))]) + error_message = "All allowed_security_group_ids must be valid security group IDs starting with 'sg-'." + } +} + +variable "allowed_cidr_blocks" { + type = list(string) + description = "List of CIDR blocks allowed to connect to the proxy." + default = [] + + validation { + condition = alltrue([for cidr in var.allowed_cidr_blocks : can(cidrhost(cidr, 0))]) + error_message = "All allowed_cidr_blocks must be valid CIDR blocks." + } +} + +################################################################################ +# IAM +################################################################################ + +variable "iam_role_creation_enabled" { + type = bool + description = "Whether to create the IAM role the proxy uses to read credentials from Secrets Manager." + default = true +} + +variable "iam_role_arn" { + type = string + description = "The ARN of an existing IAM role for the proxy to use. Required if iam_role_creation_enabled is false." + default = null + + validation { + condition = var.iam_role_arn == null || can(regex("^arn:aws[a-zA-Z-]*:iam::", var.iam_role_arn)) + error_message = "The iam_role_arn must be a valid IAM role ARN." + } +} + +################################################################################ +# Region +################################################################################ + +variable "region" { + type = string + description = "AWS region. When null, the provider's configured region is used." + default = null +} diff --git a/database/rds-proxy/versions.tf b/database/rds-proxy/versions.tf new file mode 100644 index 0000000..985850e --- /dev/null +++ b/database/rds-proxy/versions.tf @@ -0,0 +1,16 @@ +################################################################################ +# OpenTofu/Terraform and Provider Requirements +################################################################################ + +terraform { + required_version = ">= 1.10.0" + + cloud {} + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +} diff --git a/database/rds/.terraform.lock.hcl b/database/rds/.terraform.lock.hcl index 5d64317..3b6cb03 100644 --- a/database/rds/.terraform.lock.hcl +++ b/database/rds/.terraform.lock.hcl @@ -3,8 +3,16 @@ provider "registry.opentofu.org/hashicorp/aws" { version = "6.28.0" - constraints = ">= 5.0.0" + constraints = ">= 6.0.0" hashes = [ + "h1:HRIef5R96dZbCL7AtPbGsdJnr6bGjB0P3TnI+N/lEuU=", + "h1:JW/6X82H742ltNqdi1Z+o3Y+diA6fluOH805IOI7kqg=", + "h1:a04viexyZH7XCg13UIibwzkFMfQvMWA//8f9okDFKIs=", + "h1:gls3u4DZH97fapaPejImJ0ITIZo9epFFVhhJZUSuDbs=", + "h1:i0G7vt2sNy0oz84IiuG4gplonNVyOLRdprKLurU8pe4=", + "h1:k967tIoJHNgEt72UJDzlE6ngWN91Ke96zrpgnn2PvUA=", + "h1:kjmhRbLdowE0T9x6hGzO5fCM01Qz+8GntpsaXqe9HiE=", + "h1:tcau98fkhZ2RhbPHo8LdiiUk2RGpZUgT/t06sdMLids=", "h1:wek8vEEZpTPulbLi9xCf2wnxvc97JXAN4qcOhduSg7k=", "zh:38d58305206953783c150fb96d5c4f3ea5fe0b9e0987d927c884a6b0f2adf7a9", "zh:43fd483251165f98b7a44360b41b437d309b007ef2bfff818eedcf3730e3f5cb", diff --git a/database/rds/README.md b/database/rds/README.md index 02c614a..2de09f4 100644 --- a/database/rds/README.md +++ b/database/rds/README.md @@ -382,6 +382,18 @@ module "mysql" { | option_group_engine_version | Option group major engine version. | `string` | `null` | no | | options | Options for the option group. | `list(object)` | `[]` | no | | blue_green_update | Blue/Green deployment configuration. | `object` | `null` | no | +| proxy_creation_enabled | Whether to create an RDS Proxy in front of the database. | `bool` | `false` | no | +| proxy_auth_secret_arns | Secrets Manager secret ARNs for proxy auth (defaults to the managed master user secret). | `list(string)` | `[]` | no | +| proxy_secret_kms_key_arns | KMS key ARNs used to encrypt the proxy auth secrets (customer-managed keys). | `list(string)` | `[]` | no | +| proxy_iam_auth_enabled | Require IAM authentication for proxy connections. | `bool` | `false` | no | +| proxy_tls_requirement_enabled | Require TLS for proxy connections. | `bool` | `true` | no | +| proxy_debug_logging_enabled | Log detailed proxy connection information to CloudWatch Logs. | `bool` | `false` | no | +| proxy_idle_client_timeout | Seconds a client connection can be idle before the proxy disconnects it. | `number` | `1800` | no | +| proxy_connection_borrow_timeout | Seconds the proxy waits for an available connection in the pool. | `number` | `120` | no | +| proxy_init_query | SQL statements the proxy runs when opening each new database connection. | `string` | `null` | no | +| proxy_max_connections_percent | Max proxy connection pool size (% of database max_connections). | `number` | `100` | no | +| proxy_max_idle_connections_percent | Max idle proxy connections (% of database max_connections). | `number` | `50` | no | +| proxy_session_pinning_filters | Session pinning filters (EXCLUDE_VARIABLE_SETS). | `list(string)` | `[]` | no | ## Outputs @@ -415,6 +427,9 @@ module "mysql" { | db_option_group_arn | The option group ARN. | | enhanced_monitoring_iam_role_arn | The Enhanced Monitoring IAM role ARN. | | cloudwatch_alarm_arns | Map of CloudWatch alarm ARNs. | +| proxy_endpoint | The RDS Proxy endpoint (null when no proxy is created). | +| proxy_arn | The RDS Proxy ARN (null when no proxy is created). | +| proxy_security_group_id | The RDS Proxy security group ID (null when no proxy is created). | ## Security Considerations diff --git a/database/rds/locals.tf b/database/rds/locals.tf index 31c14cf..cb42d9a 100644 --- a/database/rds/locals.tf +++ b/database/rds/locals.tf @@ -139,4 +139,20 @@ locals { # DB name handling - SQL Server doesn't support db_name at creation time db_name = local.is_sqlserver ? null : var.db_name + + # RDS Proxy + create_proxy = var.proxy_creation_enabled + proxy_engine_family = ( + local.is_postgres ? "POSTGRESQL" : + local.is_sqlserver ? "SQLSERVER" : + "MYSQL" + ) + proxy_auth_secret_arns = ( + length(var.proxy_auth_secret_arns) > 0 ? var.proxy_auth_secret_arns : + var.master_user_password_management_enabled ? [for s in aws_db_instance.this.master_user_secret : s.secret_arn] : [] + ) + proxy_secret_kms_key_arns = distinct(concat( + var.proxy_secret_kms_key_arns, + try(startswith(var.master_user_secret_kms_key_id, "arn:"), false) ? [var.master_user_secret_kms_key_id] : [] + )) } diff --git a/database/rds/outputs.tf b/database/rds/outputs.tf index 9e91f43..19ffdf9 100644 --- a/database/rds/outputs.tf +++ b/database/rds/outputs.tf @@ -195,3 +195,22 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +################################################################################ +# RDS Proxy Outputs +################################################################################ + +output "proxy_endpoint" { + description = "The endpoint of the RDS Proxy. Null when no proxy is created." + value = local.create_proxy ? module.proxy[0].endpoint : null +} + +output "proxy_arn" { + description = "The ARN of the RDS Proxy. Null when no proxy is created." + value = local.create_proxy ? module.proxy[0].proxy_arn : null +} + +output "proxy_security_group_id" { + description = "The ID of the RDS Proxy security group. Null when no proxy is created." + value = local.create_proxy ? module.proxy[0].security_group_id : null +} diff --git a/database/rds/rds_instance.tf b/database/rds/rds_instance.tf index ffb9724..16ebbee 100644 --- a/database/rds/rds_instance.tf +++ b/database/rds/rds_instance.tf @@ -124,6 +124,16 @@ resource "aws_db_instance" "this" { condition = alltrue([for log in var.enabled_cloudwatch_logs_exports : contains(local.valid_log_exports[local.engine_log_type], log)]) error_message = "Invalid CloudWatch log export type for ${var.engine}. Valid types: ${join(", ", local.valid_log_exports[local.engine_log_type])}" } + + precondition { + condition = !var.proxy_creation_enabled || !local.is_oracle + error_message = "RDS Proxy is not supported for Oracle engines." + } + + precondition { + condition = !var.proxy_creation_enabled || var.master_user_password_management_enabled || length(var.proxy_auth_secret_arns) > 0 + error_message = "proxy_auth_secret_arns is required when proxy_creation_enabled is true and master_user_password_management_enabled is false." + } } depends_on = [ diff --git a/database/rds/rds_proxy.tf b/database/rds/rds_proxy.tf new file mode 100644 index 0000000..99dd6f1 --- /dev/null +++ b/database/rds/rds_proxy.tf @@ -0,0 +1,39 @@ +################################################################################ +# RDS Proxy (optional) +################################################################################ + +module "proxy" { + count = local.create_proxy ? 1 : 0 + + source = "../rds-proxy" + + name = var.name + engine_family = local.proxy_engine_family + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + port = local.port + tags = var.tags + + db_instance_identifier = aws_db_instance.this.identifier + + auth = [ + for arn in local.proxy_auth_secret_arns : { + secret_arn = arn + iam_auth = var.proxy_iam_auth_enabled ? "REQUIRED" : "DISABLED" + } + ] + secret_kms_key_arns = local.proxy_secret_kms_key_arns + + tls_requirement_enabled = var.proxy_tls_requirement_enabled + debug_logging_enabled = var.proxy_debug_logging_enabled + idle_client_timeout = var.proxy_idle_client_timeout + + connection_borrow_timeout = var.proxy_connection_borrow_timeout + init_query = var.proxy_init_query + max_connections_percent = var.proxy_max_connections_percent + max_idle_connections_percent = var.proxy_max_idle_connections_percent + session_pinning_filters = var.proxy_session_pinning_filters + + allowed_security_group_ids = var.allowed_security_group_ids + allowed_cidr_blocks = var.allowed_cidr_blocks +} diff --git a/database/rds/rvn-rds-definition.yml b/database/rds/rvn-rds-definition.yml index 42ce276..d9ec381 100644 --- a/database/rds/rvn-rds-definition.yml +++ b/database/rds/rvn-rds-definition.yml @@ -4,8 +4,8 @@ definition: description: Creates and manages an Amazon RDS database. Supports PostgreSQL, MySQL, MariaDB, Oracle, and SQL Server engines. release: - version: 1.1.1 - description: Update Terraform module sources and documentation links to the renamed `ravionhq` GitHub organization. + version: 1.2.0 + description: Add optional RDS Proxy connection pooling. module: inputs: - id: network @@ -337,6 +337,133 @@ module: show_when: security_group_creation_enabled: true type: string_array + - id: section_proxy + label: Connection pooling + show_when: + engine: + - mysql + - postgres + - mariadb + - sqlserver-ee + - sqlserver-se + - sqlserver-ex + - sqlserver-web + type: section + - default: false + description: Create an RDS Proxy in front of the database for connection pooling and improved failover handling. Applications keep connecting to the database endpoint until you update their connection string to the proxy endpoint output. + id: proxy_creation_enabled + label: RDS Proxy + show_when: + engine: + - mysql + - postgres + - mariadb + - sqlserver-ee + - sqlserver-se + - sqlserver-ex + - sqlserver-web + type: boolean + - add_button_label: Add secret ARN + collapsible: true + description: Secrets Manager secrets containing database credentials for the proxy. Leave blank to use the managed master user secret. + id: proxy_auth_secret_arns + label: Auth secret ARNs + placeholder: arn:aws:secretsmanager:... + required: false + show_when: + proxy_creation_enabled: true + type: string_array + - add_button_label: Add KMS key ARN + collapsible: true + description: KMS keys used to encrypt the auth secrets when using customer-managed keys. The master user secret KMS key is included automatically. + id: proxy_secret_kms_key_arns + label: Auth secret KMS key ARNs + placeholder: arn:aws:kms:... + required: false + show_when: + proxy_creation_enabled: true + type: string_array + - collapsible: true + default: false + description: Require IAM authentication for client connections to the proxy. + id: proxy_iam_auth_enabled + label: IAM authentication + show_when: + proxy_creation_enabled: true + type: boolean + - collapsible: true + default: true + description: Require TLS for client connections to the proxy. + id: proxy_tls_requirement_enabled + label: Require TLS + show_when: + proxy_creation_enabled: true + type: boolean + - collapsible: true + default: false + description: Log detailed connection information, including SQL statements, to CloudWatch Logs. + id: proxy_debug_logging_enabled + label: Debug logging + show_when: + proxy_creation_enabled: true + type: boolean + - collapsible: true + default: 1800 + id: proxy_idle_client_timeout + label: Idle client timeout (seconds) + max: 28800 + min: 1 + show_when: + proxy_creation_enabled: true + type: number + - collapsible: true + default: 120 + id: proxy_connection_borrow_timeout + label: Connection borrow timeout (seconds) + min: 0 + show_when: + proxy_creation_enabled: true + type: number + - collapsible: true + default: 100 + description: Maximum size of the connection pool as a percentage of the database max_connections setting. + id: proxy_max_connections_percent + label: Max connections (%) + max: 100 + min: 1 + show_when: + proxy_creation_enabled: true + type: number + - collapsible: true + default: 50 + description: Maximum idle connections kept open, as a percentage of the database max_connections setting. + id: proxy_max_idle_connections_percent + label: Max idle connections (%) + max: 100 + min: 0 + show_when: + proxy_creation_enabled: true + type: number + - add_button_label: Add filter + collapsible: true + id: proxy_session_pinning_filters + label: Session pinning filters + required: false + show_when: + proxy_creation_enabled: true + type: string_array + values: + - label: EXCLUDE_VARIABLE_SETS + value: EXCLUDE_VARIABLE_SETS + - collapsible: true + description: SQL statements the proxy runs when opening each new database connection. + id: proxy_init_query + label: Initialization query + placeholder: SET time_zone = 'UTC' + required: false + show_when: + proxy_creation_enabled: true + type: string - id: section_availability label: Availability & replicas type: section @@ -990,6 +1117,18 @@ module: performance_insights_kms_key_id: << module.input.performance_insights_kms_key_id >> performance_insights_retention_period: << module.input.performance_insights_retention_period >> port: << module.input.port >> + proxy_auth_secret_arns: << module.input.proxy_auth_secret_arns >> + proxy_connection_borrow_timeout: << module.input.proxy_connection_borrow_timeout >> + proxy_creation_enabled: << module.input.proxy_creation_enabled >> + proxy_debug_logging_enabled: << module.input.proxy_debug_logging_enabled >> + proxy_iam_auth_enabled: << module.input.proxy_iam_auth_enabled >> + proxy_idle_client_timeout: << module.input.proxy_idle_client_timeout >> + proxy_init_query: << module.input.proxy_init_query >> + proxy_max_connections_percent: << module.input.proxy_max_connections_percent >> + proxy_max_idle_connections_percent: << module.input.proxy_max_idle_connections_percent >> + proxy_secret_kms_key_arns: << module.input.proxy_secret_kms_key_arns >> + proxy_session_pinning_filters: << module.input.proxy_session_pinning_filters >> + proxy_tls_requirement_enabled: << module.input.proxy_tls_requirement_enabled >> public_access_enabled: << module.input.public_access_enabled >> read_replica_availability_zones: << module.input.read_replica_availability_zones >> read_replica_count: << module.input.read_replica_count >> @@ -1132,6 +1271,12 @@ module: CloudWatch alarms are optional. When enabled, the module can alarm on CPU utilization, free storage space, and database connections. The free storage alarm threshold is entered in GiB in Ravion and converted to bytes for Terraform. The Ravion UI also shows RDS CloudWatch metrics for CPU, free storage, connections, read IOPS, and write IOPS. + ## Connection pooling + + Enable RDS Proxy to place a managed connection pool in front of the database. The proxy authenticates with the Secrets Manager master user secret by default, requires TLS, and allows the same security group and CIDR sources configured for the database. Pool sizing, idle timeouts, session pinning, IAM authentication, and custom auth secrets are configurable in the Connection pooling section. RDS Proxy is not available for Oracle engines. + + Creating the proxy does not change how applications reach the database. Update your application connection string to the proxy endpoint output to route traffic through the pool. + ## Configuration | Field | Required | Default | Notes | @@ -1158,6 +1303,7 @@ module: | Performance insights | No | true | Enables AWS Performance Insights. | | CloudWatch alarms | No | true | Adds CPU, free storage, and connection alarms. | | Free storage alarm threshold | No | 5 GiB | Converted to bytes before Terraform runs. | + | RDS Proxy | No | false | Creates an RDS Proxy connection pool in front of the database. Not available for Oracle engines. | | Tags | No | Blank | Merged with Ravion standard tags. | ## Advanced configuration diff --git a/database/rds/security_group.tf b/database/rds/security_group.tf index 3baaf8f..39cbef2 100644 --- a/database/rds/security_group.tf +++ b/database/rds/security_group.tf @@ -48,3 +48,20 @@ module "security_group" { } ] } + +# Separate from the security group module so the database can depend on the SG +# without creating a cycle through the proxy target registration. +resource "aws_vpc_security_group_ingress_rule" "proxy" { + count = local.create_security_group && local.create_proxy ? 1 : 0 + + security_group_id = module.security_group[0].security_group_id + description = "Allow ${var.engine} traffic from the RDS Proxy" + from_port = local.port + to_port = local.port + ip_protocol = "tcp" + referenced_security_group_id = module.proxy[0].security_group_id + + tags = merge(local.tags, { + Name = "${var.name}-rds-proxy-ingress" + }) +} diff --git a/database/rds/tests/proxy.tftest.hcl b/database/rds/tests/proxy.tftest.hcl new file mode 100644 index 0000000..7de00a7 --- /dev/null +++ b/database/rds/tests/proxy.tftest.hcl @@ -0,0 +1,202 @@ +################################################################################ +# RDS Proxy Integration Tests +# +# Covers the optional RDS Proxy wiring: engine family mapping, auth secret +# resolution, preconditions, and that the combined plan graph (database +# security group ingress from the proxy security group, proxy targeting the +# database) resolves without dependency cycles. +################################################################################ + +mock_provider "aws" { + override_data { + target = data.aws_caller_identity.current + values = { + account_id = "123456789012" + } + } + + override_data { + target = data.aws_region.current + values = { + id = "us-east-1" + name = "us-east-1" + } + } + + override_data { + target = data.aws_vpc.this + values = { + cidr_block = "10.0.0.0/16" + } + } + + override_data { + target = module.proxy.data.aws_vpc.this + values = { + cidr_block = "10.0.0.0/16" + } + } + + # The proxy security group ID feeds the database security group's ingress + # rule validation, which requires an sg- prefix. + override_resource { + target = module.proxy.module.security_group.aws_security_group.this + values = { + id = "sg-proxy123456" + } + } + + override_resource { + target = module.proxy.aws_iam_role.this + values = { + arn = "arn:aws:iam::123456789012:role/test-db-rds-proxy" + } + } + + override_resource { + target = module.proxy.aws_db_proxy_default_target_group.this + values = { + name = "default" + } + } + + # The mock provider materializes computed lists as empty; provide the managed + # master user secret the proxy auth defaults to. + override_resource { + target = aws_db_instance.this + values = { + master_user_secret = [{ + kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-master-abc123" + secret_status = "active" + }] + } + } +} + +variables { + name = "test-db" + engine = "postgres" + instance_class = "db.t3.micro" + allocated_storage = 20 + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + username = "admin" +} + +run "test_proxy_not_created_by_default" { + command = plan + + assert { + condition = local.create_proxy == false + error_message = "Proxy should not be created by default." + } +} + +# Full plan with the proxy enabled. This exercises the complete dependency +# graph: standalone proxy ingress rule references the proxy security group +# while the proxy targets the database. +run "test_proxy_created_when_enabled" { + command = plan + + variables { + proxy_creation_enabled = true + } + + assert { + condition = local.create_proxy == true + error_message = "Proxy should be created when proxy_creation_enabled is true." + } + + assert { + condition = local.proxy_engine_family == "POSTGRESQL" + error_message = "postgres should map to the POSTGRESQL engine family." + } +} + +run "test_proxy_engine_family_mariadb" { + command = plan + + variables { + engine = "mariadb" + proxy_creation_enabled = true + } + + assert { + condition = local.proxy_engine_family == "MYSQL" + error_message = "mariadb should map to the MYSQL engine family." + } +} + +run "test_proxy_engine_family_sqlserver" { + command = plan + + variables { + engine = "sqlserver-se" + license_model = "license-included" + proxy_creation_enabled = true + } + + assert { + condition = local.proxy_engine_family == "SQLSERVER" + error_message = "sqlserver-se should map to the SQLSERVER engine family." + } +} + +run "test_proxy_rejected_for_oracle" { + command = plan + + variables { + engine = "oracle-ee" + license_model = "bring-your-own-license" + proxy_creation_enabled = true + } + + expect_failures = [ + aws_db_instance.this, + ] +} + +run "test_proxy_requires_secret_when_password_management_disabled" { + command = plan + + variables { + proxy_creation_enabled = true + master_user_password_management_enabled = false + password = "dummy-password-123" + } + + expect_failures = [ + aws_db_instance.this, + ] +} + +run "test_proxy_explicit_auth_secrets" { + command = plan + + variables { + proxy_creation_enabled = true + master_user_password_management_enabled = false + password = "dummy-password-123" + proxy_auth_secret_arns = ["arn:aws:secretsmanager:us-east-1:123456789012:secret:custom-abc123"] + } + + assert { + condition = local.proxy_auth_secret_arns[0] == "arn:aws:secretsmanager:us-east-1:123456789012:secret:custom-abc123" + error_message = "Explicit proxy auth secrets should be used." + } +} + +run "test_proxy_master_secret_kms_key_passthrough" { + command = plan + + variables { + proxy_creation_enabled = true + master_user_secret_kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + } + + assert { + condition = contains(local.proxy_secret_kms_key_arns, "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012") + error_message = "Master user secret KMS key ARN should be passed to the proxy." + } +} diff --git a/database/rds/variables.tf b/database/rds/variables.tf index cd06407..6c7a3b5 100644 --- a/database/rds/variables.tf +++ b/database/rds/variables.tf @@ -761,6 +761,117 @@ variable "blue_green_update" { default = null } +################################################################################ +# RDS Proxy +################################################################################ + +variable "proxy_creation_enabled" { + type = bool + description = "Whether to create an RDS Proxy in front of the database. Requires master_user_password_management_enabled or proxy_auth_secret_arns." + default = false +} + +variable "proxy_auth_secret_arns" { + type = list(string) + description = "List of Secrets Manager secret ARNs containing database credentials for the proxy. Defaults to the managed master user secret when master_user_password_management_enabled is true." + default = [] + + validation { + condition = alltrue([for arn in var.proxy_auth_secret_arns : startswith(arn, "arn:")]) + error_message = "All proxy_auth_secret_arns must be valid Secrets Manager secret ARNs." + } +} + +variable "proxy_secret_kms_key_arns" { + type = list(string) + description = "List of KMS key ARNs used to encrypt the proxy auth secrets when using customer-managed keys. The master user secret KMS key is included automatically." + default = [] + + validation { + condition = alltrue([for arn in var.proxy_secret_kms_key_arns : startswith(arn, "arn:")]) + error_message = "All proxy_secret_kms_key_arns must be valid KMS key ARNs." + } +} + +variable "proxy_iam_auth_enabled" { + type = bool + description = "Whether clients must use IAM authentication to connect to the proxy." + default = false +} + +variable "proxy_tls_requirement_enabled" { + type = bool + description = "Whether Transport Layer Security (TLS) encryption is required for connections to the proxy." + default = true +} + +variable "proxy_debug_logging_enabled" { + type = bool + description = "Whether the proxy logs detailed connection information, including SQL statements, to CloudWatch Logs." + default = false +} + +variable "proxy_idle_client_timeout" { + type = number + description = "The number of seconds a client connection to the proxy can be idle before the proxy disconnects it." + default = 1800 + + validation { + condition = var.proxy_idle_client_timeout >= 1 && var.proxy_idle_client_timeout <= 28800 + error_message = "The proxy_idle_client_timeout must be between 1 and 28800 seconds." + } +} + +variable "proxy_connection_borrow_timeout" { + type = number + description = "The number of seconds the proxy waits for a connection to become available in the connection pool before returning a timeout error." + default = 120 + + validation { + condition = var.proxy_connection_borrow_timeout >= 0 + error_message = "The proxy_connection_borrow_timeout must be greater than or equal to 0." + } +} + +variable "proxy_init_query" { + type = string + description = "One or more SQL statements for the proxy to run when opening each new database connection." + default = null +} + +variable "proxy_max_connections_percent" { + type = number + description = "The maximum size of the proxy connection pool as a percentage of the max_connections setting of the target database." + default = 100 + + validation { + condition = var.proxy_max_connections_percent >= 1 && var.proxy_max_connections_percent <= 100 + error_message = "The proxy_max_connections_percent must be between 1 and 100." + } +} + +variable "proxy_max_idle_connections_percent" { + type = number + description = "The maximum percentage of idle database connections the proxy keeps open, as a percentage of the max_connections setting of the target database." + default = 50 + + validation { + condition = var.proxy_max_idle_connections_percent >= 0 && var.proxy_max_idle_connections_percent <= 100 + error_message = "The proxy_max_idle_connections_percent must be between 0 and 100." + } +} + +variable "proxy_session_pinning_filters" { + type = list(string) + description = "Session pinning filters for the proxy. Valid value: EXCLUDE_VARIABLE_SETS." + default = [] + + validation { + condition = alltrue([for f in var.proxy_session_pinning_filters : f == "EXCLUDE_VARIABLE_SETS"]) + error_message = "The only valid session pinning filter is EXCLUDE_VARIABLE_SETS." + } +} + variable "region" { type = string description = "AWS region. When null, the provider's configured region is used."