# ============================================================================= # AI Software Factory — Data services (RDS, ElastiCache, S3, OpenSearch) # # All of these live in the PRIVATE subnets behind the `data` security group # (security.tf). The app tier reaches them via VPC-internal addressing. # ============================================================================= # --- RDS PostgreSQL 16 (multi-AZ) ------------------------------------------------ resource "aws_db_subnet_group" "main" { name = "${local.names.project}-${local.env}-db-subnets" subnet_ids = aws_subnet.private[*].id tags = { Name = "${local.names.project}-${local.env}-db-subnets" } } resource "aws_db_instance" "postgres" { identifier = local.names.rds engine = "postgres" engine_version = "16.3" instance_class = var.db_instance_class allocated_storage = var.db_allocated_storage_gb max_allocated_storage = var.db_allocated_storage_gb * 3 # Multi-AZ for HA (instance_class default includes .multi-az; the explicit # flag below is belt-and-braces for single-AZ instance classes too). multi_az = true db_name = var.db_name username = "factory" # Password comes from SSM (SecureString) — pre-create: # aws ssm put-parameter --name /factory//DB_PASSWORD \ # --type SecureString --value "$(openssl rand -base64 32)" password = data.aws_ssm_parameter.db_password.value db_subnet_group_name = aws_db_subnet_group.main.name vpc_security_group_ids = [aws_security_group.data.id] # The app connects via the endpoint below (see outputs.tf); extensions # (citext, pg_trgm, btree_gist) are applied by the first prisma migration. publicly_accessible = false apply_immediately = false skip_final_snapshot = true backup_retention_period = 7 auto_minor_version_upgrade = true deletion_protection = var.environment == "prod" storage_encrypted = true kms_key_id = aws_kms_key.app.arn storage_type = "gp3" iops = 3000 throughput = 125 performance_insights_enabled = true tags = { Name = local.names.rds } } # DB password from SSM Parameter Store (created before first apply — see # compute.tf header for the put-parameter command pattern). data "aws_ssm_parameter" "db_password" { name = "/${local.names.project}/${local.env}/DB_PASSWORD" } # --- ElastiCache Redis (single node; upgrade path: add a replica) -------------- resource "aws_elasticache_subnet_group" "main" { name = "${local.names.project}-${local.env}-redis-subnets" subnet_ids = aws_subnet.private[*].id tags = { Name = "${local.names.project}-${local.env}-redis-subnets" } } resource "aws_elasticache_replication_group" "redis" { replication_group_id = local.names.redis description = "${local.names.project}-${local.env} Redis (cache + BullMQ + realtime pubsub)" node_type = var.redis_node_type engine = "redis" engine_version = "7.1" num_cache_clusters = 1 port = 6379 subnet_group_name = aws_elasticache_subnet_group.main.name security_group_ids = [aws_security_group.data.id] parameter_group_name = "default.redis7" # Durable AOF so BullMQ queues survive node replacement. snapshot_retention_limit = 1 snapshot_window = "03:00-04:00" snapshot_name_prefix = local.names.redis at_rest_encryption_enabled = true transit_encryption_enabled = false # app talks over VPC plaintext; enable if you need TLS maintenance_window = "sun:05:00-sun:06:00" apply_immediately = false tags = { Name = local.names.redis } } # --- S3 uploads bucket (versioned, SSE-KMS, lifecycle) ---------------------------- locals { uploads_bucket_name = var.s3_bucket_name != null ? var.s3_bucket_name : "${local.names.project}-${local.env}-factory-uploads" } resource "aws_s3_bucket" "uploads" { bucket = local.uploads_bucket_name force_destroy = false tags = { Name = local.names.s3 } } resource "aws_s3_bucket_versioning" "uploads" { bucket = aws_s3_bucket.uploads.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_server_side_encryption_configuration" "uploads" { bucket = aws_s3_bucket.uploads.id rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" kms_master_key_id = aws_kms_key.app.arn } bucket_key_enabled = true } } # Block ALL public access — the bucket is private; presigned URLs only. resource "aws_s3_bucket_public_access_block" "uploads" { bucket = aws_s3_bucket.uploads.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # Lifecycle: # * incomplete multipart uploads → abort after 7 days # * noncurrent versions → transition to IA after 30 days, remove after 365 # (Keep current versions forever — they are user content.) resource "aws_s3_bucket_lifecycle_configuration" "uploads" { bucket = aws_s3_bucket.uploads.id rule { id = "abort-incomplete-multipart" status = "Enabled" abort_incomplete_multipart_upload { days_after_initiation = 7 } } rule { id = "noncurrent-versions" status = "Enabled" noncurrent_version_transition { days = 30 storage_class = "STANDARD_IA" } noncurrent_version_expiration { days = 365 } } } # Object lock is intentionally NOT enabled (legal hold is out of scope for # this path; enable it on the k8s/managed-storage path if required). # --- OpenSearch domain (3 nodes, VPC) --------------------------------------------- resource "aws_security_group_rule" "opensearch_ingress" { description = "OpenSearch from app tier" type = "ingress" from_port = 443 to_port = 443 protocol = "tcp" security_group_id = aws_security_group.data.id source_security_group_id = aws_security_group.app.id } resource "aws_opensearch_domain" "main" { domain_name = local.names.opensearch domain_endpoint_enable_tls = true engine_version = "OpenSearch_2.15" ebs_volume_size = var.opensearch_volume_gb ebs_volume_type = "gp3" node_instance_type = var.opensearch_node_type cluster_config { instance_count = 3 dedicated_master_enabled = false zone_awareness_enabled = true zone_awareness_config { availability_zone_count = 2 } } # Domain VPC access (private endpoint inside our VPC). vpc_options { security_group_ids = [aws_security_group.data.id] subnet_ids = aws_subnet.private[*].id } # Encryption at rest with our KMS key. encryption_at_rest { enabled = true kms_key_id = aws_kms_key.app.arn } # Fine-grained access control (domain users). The app authenticates with # SEARCH_USERNAME/SEARCH_PASSWORD (an IAM role-backed domain user is the # upgrade path). advanced_security_mode { enabled = true # Master user password from SSM (pre-create like DB_PASSWORD). master_user_options { master_user_name = "admin" master_user_password = data.aws_ssm_parameter.os_admin_password.value } internal_user_database_enabled = true } node_to_node_encryption { enabled = true } # Allow the domain to reach S3 for snapshots (NAT route exists anyway). tags = { Name = local.names.opensearch } } data "aws_ssm_parameter" "os_admin_password" { name = "/${local.names.project}/${local.env}/OS_ADMIN_PASSWORD" } resource "aws_opensearch_domain_policy" "main" { domain_name = aws_opensearch_domain.main.domain_name policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Principal = "*" Action = "es:*" Resource = "*" Condition = { ArnLike = { "aws:PrincipalArn" = aws_iam_role.app.arn } } } ] }) }