1# ============================================================================= 2# AI Software Factory — Data services (RDS, ElastiCache, S3, OpenSearch) 3# 4# All of these live in the PRIVATE subnets behind the `data` security group 5# (security.tf). The app tier reaches them via VPC-internal addressing. 6# ============================================================================= 7 8# --- RDS PostgreSQL 16 (multi-AZ) ------------------------------------------------ 9 10resource "aws_db_subnet_group" "main" { 11 name = "${local.names.project}-${local.env}-db-subnets" 12 subnet_ids = aws_subnet.private[*].id 13 14 tags = { 15 Name = "${local.names.project}-${local.env}-db-subnets" 16 } 17} 18 19resource "aws_db_instance" "postgres" { 20 identifier = local.names.rds 21 engine = "postgres" 22 engine_version = "16.3" 23 instance_class = var.db_instance_class 24 allocated_storage = var.db_allocated_storage_gb 25 max_allocated_storage = var.db_allocated_storage_gb * 3 26 27 # Multi-AZ for HA (instance_class default includes .multi-az; the explicit 28 # flag below is belt-and-braces for single-AZ instance classes too). 29 multi_az = true 30 31 db_name = var.db_name 32 username = "factory" 33 # Password comes from SSM (SecureString) — pre-create: 34 # aws ssm put-parameter --name /factory/<env>/DB_PASSWORD \ 35# --type SecureString --value "$(openssl rand -base64 32)" 36 password = data.aws_ssm_parameter.db_password.value 37 38 db_subnet_group_name = aws_db_subnet_group.main.name 39 vpc_security_group_ids = [aws_security_group.data.id] 40 41 # The app connects via the endpoint below (see outputs.tf); extensions 42 # (citext, pg_trgm, btree_gist) are applied by the first prisma migration. 43 44 publicly_accessible = false 45 apply_immediately = false 46 skip_final_snapshot = true 47 48 backup_retention_period = 7 49 auto_minor_version_upgrade = true 50 deletion_protection = var.environment == "prod" 51 52 storage_encrypted = true 53 kms_key_id = aws_kms_key.app.arn 54 storage_type = "gp3" 55 iops = 3000 56 throughput = 125 57 58 performance_insights_enabled = true 59 60 tags = { 61 Name = local.names.rds 62 } 63} 64 65# DB password from SSM Parameter Store (created before first apply — see 66# compute.tf header for the put-parameter command pattern). 67data "aws_ssm_parameter" "db_password" { 68 name = "/${local.names.project}/${local.env}/DB_PASSWORD" 69} 70 71# --- ElastiCache Redis (single node; upgrade path: add a replica) -------------- 72 73resource "aws_elasticache_subnet_group" "main" { 74 name = "${local.names.project}-${local.env}-redis-subnets" 75 subnet_ids = aws_subnet.private[*].id 76 77 tags = { 78 Name = "${local.names.project}-${local.env}-redis-subnets" 79 } 80} 81 82resource "aws_elasticache_replication_group" "redis" { 83 replication_group_id = local.names.redis 84 description = "${local.names.project}-${local.env} Redis (cache + BullMQ + realtime pubsub)" 85 86 node_type = var.redis_node_type 87 engine = "redis" 88 engine_version = "7.1" 89 num_cache_clusters = 1 90 port = 6379 91 92 subnet_group_name = aws_elasticache_subnet_group.main.name 93 security_group_ids = [aws_security_group.data.id] 94 parameter_group_name = "default.redis7" 95 96 # Durable AOF so BullMQ queues survive node replacement. 97 snapshot_retention_limit = 1 98 snapshot_window = "03:00-04:00" 99 snapshot_name_prefix = local.names.redis 100 101 at_rest_encryption_enabled = true 102 transit_encryption_enabled = false # app talks over VPC plaintext; enable if you need TLS 103 104 maintenance_window = "sun:05:00-sun:06:00" 105 apply_immediately = false 106 107 tags = { 108 Name = local.names.redis 109 } 110} 111 112# --- S3 uploads bucket (versioned, SSE-KMS, lifecycle) ---------------------------- 113 114locals { 115 uploads_bucket_name = var.s3_bucket_name != null ? var.s3_bucket_name : "${local.names.project}-${local.env}-factory-uploads" 116} 117 118resource "aws_s3_bucket" "uploads" { 119 bucket = local.uploads_bucket_name 120 force_destroy = false 121 122 tags = { 123 Name = local.names.s3 124 } 125} 126 127resource "aws_s3_bucket_versioning" "uploads" { 128 bucket = aws_s3_bucket.uploads.id 129 versioning_configuration { 130 status = "Enabled" 131 } 132} 133 134resource "aws_s3_bucket_server_side_encryption_configuration" "uploads" { 135 bucket = aws_s3_bucket.uploads.id 136 137 rule { 138 apply_server_side_encryption_by_default { 139 sse_algorithm = "aws:kms" 140 kms_master_key_id = aws_kms_key.app.arn 141 } 142 bucket_key_enabled = true 143 } 144} 145 146# Block ALL public access — the bucket is private; presigned URLs only. 147resource "aws_s3_bucket_public_access_block" "uploads" { 148 bucket = aws_s3_bucket.uploads.id 149 150 block_public_acls = true 151 block_public_policy = true 152 ignore_public_acls = true 153 restrict_public_buckets = true 154} 155 156# Lifecycle: 157# * incomplete multipart uploads → abort after 7 days 158# * noncurrent versions → transition to IA after 30 days, remove after 365 159# (Keep current versions forever — they are user content.) 160resource "aws_s3_bucket_lifecycle_configuration" "uploads" { 161 bucket = aws_s3_bucket.uploads.id 162 163 rule { 164 id = "abort-incomplete-multipart" 165 status = "Enabled" 166 167 abort_incomplete_multipart_upload { 168 days_after_initiation = 7 169 } 170 } 171 172 rule { 173 id = "noncurrent-versions" 174 status = "Enabled" 175 176 noncurrent_version_transition { 177 days = 30 178 storage_class = "STANDARD_IA" 179 } 180 181 noncurrent_version_expiration { 182 days = 365 183 } 184 } 185} 186 187# Object lock is intentionally NOT enabled (legal hold is out of scope for 188# this path; enable it on the k8s/managed-storage path if required). 189 190# --- OpenSearch domain (3 nodes, VPC) --------------------------------------------- 191 192resource "aws_security_group_rule" "opensearch_ingress" { 193 description = "OpenSearch from app tier" 194 type = "ingress" 195 from_port = 443 196 to_port = 443 197 protocol = "tcp" 198 security_group_id = aws_security_group.data.id 199 source_security_group_id = aws_security_group.app.id 200} 201 202resource "aws_opensearch_domain" "main" { 203 domain_name = local.names.opensearch 204 domain_endpoint_enable_tls = true 205 206 engine_version = "OpenSearch_2.15" 207 208 ebs_volume_size = var.opensearch_volume_gb 209 ebs_volume_type = "gp3" 210 node_instance_type = var.opensearch_node_type 211 cluster_config { 212 instance_count = 3 213 dedicated_master_enabled = false 214 zone_awareness_enabled = true 215 zone_awareness_config { 216 availability_zone_count = 2 217 } 218 } 219 220 # Domain VPC access (private endpoint inside our VPC). 221 vpc_options { 222 security_group_ids = [aws_security_group.data.id] 223 subnet_ids = aws_subnet.private[*].id 224 } 225 226 # Encryption at rest with our KMS key. 227 encryption_at_rest { 228 enabled = true 229 kms_key_id = aws_kms_key.app.arn 230 } 231 232 # Fine-grained access control (domain users). The app authenticates with 233 # SEARCH_USERNAME/SEARCH_PASSWORD (an IAM role-backed domain user is the 234 # upgrade path). 235 advanced_security_mode { 236 enabled = true 237 # Master user password from SSM (pre-create like DB_PASSWORD). 238 master_user_options { 239 master_user_name = "admin" 240 master_user_password = data.aws_ssm_parameter.os_admin_password.value 241 } 242 internal_user_database_enabled = true 243 } 244 245 node_to_node_encryption { 246 enabled = true 247 } 248 249 # Allow the domain to reach S3 for snapshots (NAT route exists anyway). 250 tags = { 251 Name = local.names.opensearch 252 } 253} 254 255data "aws_ssm_parameter" "os_admin_password" { 256 name = "/${local.names.project}/${local.env}/OS_ADMIN_PASSWORD" 257} 258 259resource "aws_opensearch_domain_policy" "main" { 260 domain_name = aws_opensearch_domain.main.domain_name 261 262 policy = jsonencode({ 263 Version = "2012-10-17" 264 Statement = [ 265 { 266 Effect = "Allow" 267 Principal = "*" 268 Action = "es:*" 269 Resource = "*" 270 Condition = { 271 ArnLike = { 272 "aws:PrincipalArn" = aws_iam_role.app.arn 273 } 274 } 275 } 276 ] 277 }) 278} 279