feat(phase-2): workstream 8 — Multi-Region Terraform Deployment
AWS environment: - VPC (3-AZ, public + private subnets, NAT gateways, VPC endpoints for ECR/SM/CW) - ECS Fargate service (sentryagent/agentidp) — secrets from Secrets Manager - RDS PostgreSQL 14 (Multi-AZ, encrypted, VPC-internal, storage autoscaling) - ElastiCache Redis 7 (primary + replica, at-rest + in-transit encryption) - ALB with HTTPS/443, HTTP→HTTPS redirect, ACM certificate - Route 53 alias record GCP environment: - VPC + private services access + Serverless VPC connector - Cloud Run service — secrets from Secret Manager - Cloud SQL PostgreSQL 14 (private IP, no public endpoint) - Cloud Memorystore Redis 7 (VPC-internal, AUTH enabled) Shared: - 4 reusable modules: agentidp (dual AWS/GCP), rds, redis, lb - No hardcoded secrets; all sensitive vars marked sensitive=true - terraform.tfvars.example for both environments - docs/devops/deployment.md — AWS + GCP step-by-step walkthrough, rollback procedures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
640
terraform/environments/aws/main.tf
Normal file
640
terraform/environments/aws/main.tf
Normal file
@@ -0,0 +1,640 @@
|
||||
################################################################################
|
||||
# Environment: aws
|
||||
# Main — SentryAgent.ai AgentIdP on AWS
|
||||
#
|
||||
# Architecture:
|
||||
# Internet → Route 53 → ALB (public subnets, HTTPS/443) →
|
||||
# ECS Fargate tasks (private subnets) →
|
||||
# RDS PostgreSQL 14 (private subnets, Multi-AZ) +
|
||||
# ElastiCache Redis 7 (private subnets, primary + replica)
|
||||
#
|
||||
# All secrets stored in AWS Secrets Manager — ECS tasks pull at launch time.
|
||||
# No sensitive values in state (except where Terraform internals require it).
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Remote state — configure your backend here.
|
||||
# Example using S3 + DynamoDB state locking:
|
||||
#
|
||||
# backend "s3" {
|
||||
# bucket = "sentryagent-terraform-state"
|
||||
# key = "agentidp/aws/production/terraform.tfstate"
|
||||
# region = "us-east-1"
|
||||
# encrypt = true
|
||||
# dynamodb_table = "sentryagent-terraform-locks"
|
||||
# }
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
managed_by = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# VPC
|
||||
################################################################################
|
||||
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = var.vpc_cidr
|
||||
enable_dns_support = true
|
||||
enable_dns_hostnames = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "main" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-igw"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Subnets
|
||||
################################################################################
|
||||
|
||||
resource "aws_subnet" "public" {
|
||||
count = length(var.availability_zones)
|
||||
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = var.public_subnet_cidrs[count.index]
|
||||
availability_zone = var.availability_zones[count.index]
|
||||
map_public_ip_on_launch = false
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-public-${var.availability_zones[count.index]}"
|
||||
tier = "public"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_subnet" "private" {
|
||||
count = length(var.availability_zones)
|
||||
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = var.private_subnet_cidrs[count.index]
|
||||
availability_zone = var.availability_zones[count.index]
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-private-${var.availability_zones[count.index]}"
|
||||
tier = "private"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# NAT Gateways — one per AZ for HA outbound from private subnets
|
||||
# ECS tasks need outbound internet to pull ECR images and reach Secrets Manager.
|
||||
################################################################################
|
||||
|
||||
resource "aws_eip" "nat" {
|
||||
count = length(var.availability_zones)
|
||||
domain = "vpc"
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-nat-eip-${var.availability_zones[count.index]}"
|
||||
}
|
||||
|
||||
depends_on = [aws_internet_gateway.main]
|
||||
}
|
||||
|
||||
resource "aws_nat_gateway" "main" {
|
||||
count = length(var.availability_zones)
|
||||
|
||||
allocation_id = aws_eip.nat[count.index].id
|
||||
subnet_id = aws_subnet.public[count.index].id
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-nat-${var.availability_zones[count.index]}"
|
||||
}
|
||||
|
||||
depends_on = [aws_internet_gateway.main]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route Tables
|
||||
################################################################################
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.main.id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-public-rt"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "public" {
|
||||
count = length(aws_subnet.public)
|
||||
|
||||
subnet_id = aws_subnet.public[count.index].id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
resource "aws_route_table" "private" {
|
||||
count = length(var.availability_zones)
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
nat_gateway_id = aws_nat_gateway.main[count.index].id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-private-rt-${var.availability_zones[count.index]}"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "private" {
|
||||
count = length(aws_subnet.private)
|
||||
|
||||
subnet_id = aws_subnet.private[count.index].id
|
||||
route_table_id = aws_route_table.private[count.index].id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# VPC Endpoints — allow ECS tasks to reach AWS services without NAT
|
||||
################################################################################
|
||||
|
||||
resource "aws_vpc_endpoint" "secretsmanager" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
service_name = "com.amazonaws.${var.region}.secretsmanager"
|
||||
vpc_endpoint_type = "Interface"
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
private_dns_enabled = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-secretsmanager-endpoint"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_vpc_endpoint" "ecr_api" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
service_name = "com.amazonaws.${var.region}.ecr.api"
|
||||
vpc_endpoint_type = "Interface"
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
private_dns_enabled = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-ecr-api-endpoint"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_vpc_endpoint" "ecr_dkr" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
service_name = "com.amazonaws.${var.region}.ecr.dkr"
|
||||
vpc_endpoint_type = "Interface"
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
private_dns_enabled = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-ecr-dkr-endpoint"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_vpc_endpoint" "s3" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
service_name = "com.amazonaws.${var.region}.s3"
|
||||
vpc_endpoint_type = "Gateway"
|
||||
route_table_ids = aws_route_table.private[*].id
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-s3-endpoint"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_vpc_endpoint" "cloudwatch_logs" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
service_name = "com.amazonaws.${var.region}.logs"
|
||||
vpc_endpoint_type = "Interface"
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
private_dns_enabled = true
|
||||
|
||||
tags = {
|
||||
Name = "${var.project}-${var.environment}-logs-endpoint"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM — ECS Task Execution Role
|
||||
# Allows ECS to pull images from ECR, write logs, and fetch secrets.
|
||||
################################################################################
|
||||
|
||||
data "aws_iam_policy_document" "ecs_task_execution_assume" {
|
||||
statement {
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["ecs-tasks.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "ecs_task_execution" {
|
||||
name = "${var.project}-${var.environment}-ecs-execution-role"
|
||||
assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume.json
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "ecs_task_execution_managed" {
|
||||
role = aws_iam_role.ecs_task_execution.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
# Allow the execution role to fetch the specific secrets it needs
|
||||
data "aws_iam_policy_document" "ecs_task_execution_secrets" {
|
||||
statement {
|
||||
sid = "GetAppSecrets"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret"
|
||||
]
|
||||
resources = concat(
|
||||
[
|
||||
aws_secretsmanager_secret.database_url.arn,
|
||||
aws_secretsmanager_secret.redis_url.arn,
|
||||
aws_secretsmanager_secret.jwt_private_key.arn,
|
||||
aws_secretsmanager_secret.jwt_public_key.arn,
|
||||
],
|
||||
var.vault_token != "" ? [aws_secretsmanager_secret.vault_token[0].arn] : []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "ecs_task_execution_secrets" {
|
||||
name = "${var.project}-${var.environment}-secrets-policy"
|
||||
role = aws_iam_role.ecs_task_execution.id
|
||||
policy = data.aws_iam_policy_document.ecs_task_execution_secrets.json
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM — ECS Task Role
|
||||
# Permissions granted to the running application container.
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "ecs_task" {
|
||||
name = "${var.project}-${var.environment}-ecs-task-role"
|
||||
assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume.json
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
# ECS task role policy — extend as needed for other AWS service calls.
|
||||
data "aws_iam_policy_document" "ecs_task" {
|
||||
statement {
|
||||
sid = "AllowCloudWatchMetrics"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"cloudwatch:PutMetricData"
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "ecs_task" {
|
||||
name = "${var.project}-${var.environment}-task-policy"
|
||||
role = aws_iam_role.ecs_task.id
|
||||
policy = data.aws_iam_policy_document.ecs_task.json
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM — RDS Enhanced Monitoring Role
|
||||
################################################################################
|
||||
|
||||
data "aws_iam_policy_document" "rds_monitoring_assume" {
|
||||
statement {
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["monitoring.rds.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "rds_monitoring" {
|
||||
name = "${var.project}-${var.environment}-rds-monitoring-role"
|
||||
assume_role_policy = data.aws_iam_policy_document.rds_monitoring_assume.json
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "rds_monitoring" {
|
||||
role = aws_iam_role.rds_monitoring.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# AWS Secrets Manager — store all sensitive values
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret" "database_url" {
|
||||
name = "/${var.project}/${var.environment}/database-url"
|
||||
description = "PostgreSQL DATABASE_URL for AgentIdP"
|
||||
recovery_window_in_days = 7
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "database_url" {
|
||||
secret_id = aws_secretsmanager_secret.database_url.id
|
||||
# Build the DATABASE_URL using the RDS endpoint output.
|
||||
# The password is passed in as var.db_password so it never appears in plaintext
|
||||
# in any .tf file — only in this encrypted secret version.
|
||||
secret_string = "postgresql://${var.project}:${var.db_password}@${module.rds.endpoint}:${module.rds.port}/${module.rds.db_name}?sslmode=require"
|
||||
|
||||
depends_on = [module.rds]
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "redis_url" {
|
||||
name = "/${var.project}/${var.environment}/redis-url"
|
||||
description = "Redis REDIS_URL for AgentIdP"
|
||||
recovery_window_in_days = 7
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "redis_url" {
|
||||
secret_id = aws_secretsmanager_secret.redis_url.id
|
||||
# ElastiCache Redis with TLS uses the rediss:// scheme and requires an AUTH token.
|
||||
secret_string = "rediss://:${var.redis_auth_token}@${module.redis.primary_endpoint}:${module.redis.port}"
|
||||
|
||||
depends_on = [module.redis]
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "jwt_private_key" {
|
||||
name = "/${var.project}/${var.environment}/jwt-private-key"
|
||||
description = "RSA-2048 private key for signing AgentIdP JWTs"
|
||||
recovery_window_in_days = 7
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "jwt_private_key" {
|
||||
secret_id = aws_secretsmanager_secret.jwt_private_key.id
|
||||
secret_string = var.jwt_private_key
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "jwt_public_key" {
|
||||
name = "/${var.project}/${var.environment}/jwt-public-key"
|
||||
description = "RSA-2048 public key for verifying AgentIdP JWTs"
|
||||
recovery_window_in_days = 7
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "jwt_public_key" {
|
||||
secret_id = aws_secretsmanager_secret.jwt_public_key.id
|
||||
secret_string = var.jwt_public_key
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "vault_token" {
|
||||
count = var.vault_token != "" ? 1 : 0
|
||||
|
||||
name = "/${var.project}/${var.environment}/vault-token"
|
||||
description = "HashiCorp Vault token for AgentIdP"
|
||||
recovery_window_in_days = 7
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "vault_token" {
|
||||
count = var.vault_token != "" ? 1 : 0
|
||||
|
||||
secret_id = aws_secretsmanager_secret.vault_token[0].id
|
||||
secret_string = var.vault_token
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Module: Load Balancer
|
||||
################################################################################
|
||||
|
||||
module "lb" {
|
||||
source = "../../modules/lb"
|
||||
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
vpc_id = aws_vpc.main.id
|
||||
subnet_ids = aws_subnet.public[*].id
|
||||
certificate_arn = var.certificate_arn
|
||||
|
||||
target_group_port = 3000
|
||||
enable_deletion_protection = true
|
||||
access_logs_bucket = var.alb_access_logs_bucket
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Module: RDS PostgreSQL
|
||||
################################################################################
|
||||
|
||||
module "rds" {
|
||||
source = "../../modules/rds"
|
||||
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
vpc_id = aws_vpc.main.id
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
|
||||
# The app SG is created by the agentidp module; we wire it after both modules
|
||||
# are instantiated using a separate security group rule (see below).
|
||||
allowed_security_group_ids = []
|
||||
|
||||
db_name = "sentryagent_idp"
|
||||
db_username = var.project
|
||||
db_password = var.db_password
|
||||
|
||||
instance_class = var.rds_instance_class
|
||||
allocated_storage = 50
|
||||
max_allocated_storage = 500
|
||||
multi_az = true
|
||||
backup_retention_days = var.rds_backup_retention_days
|
||||
deletion_protection = var.rds_deletion_protection
|
||||
skip_final_snapshot = var.rds_skip_final_snapshot
|
||||
monitoring_role_arn = aws_iam_role.rds_monitoring.arn
|
||||
monitoring_interval = 60
|
||||
performance_insights_enabled = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Module: Redis
|
||||
################################################################################
|
||||
|
||||
module "redis" {
|
||||
source = "../../modules/redis"
|
||||
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
vpc_id = aws_vpc.main.id
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
|
||||
# Same pattern as RDS — app SG wired after agentidp module creates it.
|
||||
allowed_security_group_ids = []
|
||||
|
||||
node_type = var.redis_node_type
|
||||
num_cache_clusters = 2
|
||||
automatic_failover_enabled = true
|
||||
multi_az_enabled = true
|
||||
at_rest_encryption_enabled = true
|
||||
transit_encryption_enabled = true
|
||||
auth_token = var.redis_auth_token
|
||||
snapshot_retention_limit = 7
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Module: AgentIdP (ECS Fargate)
|
||||
################################################################################
|
||||
|
||||
module "agentidp" {
|
||||
source = "../../modules/agentidp"
|
||||
|
||||
provider_type = "aws"
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
app_image = "sentryagent/agentidp:${var.app_image_tag}"
|
||||
app_port = 3000
|
||||
|
||||
aws_region = var.region
|
||||
aws_vpc_id = aws_vpc.main.id
|
||||
aws_subnet_ids = aws_subnet.private[*].id
|
||||
aws_target_group_arn = module.lb.target_group_arn
|
||||
aws_execution_role_arn = aws_iam_role.ecs_task_execution.arn
|
||||
aws_task_role_arn = aws_iam_role.ecs_task.arn
|
||||
aws_log_group_name = "/ecs/${var.project}-${var.environment}"
|
||||
aws_desired_count = var.ecs_desired_count
|
||||
aws_cpu = 512
|
||||
aws_memory = 1024
|
||||
aws_cors_origin = var.cors_origin
|
||||
aws_policy_dir = "/app/policies"
|
||||
aws_vault_addr = var.vault_addr
|
||||
aws_vault_mount = var.vault_mount
|
||||
|
||||
aws_secret_database_url_arn = aws_secretsmanager_secret.database_url.arn
|
||||
aws_secret_redis_url_arn = aws_secretsmanager_secret.redis_url.arn
|
||||
aws_secret_jwt_private_key_arn = aws_secretsmanager_secret.jwt_private_key.arn
|
||||
aws_secret_jwt_public_key_arn = aws_secretsmanager_secret.jwt_public_key.arn
|
||||
aws_secret_vault_token_arn = var.vault_token != "" ? aws_secretsmanager_secret.vault_token[0].arn : ""
|
||||
|
||||
depends_on = [
|
||||
aws_secretsmanager_secret_version.database_url,
|
||||
aws_secretsmanager_secret_version.redis_url,
|
||||
aws_secretsmanager_secret_version.jwt_private_key,
|
||||
aws_secretsmanager_secret_version.jwt_public_key,
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Cross-module security group wiring
|
||||
#
|
||||
# The app SG (from agentidp module) must be allowed into RDS and Redis.
|
||||
# These rules are created after both modules are fully instantiated to avoid
|
||||
# circular references in the module dependency graph.
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group_rule" "rds_from_app" {
|
||||
type = "ingress"
|
||||
description = "PostgreSQL from ECS app tasks"
|
||||
from_port = 5432
|
||||
to_port = 5432
|
||||
protocol = "tcp"
|
||||
source_security_group_id = module.agentidp.aws_app_security_group_id
|
||||
security_group_id = module.rds.security_group_id
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "redis_from_app" {
|
||||
type = "ingress"
|
||||
description = "Redis from ECS app tasks"
|
||||
from_port = 6379
|
||||
to_port = 6379
|
||||
protocol = "tcp"
|
||||
source_security_group_id = module.agentidp.aws_app_security_group_id
|
||||
security_group_id = module.redis.security_group_id
|
||||
}
|
||||
|
||||
# Allow the ALB to reach ECS tasks on the app port
|
||||
resource "aws_security_group_rule" "app_from_alb" {
|
||||
type = "ingress"
|
||||
description = "App port from ALB"
|
||||
from_port = 3000
|
||||
to_port = 3000
|
||||
protocol = "tcp"
|
||||
source_security_group_id = module.lb.alb_security_group_id
|
||||
security_group_id = module.agentidp.aws_app_security_group_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route 53 — alias record pointing the domain to the ALB
|
||||
################################################################################
|
||||
|
||||
data "aws_route53_zone" "main" {
|
||||
name = join(".", slice(split(".", var.domain_name), 1, length(split(".", var.domain_name))))
|
||||
private_zone = false
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "app" {
|
||||
zone_id = data.aws_route53_zone.main.zone_id
|
||||
name = var.domain_name
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = module.lb.alb_dns_name
|
||||
zone_id = module.lb.alb_zone_id
|
||||
evaluate_target_health = true
|
||||
}
|
||||
}
|
||||
84
terraform/environments/aws/outputs.tf
Normal file
84
terraform/environments/aws/outputs.tf
Normal file
@@ -0,0 +1,84 @@
|
||||
################################################################################
|
||||
# Environment: aws
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "alb_dns_name" {
|
||||
description = "DNS name of the Application Load Balancer."
|
||||
value = module.lb.alb_dns_name
|
||||
}
|
||||
|
||||
output "service_url" {
|
||||
description = "Public HTTPS URL of the AgentIdP service."
|
||||
value = "https://${var.domain_name}"
|
||||
}
|
||||
|
||||
output "ecs_cluster_arn" {
|
||||
description = "ARN of the ECS cluster."
|
||||
value = module.agentidp.aws_ecs_cluster_arn
|
||||
}
|
||||
|
||||
output "ecs_service_name" {
|
||||
description = "Name of the ECS Fargate service."
|
||||
value = module.agentidp.aws_ecs_service_name
|
||||
}
|
||||
|
||||
output "ecs_task_definition_arn" {
|
||||
description = "Active ECS task definition ARN."
|
||||
value = module.agentidp.aws_ecs_task_definition_arn
|
||||
}
|
||||
|
||||
output "rds_endpoint" {
|
||||
description = "RDS PostgreSQL endpoint hostname."
|
||||
value = module.rds.endpoint
|
||||
}
|
||||
|
||||
output "rds_port" {
|
||||
description = "RDS PostgreSQL port."
|
||||
value = module.rds.port
|
||||
}
|
||||
|
||||
output "rds_instance_id" {
|
||||
description = "RDS instance identifier."
|
||||
value = module.rds.instance_id
|
||||
}
|
||||
|
||||
output "redis_primary_endpoint" {
|
||||
description = "ElastiCache Redis primary endpoint hostname."
|
||||
value = module.redis.primary_endpoint
|
||||
}
|
||||
|
||||
output "redis_reader_endpoint" {
|
||||
description = "ElastiCache Redis reader endpoint."
|
||||
value = module.redis.reader_endpoint
|
||||
}
|
||||
|
||||
output "vpc_id" {
|
||||
description = "ID of the VPC created for this deployment."
|
||||
value = aws_vpc.main.id
|
||||
}
|
||||
|
||||
output "private_subnet_ids" {
|
||||
description = "IDs of the private subnets (ECS, RDS, Redis)."
|
||||
value = aws_subnet.private[*].id
|
||||
}
|
||||
|
||||
output "public_subnet_ids" {
|
||||
description = "IDs of the public subnets (ALB)."
|
||||
value = aws_subnet.public[*].id
|
||||
}
|
||||
|
||||
output "cloudwatch_log_group" {
|
||||
description = "CloudWatch log group for ECS container logs."
|
||||
value = module.agentidp.aws_cloudwatch_log_group_name
|
||||
}
|
||||
|
||||
output "secrets_manager_database_url_arn" {
|
||||
description = "ARN of the Secrets Manager secret holding DATABASE_URL."
|
||||
value = aws_secretsmanager_secret.database_url.arn
|
||||
}
|
||||
|
||||
output "secrets_manager_redis_url_arn" {
|
||||
description = "ARN of the Secrets Manager secret holding REDIS_URL."
|
||||
value = aws_secretsmanager_secret.redis_url.arn
|
||||
}
|
||||
76
terraform/environments/aws/terraform.tfvars.example
Normal file
76
terraform/environments/aws/terraform.tfvars.example
Normal file
@@ -0,0 +1,76 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# terraform/environments/aws/terraform.tfvars.example
|
||||
#
|
||||
# Copy this file to terraform.tfvars and fill in real values.
|
||||
# NEVER commit terraform.tfvars to version control — it contains secrets.
|
||||
#
|
||||
# All sensitive variables (db_password, jwt_*, vault_token) must be provided
|
||||
# via this file or as TF_VAR_* environment variables in your CI/CD pipeline.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Region & environment ──────────────────────────────────────────────────────
|
||||
|
||||
region = "us-east-1"
|
||||
environment = "production"
|
||||
project = "sentryagent-agentidp"
|
||||
|
||||
# ── Application image ─────────────────────────────────────────────────────────
|
||||
# Docker image tag to deploy. Update this to roll out a new version.
|
||||
|
||||
app_image_tag = "1.0.0"
|
||||
|
||||
# ── DNS & TLS ─────────────────────────────────────────────────────────────────
|
||||
# The ACM certificate must already exist in the same region as the ALB.
|
||||
# Create it with: aws acm request-certificate --domain-name idp.example.com --validation-method DNS
|
||||
|
||||
domain_name = "idp.example.com"
|
||||
certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
|
||||
# ── Networking ────────────────────────────────────────────────────────────────
|
||||
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
|
||||
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
|
||||
private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
|
||||
|
||||
# ── Secrets — REPLACE ALL VALUES BELOW ───────────────────────────────────────
|
||||
# Use strong, randomly generated values. Do NOT use these placeholders in production.
|
||||
|
||||
# Master password for RDS PostgreSQL (min 8 chars, no special chars that break URLs)
|
||||
db_password = "REPLACE_WITH_STRONG_RANDOM_PASSWORD"
|
||||
|
||||
# AUTH token for ElastiCache Redis (min 16 chars)
|
||||
redis_auth_token = "REPLACE_WITH_STRONG_RANDOM_TOKEN_AT_LEAST_16_CHARS"
|
||||
|
||||
# RSA-2048 key pair for JWT signing/verification.
|
||||
# Generate with:
|
||||
# openssl genrsa -out private.pem 2048
|
||||
# openssl rsa -in private.pem -pubout -out public.pem
|
||||
jwt_private_key = "-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_ACTUAL_PRIVATE_KEY_CONTENTS\n-----END RSA PRIVATE KEY-----"
|
||||
jwt_public_key = "-----BEGIN PUBLIC KEY-----\nREPLACE_WITH_ACTUAL_PUBLIC_KEY_CONTENTS\n-----END PUBLIC KEY-----"
|
||||
|
||||
# HashiCorp Vault (optional — leave empty strings to disable Vault integration)
|
||||
vault_addr = ""
|
||||
vault_token = ""
|
||||
vault_mount = "secret"
|
||||
|
||||
# ── Application configuration ─────────────────────────────────────────────────
|
||||
|
||||
cors_origin = "*"
|
||||
ecs_desired_count = 2
|
||||
|
||||
# ── Infrastructure sizing ─────────────────────────────────────────────────────
|
||||
|
||||
rds_instance_class = "db.t3.medium"
|
||||
redis_node_type = "cache.t3.medium"
|
||||
|
||||
# ── ALB access logs (optional) ────────────────────────────────────────────────
|
||||
# Create the S3 bucket and enable ALB log delivery permissions before setting this.
|
||||
|
||||
alb_access_logs_bucket = ""
|
||||
|
||||
# ── RDS settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
rds_backup_retention_days = 7
|
||||
rds_deletion_protection = true
|
||||
rds_skip_final_snapshot = false
|
||||
164
terraform/environments/aws/variables.tf
Normal file
164
terraform/environments/aws/variables.tf
Normal file
@@ -0,0 +1,164 @@
|
||||
################################################################################
|
||||
# Environment: aws
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
description = "AWS region for all resources."
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "environment" {
|
||||
description = "Deployment environment (e.g. production, staging)."
|
||||
type = string
|
||||
default = "production"
|
||||
}
|
||||
|
||||
variable "project" {
|
||||
description = "Project identifier — used in all resource names and tags."
|
||||
type = string
|
||||
default = "sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "app_image_tag" {
|
||||
description = "Docker image tag to deploy (e.g. '1.2.3' or a full SHA)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "domain_name" {
|
||||
description = "Primary domain name for the AgentIdP service (e.g. idp.sentryagent.ai)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
description = "ARN of the ACM certificate for the domain_name. Must be in the same region as the ALB."
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Networking
|
||||
################################################################################
|
||||
|
||||
variable "vpc_cidr" {
|
||||
description = "CIDR block for the VPC."
|
||||
type = string
|
||||
default = "10.0.0.0/16"
|
||||
}
|
||||
|
||||
variable "availability_zones" {
|
||||
description = "List of Availability Zones to use. Must contain at least 2 for Multi-AZ resources."
|
||||
type = list(string)
|
||||
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
|
||||
}
|
||||
|
||||
variable "public_subnet_cidrs" {
|
||||
description = "CIDR blocks for public subnets (ALB). One per AZ."
|
||||
type = list(string)
|
||||
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
|
||||
}
|
||||
|
||||
variable "private_subnet_cidrs" {
|
||||
description = "CIDR blocks for private subnets (ECS, RDS, Redis). One per AZ."
|
||||
type = list(string)
|
||||
default = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets — all marked sensitive; provide via tfvars or environment variables
|
||||
################################################################################
|
||||
|
||||
variable "db_password" {
|
||||
description = "Master password for the RDS PostgreSQL instance. Stored in AWS Secrets Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "redis_auth_token" {
|
||||
description = "AUTH token for ElastiCache Redis (minimum 16 characters). Stored in AWS Secrets Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "jwt_private_key" {
|
||||
description = "PEM-encoded RSA-2048 private key for signing JWTs. Stored in AWS Secrets Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "jwt_public_key" {
|
||||
description = "PEM-encoded RSA-2048 public key for verifying JWTs. Stored in AWS Secrets Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "vault_token" {
|
||||
description = "HashiCorp Vault token. Leave empty to disable Vault integration."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Optional configuration
|
||||
################################################################################
|
||||
|
||||
variable "vault_addr" {
|
||||
description = "HashiCorp Vault server address. Leave empty to disable Vault integration."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "vault_mount" {
|
||||
description = "HashiCorp Vault KV v2 mount path."
|
||||
type = string
|
||||
default = "secret"
|
||||
}
|
||||
|
||||
variable "cors_origin" {
|
||||
description = "CORS_ORIGIN value for the app (use * for public APIs or a specific origin)."
|
||||
type = string
|
||||
default = "*"
|
||||
}
|
||||
|
||||
variable "ecs_desired_count" {
|
||||
description = "Number of ECS Fargate tasks to run."
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "rds_instance_class" {
|
||||
description = "RDS instance class."
|
||||
type = string
|
||||
default = "db.t3.medium"
|
||||
}
|
||||
|
||||
variable "redis_node_type" {
|
||||
description = "ElastiCache node type."
|
||||
type = string
|
||||
default = "cache.t3.medium"
|
||||
}
|
||||
|
||||
variable "alb_access_logs_bucket" {
|
||||
description = "S3 bucket for ALB access logs. Leave empty to disable."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "rds_backup_retention_days" {
|
||||
description = "Number of days to retain RDS automated backups."
|
||||
type = number
|
||||
default = 7
|
||||
}
|
||||
|
||||
variable "rds_deletion_protection" {
|
||||
description = "Enable RDS deletion protection."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "rds_skip_final_snapshot" {
|
||||
description = "Skip final RDS snapshot on destroy. Keep false in production."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
477
terraform/environments/gcp/main.tf
Normal file
477
terraform/environments/gcp/main.tf
Normal file
@@ -0,0 +1,477 @@
|
||||
################################################################################
|
||||
# Environment: gcp
|
||||
# Main — SentryAgent.ai AgentIdP on Google Cloud Platform
|
||||
#
|
||||
# Architecture:
|
||||
# Internet → Cloud Run (Google-managed TLS, auto-scaling) →
|
||||
# Cloud SQL PostgreSQL 14 (private IP, REGIONAL HA) +
|
||||
# Memorystore Redis 7 (STANDARD_HA, in-transit encryption)
|
||||
# via Serverless VPC Access connector
|
||||
#
|
||||
# All secrets stored in GCP Secret Manager — Cloud Run reads them at startup.
|
||||
# No sensitive values in state (except where Terraform internals require it).
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = ">= 5.20.0"
|
||||
}
|
||||
google-beta = {
|
||||
source = "hashicorp/google-beta"
|
||||
version = ">= 5.20.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Remote state — configure your backend here.
|
||||
# Example using GCS:
|
||||
#
|
||||
# backend "gcs" {
|
||||
# bucket = "sentryagent-terraform-state"
|
||||
# prefix = "agentidp/gcp/production"
|
||||
# }
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
}
|
||||
|
||||
provider "google-beta" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Enable required GCP APIs
|
||||
################################################################################
|
||||
|
||||
resource "google_project_service" "apis" {
|
||||
for_each = toset([
|
||||
"run.googleapis.com",
|
||||
"sqladmin.googleapis.com",
|
||||
"redis.googleapis.com",
|
||||
"vpcaccess.googleapis.com",
|
||||
"secretmanager.googleapis.com",
|
||||
"servicenetworking.googleapis.com",
|
||||
"cloudresourcemanager.googleapis.com",
|
||||
"iam.googleapis.com",
|
||||
])
|
||||
|
||||
project = var.project_id
|
||||
service = each.value
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Locals
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
name_prefix = "${var.project}-${var.environment}"
|
||||
|
||||
common_labels = {
|
||||
environment = var.environment
|
||||
project = replace(var.project, "-", "_")
|
||||
managed_by = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# VPC Network
|
||||
################################################################################
|
||||
|
||||
resource "google_compute_network" "main" {
|
||||
name = "${local.name_prefix}-vpc"
|
||||
auto_create_subnetworks = false
|
||||
project = var.project_id
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
resource "google_compute_subnetwork" "private" {
|
||||
name = "${local.name_prefix}-private-subnet"
|
||||
ip_cidr_range = var.vpc_cidr
|
||||
region = var.region
|
||||
network = google_compute_network.main.id
|
||||
project = var.project_id
|
||||
|
||||
private_ip_google_access = true
|
||||
|
||||
log_config {
|
||||
aggregation_interval = "INTERVAL_10_MIN"
|
||||
flow_sampling = 0.5
|
||||
metadata = "INCLUDE_ALL_METADATA"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Private Services Access — required for Cloud SQL private IP
|
||||
################################################################################
|
||||
|
||||
resource "google_compute_global_address" "private_services" {
|
||||
name = "${local.name_prefix}-private-services-range"
|
||||
purpose = "VPC_PEERING"
|
||||
address_type = "INTERNAL"
|
||||
prefix_length = 20
|
||||
network = google_compute_network.main.id
|
||||
project = var.project_id
|
||||
}
|
||||
|
||||
resource "google_service_networking_connection" "private_services" {
|
||||
network = google_compute_network.main.id
|
||||
service = "servicenetworking.googleapis.com"
|
||||
reserved_peering_ranges = [google_compute_global_address.private_services.name]
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Serverless VPC Access Connector
|
||||
# Cloud Run uses this to reach Cloud SQL (private IP) and Memorystore.
|
||||
################################################################################
|
||||
|
||||
resource "google_vpc_access_connector" "main" {
|
||||
name = "${local.name_prefix}-connector"
|
||||
region = var.region
|
||||
project = var.project_id
|
||||
ip_cidr_range = var.vpc_connector_cidr
|
||||
network = google_compute_network.main.name
|
||||
min_instances = 2
|
||||
max_instances = 10
|
||||
machine_type = "e2-micro"
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Service Account for Cloud Run
|
||||
################################################################################
|
||||
|
||||
resource "google_service_account" "cloud_run" {
|
||||
account_id = "${var.project}-${var.environment}-run-sa"
|
||||
display_name = "AgentIdP Cloud Run Service Account (${var.environment})"
|
||||
project = var.project_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secret Manager — create secrets and grant the SA access
|
||||
################################################################################
|
||||
|
||||
resource "google_secret_manager_secret" "database_url" {
|
||||
secret_id = "${local.name_prefix}-database-url"
|
||||
project = var.project_id
|
||||
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
|
||||
labels = local.common_labels
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "database_url" {
|
||||
secret = google_secret_manager_secret.database_url.id
|
||||
# Build the DATABASE_URL from Cloud SQL private IP output.
|
||||
secret_data = "postgresql://${var.db_username}:${var.db_password}@${google_sql_database_instance.main.private_ip_address}:5432/${var.db_name}?sslmode=require"
|
||||
|
||||
depends_on = [google_sql_database_instance.main]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "redis_url" {
|
||||
secret_id = "${local.name_prefix}-redis-url"
|
||||
project = var.project_id
|
||||
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
|
||||
labels = local.common_labels
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "redis_url" {
|
||||
secret = google_secret_manager_secret.redis_url.id
|
||||
# Memorystore Redis with in-transit encryption uses the rediss:// scheme.
|
||||
secret_data = "rediss://${google_redis_instance.main.host}:${google_redis_instance.main.port}"
|
||||
|
||||
depends_on = [google_redis_instance.main]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "jwt_private_key" {
|
||||
secret_id = "${local.name_prefix}-jwt-private-key"
|
||||
project = var.project_id
|
||||
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
|
||||
labels = local.common_labels
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "jwt_private_key" {
|
||||
secret = google_secret_manager_secret.jwt_private_key.id
|
||||
secret_data = var.jwt_private_key
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "jwt_public_key" {
|
||||
secret_id = "${local.name_prefix}-jwt-public-key"
|
||||
project = var.project_id
|
||||
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
|
||||
labels = local.common_labels
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "jwt_public_key" {
|
||||
secret = google_secret_manager_secret.jwt_public_key.id
|
||||
secret_data = var.jwt_public_key
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "vault_token" {
|
||||
count = var.vault_token != "" ? 1 : 0
|
||||
|
||||
secret_id = "${local.name_prefix}-vault-token"
|
||||
project = var.project_id
|
||||
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
|
||||
labels = local.common_labels
|
||||
|
||||
depends_on = [google_project_service.apis]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "vault_token" {
|
||||
count = var.vault_token != "" ? 1 : 0
|
||||
|
||||
secret = google_secret_manager_secret.vault_token[0].id
|
||||
secret_data = var.vault_token
|
||||
}
|
||||
|
||||
# Grant the Cloud Run SA access to each secret
|
||||
resource "google_secret_manager_secret_iam_member" "run_database_url" {
|
||||
project = var.project_id
|
||||
secret_id = google_secret_manager_secret.database_url.secret_id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloud_run.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "run_redis_url" {
|
||||
project = var.project_id
|
||||
secret_id = google_secret_manager_secret.redis_url.secret_id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloud_run.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "run_jwt_private_key" {
|
||||
project = var.project_id
|
||||
secret_id = google_secret_manager_secret.jwt_private_key.secret_id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloud_run.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "run_jwt_public_key" {
|
||||
project = var.project_id
|
||||
secret_id = google_secret_manager_secret.jwt_public_key.secret_id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloud_run.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "run_vault_token" {
|
||||
count = var.vault_token != "" ? 1 : 0
|
||||
|
||||
project = var.project_id
|
||||
secret_id = google_secret_manager_secret.vault_token[0].secret_id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloud_run.email}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Cloud SQL — PostgreSQL 14, private IP, REGIONAL HA
|
||||
################################################################################
|
||||
|
||||
resource "google_sql_database_instance" "main" {
|
||||
name = "${local.name_prefix}-pg14"
|
||||
database_version = "POSTGRES_14"
|
||||
region = var.region
|
||||
project = var.project_id
|
||||
|
||||
deletion_protection = var.deletion_protection
|
||||
|
||||
settings {
|
||||
tier = var.db_tier
|
||||
availability_type = var.db_availability_type
|
||||
disk_type = "PD_SSD"
|
||||
disk_size = 50
|
||||
disk_autoresize = true
|
||||
|
||||
ip_configuration {
|
||||
ipv4_enabled = false # No public IP
|
||||
private_network = google_compute_network.main.id
|
||||
require_ssl = true
|
||||
}
|
||||
|
||||
backup_configuration {
|
||||
enabled = true
|
||||
start_time = "03:00"
|
||||
point_in_time_recovery_enabled = true
|
||||
transaction_log_retention_days = 7
|
||||
backup_retention_settings {
|
||||
retained_backups = 7
|
||||
retention_unit = "COUNT"
|
||||
}
|
||||
}
|
||||
|
||||
maintenance_window {
|
||||
day = 7 # Sunday
|
||||
hour = 5
|
||||
update_track = "stable"
|
||||
}
|
||||
|
||||
insights_config {
|
||||
query_insights_enabled = true
|
||||
query_string_length = 1024
|
||||
record_application_tags = true
|
||||
record_client_address = false
|
||||
}
|
||||
|
||||
database_flags {
|
||||
name = "log_connections"
|
||||
value = "on"
|
||||
}
|
||||
|
||||
database_flags {
|
||||
name = "log_disconnections"
|
||||
value = "on"
|
||||
}
|
||||
|
||||
database_flags {
|
||||
name = "log_min_duration_statement"
|
||||
value = "1000"
|
||||
}
|
||||
|
||||
user_labels = local.common_labels
|
||||
}
|
||||
|
||||
depends_on = [google_service_networking_connection.private_services]
|
||||
}
|
||||
|
||||
resource "google_sql_database" "main" {
|
||||
name = var.db_name
|
||||
instance = google_sql_database_instance.main.name
|
||||
project = var.project_id
|
||||
}
|
||||
|
||||
resource "google_sql_user" "app" {
|
||||
name = var.db_username
|
||||
instance = google_sql_database_instance.main.name
|
||||
password = var.db_password
|
||||
project = var.project_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Memorystore Redis 7 — STANDARD_HA (primary + replica), TLS enabled
|
||||
################################################################################
|
||||
|
||||
resource "google_redis_instance" "main" {
|
||||
name = "${local.name_prefix}-redis"
|
||||
tier = var.memorystore_tier
|
||||
memory_size_gb = var.memorystore_memory_size_gb
|
||||
region = var.region
|
||||
project = var.project_id
|
||||
|
||||
redis_version = var.memorystore_redis_version
|
||||
|
||||
# Private connectivity via the VPC
|
||||
authorized_network = google_compute_network.main.id
|
||||
connect_mode = "PRIVATE_SERVICE_ACCESS"
|
||||
|
||||
# TLS in transit
|
||||
transit_encryption_mode = "SERVER_AUTHENTICATION"
|
||||
|
||||
# No AUTH token for Memorystore — access is controlled by VPC network policy.
|
||||
# If AUTH is required, set auth_enabled = true and read the generated auth_string output.
|
||||
auth_enabled = true
|
||||
|
||||
redis_configs = {
|
||||
lazyfree-lazy-eviction = "yes"
|
||||
lazyfree-lazy-expire = "yes"
|
||||
}
|
||||
|
||||
maintenance_policy {
|
||||
weekly_maintenance_window {
|
||||
day = "SUNDAY"
|
||||
start_time {
|
||||
hours = 6
|
||||
minutes = 0
|
||||
seconds = 0
|
||||
nanos = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labels = local.common_labels
|
||||
|
||||
depends_on = [google_service_networking_connection.private_services]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Module: AgentIdP (Cloud Run)
|
||||
################################################################################
|
||||
|
||||
module "agentidp" {
|
||||
source = "../../modules/agentidp"
|
||||
|
||||
provider_type = "gcp"
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
app_image = "sentryagent/agentidp:${var.app_image_tag}"
|
||||
app_port = 3000
|
||||
|
||||
gcp_project_id = var.project_id
|
||||
gcp_region = var.region
|
||||
gcp_service_account_email = google_service_account.cloud_run.email
|
||||
gcp_vpc_connector_name = google_vpc_access_connector.main.id
|
||||
gcp_min_instances = var.cloud_run_min_instances
|
||||
gcp_max_instances = var.cloud_run_max_instances
|
||||
gcp_cpu = var.cloud_run_cpu
|
||||
gcp_memory = var.cloud_run_memory
|
||||
gcp_cors_origin = var.cors_origin
|
||||
gcp_policy_dir = "/app/policies"
|
||||
gcp_vault_addr = var.vault_addr
|
||||
gcp_vault_mount = var.vault_mount
|
||||
|
||||
gcp_secret_database_url_id = google_secret_manager_secret.database_url.secret_id
|
||||
gcp_secret_redis_url_id = google_secret_manager_secret.redis_url.secret_id
|
||||
gcp_secret_jwt_private_key_id = google_secret_manager_secret.jwt_private_key.secret_id
|
||||
gcp_secret_jwt_public_key_id = google_secret_manager_secret.jwt_public_key.secret_id
|
||||
gcp_secret_vault_token_id = var.vault_token != "" ? google_secret_manager_secret.vault_token[0].secret_id : ""
|
||||
|
||||
depends_on = [
|
||||
google_secret_manager_secret_version.database_url,
|
||||
google_secret_manager_secret_version.redis_url,
|
||||
google_secret_manager_secret_version.jwt_private_key,
|
||||
google_secret_manager_secret_version.jwt_public_key,
|
||||
google_secret_manager_secret_iam_member.run_database_url,
|
||||
google_secret_manager_secret_iam_member.run_redis_url,
|
||||
google_secret_manager_secret_iam_member.run_jwt_private_key,
|
||||
google_secret_manager_secret_iam_member.run_jwt_public_key,
|
||||
]
|
||||
}
|
||||
64
terraform/environments/gcp/outputs.tf
Normal file
64
terraform/environments/gcp/outputs.tf
Normal file
@@ -0,0 +1,64 @@
|
||||
################################################################################
|
||||
# Environment: gcp
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "service_url" {
|
||||
description = "Public HTTPS URL of the AgentIdP Cloud Run service (Google-managed TLS)."
|
||||
value = module.agentidp.gcp_cloud_run_service_url
|
||||
}
|
||||
|
||||
output "cloud_run_service_name" {
|
||||
description = "Name of the Cloud Run service."
|
||||
value = module.agentidp.gcp_cloud_run_service_name
|
||||
}
|
||||
|
||||
output "cloud_run_service_id" {
|
||||
description = "Full resource ID of the Cloud Run service."
|
||||
value = module.agentidp.gcp_cloud_run_service_id
|
||||
}
|
||||
|
||||
output "cloud_sql_instance_name" {
|
||||
description = "Cloud SQL instance name."
|
||||
value = google_sql_database_instance.main.name
|
||||
}
|
||||
|
||||
output "cloud_sql_private_ip" {
|
||||
description = "Private IP address of the Cloud SQL instance."
|
||||
value = google_sql_database_instance.main.private_ip_address
|
||||
}
|
||||
|
||||
output "cloud_sql_connection_name" {
|
||||
description = "Cloud SQL instance connection name (project:region:name) for Cloud SQL Proxy."
|
||||
value = google_sql_database_instance.main.connection_name
|
||||
}
|
||||
|
||||
output "memorystore_host" {
|
||||
description = "IP address of the Memorystore Redis primary endpoint."
|
||||
value = google_redis_instance.main.host
|
||||
}
|
||||
|
||||
output "memorystore_port" {
|
||||
description = "Port of the Memorystore Redis instance."
|
||||
value = google_redis_instance.main.port
|
||||
}
|
||||
|
||||
output "memorystore_id" {
|
||||
description = "Fully-qualified resource ID of the Memorystore Redis instance."
|
||||
value = google_redis_instance.main.id
|
||||
}
|
||||
|
||||
output "vpc_network_name" {
|
||||
description = "Name of the VPC network created for this deployment."
|
||||
value = google_compute_network.main.name
|
||||
}
|
||||
|
||||
output "vpc_connector_name" {
|
||||
description = "Serverless VPC Access connector name used by Cloud Run."
|
||||
value = google_vpc_access_connector.main.name
|
||||
}
|
||||
|
||||
output "cloud_run_service_account_email" {
|
||||
description = "Email of the service account attached to the Cloud Run service."
|
||||
value = google_service_account.cloud_run.email
|
||||
}
|
||||
70
terraform/environments/gcp/terraform.tfvars.example
Normal file
70
terraform/environments/gcp/terraform.tfvars.example
Normal file
@@ -0,0 +1,70 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# terraform/environments/gcp/terraform.tfvars.example
|
||||
#
|
||||
# Copy this file to terraform.tfvars and fill in real values.
|
||||
# NEVER commit terraform.tfvars to version control — it contains secrets.
|
||||
#
|
||||
# All sensitive variables (db_password, jwt_*, vault_token) must be provided
|
||||
# via this file or as TF_VAR_* environment variables in your CI/CD pipeline.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── GCP project & region ──────────────────────────────────────────────────────
|
||||
|
||||
project_id = "your-gcp-project-id"
|
||||
region = "us-central1"
|
||||
environment = "production"
|
||||
project = "sentryagent-agentidp"
|
||||
|
||||
# ── Application image ─────────────────────────────────────────────────────────
|
||||
|
||||
app_image_tag = "1.0.0"
|
||||
|
||||
# ── Networking ────────────────────────────────────────────────────────────────
|
||||
|
||||
vpc_cidr = "10.1.0.0/24"
|
||||
vpc_connector_cidr = "10.8.0.0/28"
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
db_tier = "db-g1-small"
|
||||
db_name = "sentryagent_idp"
|
||||
db_username = "sentryagent"
|
||||
db_availability_type = "REGIONAL"
|
||||
|
||||
# ── Secrets — REPLACE ALL VALUES BELOW ───────────────────────────────────────
|
||||
|
||||
# Password for Cloud SQL PostgreSQL user
|
||||
db_password = "REPLACE_WITH_STRONG_RANDOM_PASSWORD"
|
||||
|
||||
# RSA-2048 key pair for JWT signing/verification.
|
||||
# Generate with:
|
||||
# openssl genrsa -out private.pem 2048
|
||||
# openssl rsa -in private.pem -pubout -out public.pem
|
||||
jwt_private_key = "-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_ACTUAL_PRIVATE_KEY_CONTENTS\n-----END RSA PRIVATE KEY-----"
|
||||
jwt_public_key = "-----BEGIN PUBLIC KEY-----\nREPLACE_WITH_ACTUAL_PUBLIC_KEY_CONTENTS\n-----END PUBLIC KEY-----"
|
||||
|
||||
# HashiCorp Vault (optional — leave empty strings to disable Vault integration)
|
||||
vault_addr = ""
|
||||
vault_token = ""
|
||||
vault_mount = "secret"
|
||||
|
||||
# ── Application configuration ─────────────────────────────────────────────────
|
||||
|
||||
cors_origin = "*"
|
||||
|
||||
# ── Cloud Run scaling ─────────────────────────────────────────────────────────
|
||||
|
||||
cloud_run_min_instances = 1
|
||||
cloud_run_max_instances = 10
|
||||
cloud_run_cpu = "1"
|
||||
cloud_run_memory = "512Mi"
|
||||
|
||||
# ── Memorystore Redis ─────────────────────────────────────────────────────────
|
||||
|
||||
memorystore_memory_size_gb = 1
|
||||
memorystore_redis_version = "REDIS_7_0"
|
||||
memorystore_tier = "STANDARD_HA"
|
||||
|
||||
# ── Protection ────────────────────────────────────────────────────────────────
|
||||
|
||||
deletion_protection = true
|
||||
175
terraform/environments/gcp/variables.tf
Normal file
175
terraform/environments/gcp/variables.tf
Normal file
@@ -0,0 +1,175 @@
|
||||
################################################################################
|
||||
# Environment: gcp
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "project_id" {
|
||||
description = "GCP project ID where all resources will be created."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "GCP region for all resources."
|
||||
type = string
|
||||
default = "us-central1"
|
||||
}
|
||||
|
||||
variable "environment" {
|
||||
description = "Deployment environment (e.g. production, staging)."
|
||||
type = string
|
||||
default = "production"
|
||||
}
|
||||
|
||||
variable "project" {
|
||||
description = "Project identifier — used in resource names and labels."
|
||||
type = string
|
||||
default = "sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "app_image_tag" {
|
||||
description = "Docker image tag to deploy (e.g. '1.2.3')."
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Networking
|
||||
################################################################################
|
||||
|
||||
variable "vpc_cidr" {
|
||||
description = "CIDR range for the VPC subnet used by Cloud Run and Cloud SQL."
|
||||
type = string
|
||||
default = "10.1.0.0/24"
|
||||
}
|
||||
|
||||
variable "vpc_connector_cidr" {
|
||||
description = "CIDR range for the Serverless VPC Access connector (/28 required)."
|
||||
type = string
|
||||
default = "10.8.0.0/28"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Database
|
||||
################################################################################
|
||||
|
||||
variable "db_tier" {
|
||||
description = "Cloud SQL instance tier (machine type)."
|
||||
type = string
|
||||
default = "db-g1-small"
|
||||
}
|
||||
|
||||
variable "db_name" {
|
||||
description = "Name of the PostgreSQL database to create."
|
||||
type = string
|
||||
default = "sentryagent_idp"
|
||||
}
|
||||
|
||||
variable "db_username" {
|
||||
description = "PostgreSQL user for the application."
|
||||
type = string
|
||||
default = "sentryagent"
|
||||
}
|
||||
|
||||
variable "db_availability_type" {
|
||||
description = "Cloud SQL availability type: REGIONAL (HA) or ZONAL."
|
||||
type = string
|
||||
default = "REGIONAL"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets — all marked sensitive; provide via tfvars or environment variables
|
||||
################################################################################
|
||||
|
||||
variable "db_password" {
|
||||
description = "Password for the Cloud SQL PostgreSQL user. Stored in Secret Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "jwt_private_key" {
|
||||
description = "PEM-encoded RSA-2048 private key for signing JWTs. Stored in Secret Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "jwt_public_key" {
|
||||
description = "PEM-encoded RSA-2048 public key for verifying JWTs. Stored in Secret Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "vault_token" {
|
||||
description = "HashiCorp Vault token. Leave empty to disable Vault integration."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Optional configuration
|
||||
################################################################################
|
||||
|
||||
variable "vault_addr" {
|
||||
description = "HashiCorp Vault server address. Leave empty to disable Vault integration."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "vault_mount" {
|
||||
description = "HashiCorp Vault KV v2 mount path."
|
||||
type = string
|
||||
default = "secret"
|
||||
}
|
||||
|
||||
variable "cors_origin" {
|
||||
description = "CORS_ORIGIN value for the app."
|
||||
type = string
|
||||
default = "*"
|
||||
}
|
||||
|
||||
variable "cloud_run_min_instances" {
|
||||
description = "Minimum Cloud Run instances (set > 0 to prevent cold starts)."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "cloud_run_max_instances" {
|
||||
description = "Maximum Cloud Run instances."
|
||||
type = number
|
||||
default = 10
|
||||
}
|
||||
|
||||
variable "cloud_run_cpu" {
|
||||
description = "CPU limit per Cloud Run instance."
|
||||
type = string
|
||||
default = "1"
|
||||
}
|
||||
|
||||
variable "cloud_run_memory" {
|
||||
description = "Memory limit per Cloud Run instance."
|
||||
type = string
|
||||
default = "512Mi"
|
||||
}
|
||||
|
||||
variable "memorystore_memory_size_gb" {
|
||||
description = "Memory size in GiB for the Memorystore Redis instance."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "memorystore_redis_version" {
|
||||
description = "Redis version for Memorystore."
|
||||
type = string
|
||||
default = "REDIS_7_0"
|
||||
}
|
||||
|
||||
variable "memorystore_tier" {
|
||||
description = "Memorystore service tier: BASIC (single node) or STANDARD_HA (primary + replica)."
|
||||
type = string
|
||||
default = "STANDARD_HA"
|
||||
}
|
||||
|
||||
variable "deletion_protection" {
|
||||
description = "Enable deletion protection on Cloud SQL and Memorystore resources."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
426
terraform/modules/agentidp/main.tf
Normal file
426
terraform/modules/agentidp/main.tf
Normal file
@@ -0,0 +1,426 @@
|
||||
################################################################################
|
||||
# Module: agentidp
|
||||
# Main — ECS Fargate (AWS) or Cloud Run (GCP)
|
||||
#
|
||||
# Deploys the sentryagent/agentidp container.
|
||||
# All sensitive environment variables are injected from AWS Secrets Manager
|
||||
# (AWS path) or GCP Secret Manager (GCP path) — no plaintext secrets here.
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40.0"
|
||||
}
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = ">= 5.20.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Locals
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
common_tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
managed_by = "terraform"
|
||||
}
|
||||
|
||||
# Build the list of Vault-related env vars conditionally.
|
||||
# If vault_addr is empty we omit all Vault env vars entirely.
|
||||
aws_vault_env_plain = var.aws_vault_addr != "" ? [
|
||||
{
|
||||
name = "VAULT_ADDR"
|
||||
value = var.aws_vault_addr
|
||||
},
|
||||
{
|
||||
name = "VAULT_MOUNT"
|
||||
value = var.aws_vault_mount
|
||||
}
|
||||
] : []
|
||||
|
||||
aws_vault_secret_env = var.aws_secret_vault_token_arn != "" ? [
|
||||
{
|
||||
name = "VAULT_TOKEN"
|
||||
valueFrom = var.aws_secret_vault_token_arn
|
||||
}
|
||||
] : []
|
||||
|
||||
gcp_vault_env_plain = var.gcp_vault_addr != "" ? {
|
||||
VAULT_ADDR = var.gcp_vault_addr
|
||||
VAULT_MOUNT = var.gcp_vault_mount
|
||||
} : {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ── AWS PATH ──────────────────────────────────────────────────────────────────
|
||||
################################################################################
|
||||
|
||||
# Security group: allow inbound traffic only from the ALB on app_port,
|
||||
# allow all outbound (needed for Secrets Manager and ECR API calls over HTTPS).
|
||||
resource "aws_security_group" "app" {
|
||||
count = var.provider_type == "aws" ? 1 : 0
|
||||
|
||||
name = "${var.project}-${var.environment}-app-sg"
|
||||
description = "Security group for AgentIdP ECS tasks — inbound from ALB only"
|
||||
vpc_id = var.aws_vpc_id
|
||||
|
||||
ingress {
|
||||
description = "App port from ALB"
|
||||
from_port = var.app_port
|
||||
to_port = var.app_port
|
||||
protocol = "tcp"
|
||||
# The ALB security group ID is not directly available here; in the root
|
||||
# environment module the ALB SG and this SG are cross-referenced.
|
||||
# The environment module passes the ALB SG id via aws_lb_security_group_id
|
||||
# below using a separate ingress rule resource to avoid circular dependency.
|
||||
cidr_blocks = []
|
||||
self = false
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${var.project}-${var.environment}-app-sg"
|
||||
})
|
||||
}
|
||||
|
||||
# ECS Cluster
|
||||
resource "aws_ecs_cluster" "main" {
|
||||
count = var.provider_type == "aws" ? 1 : 0
|
||||
|
||||
name = "${var.project}-${var.environment}"
|
||||
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
# ECS Cluster Capacity Providers — use FARGATE and FARGATE_SPOT
|
||||
resource "aws_ecs_cluster_capacity_providers" "main" {
|
||||
count = var.provider_type == "aws" ? 1 : 0
|
||||
|
||||
cluster_name = aws_ecs_cluster.main[0].name
|
||||
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
|
||||
|
||||
default_capacity_provider_strategy {
|
||||
capacity_provider = "FARGATE"
|
||||
weight = 1
|
||||
base = 1
|
||||
}
|
||||
}
|
||||
|
||||
# CloudWatch Log Group
|
||||
resource "aws_cloudwatch_log_group" "app" {
|
||||
count = var.provider_type == "aws" ? 1 : 0
|
||||
|
||||
name = var.aws_log_group_name
|
||||
retention_in_days = 30
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
# ECS Task Definition
|
||||
resource "aws_ecs_task_definition" "app" {
|
||||
count = var.provider_type == "aws" ? 1 : 0
|
||||
|
||||
family = "${var.project}-${var.environment}"
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
cpu = tostring(var.aws_cpu)
|
||||
memory = tostring(var.aws_memory)
|
||||
execution_role_arn = var.aws_execution_role_arn
|
||||
task_role_arn = var.aws_task_role_arn
|
||||
|
||||
container_definitions = jsonencode([
|
||||
{
|
||||
name = "agentidp"
|
||||
image = var.app_image
|
||||
essential = true
|
||||
|
||||
portMappings = [
|
||||
{
|
||||
containerPort = var.app_port
|
||||
protocol = "tcp"
|
||||
}
|
||||
]
|
||||
|
||||
# Plain (non-sensitive) environment variables
|
||||
environment = concat(
|
||||
[
|
||||
{ name = "PORT", value = tostring(var.app_port) },
|
||||
{ name = "NODE_ENV", value = "production" },
|
||||
{ name = "CORS_ORIGIN", value = var.aws_cors_origin },
|
||||
{ name = "POLICY_DIR", value = var.aws_policy_dir }
|
||||
],
|
||||
local.aws_vault_env_plain
|
||||
)
|
||||
|
||||
# Sensitive values fetched from Secrets Manager at task launch.
|
||||
# Each entry is injected as the named environment variable.
|
||||
secrets = concat(
|
||||
[
|
||||
{
|
||||
name = "DATABASE_URL"
|
||||
valueFrom = var.aws_secret_database_url_arn
|
||||
},
|
||||
{
|
||||
name = "REDIS_URL"
|
||||
valueFrom = var.aws_secret_redis_url_arn
|
||||
},
|
||||
{
|
||||
name = "JWT_PRIVATE_KEY"
|
||||
valueFrom = var.aws_secret_jwt_private_key_arn
|
||||
},
|
||||
{
|
||||
name = "JWT_PUBLIC_KEY"
|
||||
valueFrom = var.aws_secret_jwt_public_key_arn
|
||||
}
|
||||
],
|
||||
local.aws_vault_secret_env
|
||||
)
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
"awslogs-group" = var.aws_log_group_name
|
||||
"awslogs-region" = var.aws_region
|
||||
"awslogs-stream-prefix" = "agentidp"
|
||||
}
|
||||
}
|
||||
|
||||
healthCheck = {
|
||||
command = ["CMD-SHELL", "wget -qO- http://localhost:${var.app_port}/health || exit 1"]
|
||||
interval = 30
|
||||
timeout = 5
|
||||
retries = 3
|
||||
startPeriod = 60
|
||||
}
|
||||
|
||||
readonlyRootFilesystem = false
|
||||
user = "node"
|
||||
}
|
||||
])
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
# ECS Service
|
||||
resource "aws_ecs_service" "app" {
|
||||
count = var.provider_type == "aws" ? 1 : 0
|
||||
|
||||
name = "${var.project}-${var.environment}"
|
||||
cluster = aws_ecs_cluster.main[0].id
|
||||
task_definition = aws_ecs_task_definition.app[0].arn
|
||||
desired_count = var.aws_desired_count
|
||||
launch_type = "FARGATE"
|
||||
|
||||
# Rolling update: keep at least 100% healthy tasks during deployment
|
||||
deployment_minimum_healthy_percent = 100
|
||||
deployment_maximum_percent = 200
|
||||
|
||||
network_configuration {
|
||||
subnets = var.aws_subnet_ids
|
||||
security_groups = [aws_security_group.app[0].id]
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = var.aws_target_group_arn
|
||||
container_name = "agentidp"
|
||||
container_port = var.app_port
|
||||
}
|
||||
|
||||
# Ignore task_definition changes driven by image tag updates — deployments
|
||||
# are managed externally (CI/CD pipeline updates the image tag).
|
||||
lifecycle {
|
||||
ignore_changes = [task_definition, desired_count]
|
||||
}
|
||||
|
||||
tags = local.common_tags
|
||||
|
||||
depends_on = [aws_ecs_cluster_capacity_providers.main]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ── GCP PATH ──────────────────────────────────────────────────────────────────
|
||||
################################################################################
|
||||
|
||||
# Cloud Run Service
|
||||
resource "google_cloud_run_v2_service" "app" {
|
||||
count = var.provider_type == "gcp" ? 1 : 0
|
||||
|
||||
name = "${var.project}-${var.environment}"
|
||||
location = var.gcp_region
|
||||
project = var.gcp_project_id
|
||||
|
||||
# Ingress: allow only requests from the load balancer / public internet.
|
||||
# Cloud Run provides Google-managed TLS on the default *.run.app domain
|
||||
# and on any custom domains mapped via Cloud Run domain mappings.
|
||||
ingress = "INGRESS_TRAFFIC_ALL"
|
||||
|
||||
template {
|
||||
service_account = var.gcp_service_account_email
|
||||
|
||||
scaling {
|
||||
min_instance_count = var.gcp_min_instances
|
||||
max_instance_count = var.gcp_max_instances
|
||||
}
|
||||
|
||||
# VPC access — route outbound traffic through the VPC connector so the
|
||||
# container can reach Cloud SQL (private IP) and Memorystore.
|
||||
vpc_access {
|
||||
connector = var.gcp_vpc_connector_name
|
||||
egress = "PRIVATE_RANGES_ONLY"
|
||||
}
|
||||
|
||||
containers {
|
||||
image = var.app_image
|
||||
|
||||
ports {
|
||||
container_port = var.app_port
|
||||
}
|
||||
|
||||
resources {
|
||||
limits = {
|
||||
cpu = var.gcp_cpu
|
||||
memory = var.gcp_memory
|
||||
}
|
||||
cpu_idle = false
|
||||
startup_cpu_boost = true
|
||||
}
|
||||
|
||||
# Plain environment variables
|
||||
dynamic "env" {
|
||||
for_each = merge(
|
||||
{
|
||||
PORT = tostring(var.app_port)
|
||||
NODE_ENV = "production"
|
||||
CORS_ORIGIN = var.gcp_cors_origin
|
||||
POLICY_DIR = var.gcp_policy_dir
|
||||
},
|
||||
local.gcp_vault_env_plain
|
||||
)
|
||||
content {
|
||||
name = env.key
|
||||
value = env.value
|
||||
}
|
||||
}
|
||||
|
||||
# DATABASE_URL from Secret Manager
|
||||
env {
|
||||
name = "DATABASE_URL"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = var.gcp_secret_database_url_id
|
||||
version = "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# REDIS_URL from Secret Manager
|
||||
env {
|
||||
name = "REDIS_URL"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = var.gcp_secret_redis_url_id
|
||||
version = "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# JWT_PRIVATE_KEY from Secret Manager
|
||||
env {
|
||||
name = "JWT_PRIVATE_KEY"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = var.gcp_secret_jwt_private_key_id
|
||||
version = "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# JWT_PUBLIC_KEY from Secret Manager
|
||||
env {
|
||||
name = "JWT_PUBLIC_KEY"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = var.gcp_secret_jwt_public_key_id
|
||||
version = "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# VAULT_TOKEN from Secret Manager (conditional)
|
||||
dynamic "env" {
|
||||
for_each = var.gcp_secret_vault_token_id != "" ? [1] : []
|
||||
content {
|
||||
name = "VAULT_TOKEN"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = var.gcp_secret_vault_token_id
|
||||
version = "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/health"
|
||||
port = var.app_port
|
||||
}
|
||||
initial_delay_seconds = 30
|
||||
period_seconds = 15
|
||||
failure_threshold = 3
|
||||
timeout_seconds = 5
|
||||
}
|
||||
|
||||
startup_probe {
|
||||
http_get {
|
||||
path = "/health"
|
||||
port = var.app_port
|
||||
}
|
||||
initial_delay_seconds = 10
|
||||
period_seconds = 5
|
||||
failure_threshold = 12
|
||||
timeout_seconds = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labels = {
|
||||
environment = var.environment
|
||||
project = replace(var.project, "-", "_")
|
||||
managed_by = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
# Allow unauthenticated (public internet) invocations of the Cloud Run service.
|
||||
# Authentication for AgentIdP clients is handled by the application layer
|
||||
# (JWT Bearer tokens), not by Cloud Run's built-in IAM auth.
|
||||
resource "google_cloud_run_v2_service_iam_member" "public_invoker" {
|
||||
count = var.provider_type == "gcp" ? 1 : 0
|
||||
|
||||
project = var.gcp_project_id
|
||||
location = var.gcp_region
|
||||
name = google_cloud_run_v2_service.app[0].name
|
||||
role = "roles/run.invoker"
|
||||
member = "allUsers"
|
||||
}
|
||||
55
terraform/modules/agentidp/outputs.tf
Normal file
55
terraform/modules/agentidp/outputs.tf
Normal file
@@ -0,0 +1,55 @@
|
||||
################################################################################
|
||||
# Module: agentidp
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
# ── AWS Outputs ──────────────────────────────────────────────────────────────
|
||||
|
||||
output "aws_ecs_cluster_arn" {
|
||||
description = "ARN of the ECS cluster hosting the AgentIdP service."
|
||||
value = var.provider_type == "aws" ? aws_ecs_cluster.main[0].arn : null
|
||||
}
|
||||
|
||||
output "aws_ecs_service_name" {
|
||||
description = "Name of the ECS Fargate service."
|
||||
value = var.provider_type == "aws" ? aws_ecs_service.app[0].name : null
|
||||
}
|
||||
|
||||
output "aws_ecs_task_definition_arn" {
|
||||
description = "ARN of the active ECS task definition revision."
|
||||
value = var.provider_type == "aws" ? aws_ecs_task_definition.app[0].arn : null
|
||||
}
|
||||
|
||||
output "aws_app_security_group_id" {
|
||||
description = "Security group ID attached to the ECS tasks. Use this to add ingress rules from the ALB."
|
||||
value = var.provider_type == "aws" ? aws_security_group.app[0].id : null
|
||||
}
|
||||
|
||||
output "aws_cloudwatch_log_group_name" {
|
||||
description = "CloudWatch log group name for ECS container logs."
|
||||
value = var.provider_type == "aws" ? aws_cloudwatch_log_group.app[0].name : null
|
||||
}
|
||||
|
||||
# ── GCP Outputs ──────────────────────────────────────────────────────────────
|
||||
|
||||
output "gcp_cloud_run_service_name" {
|
||||
description = "Name of the Cloud Run service."
|
||||
value = var.provider_type == "gcp" ? google_cloud_run_v2_service.app[0].name : null
|
||||
}
|
||||
|
||||
output "gcp_cloud_run_service_url" {
|
||||
description = "Publicly accessible HTTPS URL of the Cloud Run service (Google-managed TLS)."
|
||||
value = var.provider_type == "gcp" ? google_cloud_run_v2_service.app[0].uri : null
|
||||
}
|
||||
|
||||
output "gcp_cloud_run_service_id" {
|
||||
description = "Full resource ID of the Cloud Run service."
|
||||
value = var.provider_type == "gcp" ? google_cloud_run_v2_service.app[0].id : null
|
||||
}
|
||||
|
||||
# ── Unified Outputs ───────────────────────────────────────────────────────────
|
||||
|
||||
output "service_url" {
|
||||
description = "Publicly accessible service URL. Populated for GCP (Cloud Run native URL). For AWS use the ALB DNS name from the lb module."
|
||||
value = var.provider_type == "gcp" ? google_cloud_run_v2_service.app[0].uri : null
|
||||
}
|
||||
279
terraform/modules/agentidp/variables.tf
Normal file
279
terraform/modules/agentidp/variables.tf
Normal file
@@ -0,0 +1,279 @@
|
||||
################################################################################
|
||||
# Module: agentidp
|
||||
# Variables
|
||||
#
|
||||
# Accepts all configuration for deploying the AgentIdP container to either
|
||||
# AWS ECS Fargate (provider = "aws") or GCP Cloud Run (provider = "gcp").
|
||||
################################################################################
|
||||
|
||||
variable "provider_type" {
|
||||
description = "Cloud provider target: 'aws' or 'gcp'."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = contains(["aws", "gcp"], var.provider_type)
|
||||
error_message = "provider_type must be either 'aws' or 'gcp'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "environment" {
|
||||
description = "Deployment environment label (e.g. production, staging)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "project" {
|
||||
description = "Project identifier used in resource tags and names."
|
||||
type = string
|
||||
default = "sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "app_image" {
|
||||
description = "Fully-qualified container image reference including registry host and tag."
|
||||
type = string
|
||||
# Example: "sentryagent/agentidp:1.2.3"
|
||||
}
|
||||
|
||||
variable "app_port" {
|
||||
description = "Port the AgentIdP container listens on. Must match the PORT env var."
|
||||
type = number
|
||||
default = 3000
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# AWS-specific variables (required when provider_type = "aws")
|
||||
################################################################################
|
||||
|
||||
variable "aws_region" {
|
||||
description = "(AWS) AWS region where ECS resources are deployed."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "aws_vpc_id" {
|
||||
description = "(AWS) VPC ID in which to create the ECS service and security group."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "aws_subnet_ids" {
|
||||
description = "(AWS) List of private subnet IDs for the ECS Fargate tasks."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "aws_target_group_arn" {
|
||||
description = "(AWS) ARN of the ALB target group to register ECS tasks with."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "aws_execution_role_arn" {
|
||||
description = "(AWS) IAM role ARN that ECS uses to pull images and write logs (ECS task execution role)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "aws_task_role_arn" {
|
||||
description = "(AWS) IAM role ARN granted to the running ECS task (allows it to call Secrets Manager, etc.)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "aws_log_group_name" {
|
||||
description = "(AWS) CloudWatch log group name where container logs are sent."
|
||||
type = string
|
||||
default = "/ecs/sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "aws_desired_count" {
|
||||
description = "(AWS) Number of ECS Fargate task instances to run."
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "aws_cpu" {
|
||||
description = "(AWS) ECS task CPU units (256 = 0.25 vCPU)."
|
||||
type = number
|
||||
default = 512
|
||||
}
|
||||
|
||||
variable "aws_memory" {
|
||||
description = "(AWS) ECS task memory in MiB."
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
# Secret ARNs — the ECS task fetches these from Secrets Manager at launch time.
|
||||
# The task execution role must have secretsmanager:GetSecretValue on each ARN.
|
||||
|
||||
variable "aws_secret_database_url_arn" {
|
||||
description = "(AWS) ARN of the Secrets Manager secret holding DATABASE_URL."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aws_secret_redis_url_arn" {
|
||||
description = "(AWS) ARN of the Secrets Manager secret holding REDIS_URL."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aws_secret_jwt_private_key_arn" {
|
||||
description = "(AWS) ARN of the Secrets Manager secret holding JWT_PRIVATE_KEY."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aws_secret_jwt_public_key_arn" {
|
||||
description = "(AWS) ARN of the Secrets Manager secret holding JWT_PUBLIC_KEY."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aws_secret_vault_token_arn" {
|
||||
description = "(AWS) ARN of the Secrets Manager secret holding VAULT_TOKEN. Leave empty to omit Vault integration."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aws_vault_addr" {
|
||||
description = "(AWS) HashiCorp Vault address injected as a plain env var (not a secret). Leave empty to disable."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "aws_vault_mount" {
|
||||
description = "(AWS) HashiCorp Vault KV v2 mount path."
|
||||
type = string
|
||||
default = "secret"
|
||||
}
|
||||
|
||||
variable "aws_cors_origin" {
|
||||
description = "(AWS) Value for CORS_ORIGIN env var."
|
||||
type = string
|
||||
default = "*"
|
||||
}
|
||||
|
||||
variable "aws_policy_dir" {
|
||||
description = "(AWS) Path inside the container where OPA policy files are located."
|
||||
type = string
|
||||
default = "/app/policies"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# GCP-specific variables (required when provider_type = "gcp")
|
||||
################################################################################
|
||||
|
||||
variable "gcp_project_id" {
|
||||
description = "(GCP) GCP project ID where Cloud Run and supporting resources live."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gcp_region" {
|
||||
description = "(GCP) GCP region for Cloud Run deployment."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gcp_service_account_email" {
|
||||
description = "(GCP) Service account email attached to the Cloud Run service."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gcp_vpc_connector_name" {
|
||||
description = "(GCP) Serverless VPC Access connector name for reaching Cloud SQL and Memorystore."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gcp_min_instances" {
|
||||
description = "(GCP) Minimum number of Cloud Run instances (set > 0 to avoid cold starts)."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "gcp_max_instances" {
|
||||
description = "(GCP) Maximum number of Cloud Run instances."
|
||||
type = number
|
||||
default = 10
|
||||
}
|
||||
|
||||
variable "gcp_cpu" {
|
||||
description = "(GCP) CPU limit for each Cloud Run container instance (e.g. '1', '2')."
|
||||
type = string
|
||||
default = "1"
|
||||
}
|
||||
|
||||
variable "gcp_memory" {
|
||||
description = "(GCP) Memory limit for each Cloud Run container instance (e.g. '512Mi', '1Gi')."
|
||||
type = string
|
||||
default = "512Mi"
|
||||
}
|
||||
|
||||
# Secret Manager secret IDs — the Cloud Run service fetches these at startup.
|
||||
|
||||
variable "gcp_secret_database_url_id" {
|
||||
description = "(GCP) Secret Manager secret ID for DATABASE_URL."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "gcp_secret_redis_url_id" {
|
||||
description = "(GCP) Secret Manager secret ID for REDIS_URL."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "gcp_secret_jwt_private_key_id" {
|
||||
description = "(GCP) Secret Manager secret ID for JWT_PRIVATE_KEY."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "gcp_secret_jwt_public_key_id" {
|
||||
description = "(GCP) Secret Manager secret ID for JWT_PUBLIC_KEY."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "gcp_secret_vault_token_id" {
|
||||
description = "(GCP) Secret Manager secret ID for VAULT_TOKEN. Leave empty to omit Vault integration."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "gcp_vault_addr" {
|
||||
description = "(GCP) HashiCorp Vault address injected as a plain env var. Leave empty to disable."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gcp_vault_mount" {
|
||||
description = "(GCP) HashiCorp Vault KV v2 mount path."
|
||||
type = string
|
||||
default = "secret"
|
||||
}
|
||||
|
||||
variable "gcp_cors_origin" {
|
||||
description = "(GCP) Value for CORS_ORIGIN env var."
|
||||
type = string
|
||||
default = "*"
|
||||
}
|
||||
|
||||
variable "gcp_policy_dir" {
|
||||
description = "(GCP) Path inside the Cloud Run container where OPA policy files are located."
|
||||
type = string
|
||||
default = "/app/policies"
|
||||
}
|
||||
183
terraform/modules/lb/main.tf
Normal file
183
terraform/modules/lb/main.tf
Normal file
@@ -0,0 +1,183 @@
|
||||
################################################################################
|
||||
# Module: lb
|
||||
# Main — AWS Application Load Balancer
|
||||
#
|
||||
# - Internet-facing ALB in public subnets
|
||||
# - HTTPS listener (443) with ACM certificate, TLS 1.2+ enforced
|
||||
# - HTTP listener (80) redirects permanently to HTTPS — no plaintext traffic
|
||||
# - Target group pointing to ECS Fargate tasks on the app port
|
||||
# - Access logs optionally streamed to S3
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
identifier = "${var.project}-${var.environment}"
|
||||
|
||||
common_tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
managed_by = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group — ALB allows inbound 80 + 443 from the internet
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${local.identifier}-alb-sg"
|
||||
description = "ALB security group — inbound 80/443 from internet, outbound to app"
|
||||
vpc_id = var.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTP from internet (redirected to HTTPS)"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = var.allowed_ingress_cidrs
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTPS from internet"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = var.allowed_ingress_cidrs
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "Forward to ECS app tasks"
|
||||
from_port = var.target_group_port
|
||||
to_port = var.target_group_port
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${local.identifier}-alb-sg"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Application Load Balancer
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb" "main" {
|
||||
name = "${local.identifier}-alb"
|
||||
internal = false
|
||||
load_balancer_type = "application"
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
subnets = var.subnet_ids
|
||||
|
||||
idle_timeout = var.idle_timeout
|
||||
enable_deletion_protection = var.enable_deletion_protection
|
||||
|
||||
# HTTP/2 is enabled by default on ALB; leave it on for performance.
|
||||
enable_http2 = true
|
||||
|
||||
# Drop invalid header fields to harden against request smuggling.
|
||||
drop_invalid_header_fields = true
|
||||
|
||||
dynamic "access_logs" {
|
||||
for_each = var.access_logs_bucket != "" ? [1] : []
|
||||
content {
|
||||
bucket = var.access_logs_bucket
|
||||
prefix = var.access_logs_prefix
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${local.identifier}-alb"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Target Group — ECS Fargate tasks register here
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_target_group" "app" {
|
||||
name = "${local.identifier}-tg"
|
||||
port = var.target_group_port
|
||||
protocol = "HTTP"
|
||||
vpc_id = var.vpc_id
|
||||
target_type = "ip" # Required for Fargate (awsvpc network mode)
|
||||
|
||||
deregistration_delay = 30
|
||||
|
||||
health_check {
|
||||
enabled = true
|
||||
path = var.target_group_health_check_path
|
||||
port = "traffic-port"
|
||||
protocol = "HTTP"
|
||||
interval = var.target_group_health_check_interval
|
||||
timeout = var.target_group_health_check_timeout
|
||||
healthy_threshold = var.target_group_healthy_threshold
|
||||
unhealthy_threshold = var.target_group_unhealthy_threshold
|
||||
matcher = "200"
|
||||
}
|
||||
|
||||
stickiness {
|
||||
type = "lb_cookie"
|
||||
enabled = false # AgentIdP is stateless (JWT-based); no sticky sessions needed
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${local.identifier}-tg"
|
||||
})
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# HTTPS Listener (port 443) — primary listener
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_listener" "https" {
|
||||
load_balancer_arn = aws_lb.main.arn
|
||||
port = 443
|
||||
protocol = "HTTPS"
|
||||
ssl_policy = var.ssl_policy
|
||||
certificate_arn = var.certificate_arn
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
}
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# HTTP Listener (port 80) — permanent redirect to HTTPS
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb_listener" "http_redirect" {
|
||||
load_balancer_arn = aws_lb.main.arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "redirect"
|
||||
|
||||
redirect {
|
||||
port = "443"
|
||||
protocol = "HTTPS"
|
||||
status_code = "HTTP_301"
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
49
terraform/modules/lb/outputs.tf
Normal file
49
terraform/modules/lb/outputs.tf
Normal file
@@ -0,0 +1,49 @@
|
||||
################################################################################
|
||||
# Module: lb
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "alb_dns_name" {
|
||||
description = "DNS name of the Application Load Balancer. Create a CNAME or alias record in Route 53 pointing your domain here."
|
||||
value = aws_lb.main.dns_name
|
||||
}
|
||||
|
||||
output "alb_zone_id" {
|
||||
description = "Hosted zone ID of the ALB. Use with aws_route53_record alias records."
|
||||
value = aws_lb.main.zone_id
|
||||
}
|
||||
|
||||
output "alb_arn" {
|
||||
description = "ARN of the Application Load Balancer."
|
||||
value = aws_lb.main.arn
|
||||
}
|
||||
|
||||
output "alb_arn_suffix" {
|
||||
description = "ARN suffix of the ALB for use in CloudWatch metrics."
|
||||
value = aws_lb.main.arn_suffix
|
||||
}
|
||||
|
||||
output "target_group_arn" {
|
||||
description = "ARN of the target group. Pass to the agentidp module as aws_target_group_arn."
|
||||
value = aws_lb_target_group.app.arn
|
||||
}
|
||||
|
||||
output "target_group_arn_suffix" {
|
||||
description = "ARN suffix of the target group for use in CloudWatch metrics."
|
||||
value = aws_lb_target_group.app.arn_suffix
|
||||
}
|
||||
|
||||
output "https_listener_arn" {
|
||||
description = "ARN of the HTTPS listener."
|
||||
value = aws_lb_listener.https.arn
|
||||
}
|
||||
|
||||
output "http_redirect_listener_arn" {
|
||||
description = "ARN of the HTTP→HTTPS redirect listener."
|
||||
value = aws_lb_listener.http_redirect.arn
|
||||
}
|
||||
|
||||
output "alb_security_group_id" {
|
||||
description = "Security group ID of the ALB. Add this as an allowed source in the app task security group."
|
||||
value = aws_security_group.alb.id
|
||||
}
|
||||
102
terraform/modules/lb/variables.tf
Normal file
102
terraform/modules/lb/variables.tf
Normal file
@@ -0,0 +1,102 @@
|
||||
################################################################################
|
||||
# Module: lb
|
||||
# Variables — AWS Application Load Balancer
|
||||
################################################################################
|
||||
|
||||
variable "environment" {
|
||||
description = "Deployment environment label (e.g. production, staging)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "project" {
|
||||
description = "Project identifier used in resource names and tags."
|
||||
type = string
|
||||
default = "sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
description = "VPC ID in which to create the ALB and its security group."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of public subnet IDs for the ALB. Must span at least 2 AZs."
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
description = "ARN of the ACM certificate to attach to the HTTPS listener (port 443)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "target_group_port" {
|
||||
description = "Port that ECS task containers listen on. Target group forwards traffic to this port."
|
||||
type = number
|
||||
default = 3000
|
||||
}
|
||||
|
||||
variable "target_group_health_check_path" {
|
||||
description = "HTTP path used by the ALB target group health check."
|
||||
type = string
|
||||
default = "/health"
|
||||
}
|
||||
|
||||
variable "target_group_health_check_interval" {
|
||||
description = "Interval in seconds between ALB health checks."
|
||||
type = number
|
||||
default = 30
|
||||
}
|
||||
|
||||
variable "target_group_health_check_timeout" {
|
||||
description = "Timeout in seconds for each ALB health check request."
|
||||
type = number
|
||||
default = 5
|
||||
}
|
||||
|
||||
variable "target_group_healthy_threshold" {
|
||||
description = "Number of consecutive successful health checks before marking a target healthy."
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "target_group_unhealthy_threshold" {
|
||||
description = "Number of consecutive failed health checks before marking a target unhealthy."
|
||||
type = number
|
||||
default = 3
|
||||
}
|
||||
|
||||
variable "idle_timeout" {
|
||||
description = "ALB idle connection timeout in seconds."
|
||||
type = number
|
||||
default = 60
|
||||
}
|
||||
|
||||
variable "enable_deletion_protection" {
|
||||
description = "Prevent the ALB from being deleted via the AWS API."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "access_logs_bucket" {
|
||||
description = "S3 bucket name for ALB access logs. Leave empty to disable access logging."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "access_logs_prefix" {
|
||||
description = "S3 key prefix for ALB access log files."
|
||||
type = string
|
||||
default = "alb"
|
||||
}
|
||||
|
||||
variable "ssl_policy" {
|
||||
description = "SSL negotiation policy for the HTTPS listener. ELBSecurityPolicy-TLS13-1-2-2021-06 enforces TLS 1.2+ and TLS 1.3."
|
||||
type = string
|
||||
default = "ELBSecurityPolicy-TLS13-1-2-2021-06"
|
||||
}
|
||||
|
||||
variable "allowed_ingress_cidrs" {
|
||||
description = "CIDR blocks allowed to reach the ALB on port 80 and 443. Default allows public internet."
|
||||
type = list(string)
|
||||
default = ["0.0.0.0/0"]
|
||||
}
|
||||
180
terraform/modules/rds/main.tf
Normal file
180
terraform/modules/rds/main.tf
Normal file
@@ -0,0 +1,180 @@
|
||||
################################################################################
|
||||
# Module: rds
|
||||
# Main — AWS RDS PostgreSQL 14
|
||||
#
|
||||
# - Multi-AZ for HA
|
||||
# - Encryption at rest (AWS-managed KMS key)
|
||||
# - No public access — VPC-internal only
|
||||
# - Storage autoscaling up to max_allocated_storage
|
||||
# - Enhanced monitoring and Performance Insights enabled by default
|
||||
# - Access restricted to explicitly allowed security groups (app only)
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
identifier = "${var.project}-${var.environment}"
|
||||
|
||||
common_tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
managed_by = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group — only the app SGs may connect on 5432
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "rds" {
|
||||
name = "${local.identifier}-rds-sg"
|
||||
description = "Controls inbound access to RDS PostgreSQL — allow only app SG on 5432"
|
||||
vpc_id = var.vpc_id
|
||||
|
||||
# No ingress rules defined here — added dynamically below to avoid circular deps.
|
||||
egress {
|
||||
description = "All outbound (RDS initiates no outbound connections; this satisfies AWS requirement)"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${local.identifier}-rds-sg"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "rds_ingress_from_app" {
|
||||
for_each = toset(var.allowed_security_group_ids)
|
||||
|
||||
type = "ingress"
|
||||
description = "PostgreSQL from app security group"
|
||||
from_port = 5432
|
||||
to_port = 5432
|
||||
protocol = "tcp"
|
||||
source_security_group_id = each.value
|
||||
security_group_id = aws_security_group.rds.id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DB Subnet Group — must cover at least 2 AZs for Multi-AZ
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_subnet_group" "main" {
|
||||
name = "${local.identifier}-db-subnet-group"
|
||||
description = "Private subnets for AgentIdP RDS instance"
|
||||
subnet_ids = var.subnet_ids
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${local.identifier}-db-subnet-group"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DB Parameter Group — enforce SSL connections
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_parameter_group" "main" {
|
||||
name = "${local.identifier}-pg14-params"
|
||||
family = var.parameter_group_family
|
||||
description = "AgentIdP custom parameter group — enforces SSL"
|
||||
|
||||
parameter {
|
||||
name = "rds.force_ssl"
|
||||
value = "1"
|
||||
apply_method = "immediate"
|
||||
}
|
||||
|
||||
parameter {
|
||||
name = "log_connections"
|
||||
value = "1"
|
||||
apply_method = "immediate"
|
||||
}
|
||||
|
||||
parameter {
|
||||
name = "log_disconnections"
|
||||
value = "1"
|
||||
apply_method = "immediate"
|
||||
}
|
||||
|
||||
parameter {
|
||||
name = "log_min_duration_statement"
|
||||
value = "1000"
|
||||
apply_method = "immediate"
|
||||
}
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# RDS Instance
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_instance" "main" {
|
||||
identifier = local.identifier
|
||||
|
||||
# Engine
|
||||
engine = "postgres"
|
||||
engine_version = "14"
|
||||
instance_class = var.instance_class
|
||||
|
||||
# Storage
|
||||
storage_type = "gp3"
|
||||
allocated_storage = var.allocated_storage
|
||||
max_allocated_storage = var.max_allocated_storage
|
||||
storage_encrypted = true
|
||||
# kms_key_id is omitted — defaults to the AWS-managed RDS KMS key.
|
||||
# For customer-managed key, set kms_key_id to your CMK ARN.
|
||||
|
||||
# Database
|
||||
db_name = var.db_name
|
||||
username = var.db_username
|
||||
password = var.db_password
|
||||
|
||||
# Network — VPC-internal only, no public endpoint
|
||||
db_subnet_group_name = aws_db_subnet_group.main.name
|
||||
vpc_security_group_ids = [aws_security_group.rds.id]
|
||||
publicly_accessible = false
|
||||
multi_az = var.multi_az
|
||||
port = 5432
|
||||
|
||||
# Parameter group
|
||||
parameter_group_name = aws_db_parameter_group.main.name
|
||||
|
||||
# Backups
|
||||
backup_retention_period = var.backup_retention_days
|
||||
backup_window = var.backup_window
|
||||
delete_automated_backups = false
|
||||
copy_tags_to_snapshot = true
|
||||
skip_final_snapshot = var.skip_final_snapshot
|
||||
final_snapshot_identifier = var.skip_final_snapshot ? null : "${local.identifier}-final-snapshot"
|
||||
|
||||
# Maintenance
|
||||
maintenance_window = var.maintenance_window
|
||||
auto_minor_version_upgrade = true
|
||||
apply_immediately = false
|
||||
|
||||
# Observability
|
||||
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
|
||||
performance_insights_enabled = var.performance_insights_enabled
|
||||
performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null
|
||||
monitoring_interval = var.monitoring_interval
|
||||
monitoring_role_arn = var.monitoring_interval > 0 ? var.monitoring_role_arn : null
|
||||
|
||||
# Protection
|
||||
deletion_protection = var.deletion_protection
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = local.identifier
|
||||
})
|
||||
}
|
||||
44
terraform/modules/rds/outputs.tf
Normal file
44
terraform/modules/rds/outputs.tf
Normal file
@@ -0,0 +1,44 @@
|
||||
################################################################################
|
||||
# Module: rds
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "endpoint" {
|
||||
description = "RDS instance endpoint hostname (without port). Use to construct DATABASE_URL."
|
||||
value = aws_db_instance.main.address
|
||||
}
|
||||
|
||||
output "port" {
|
||||
description = "Port the RDS instance listens on (always 5432)."
|
||||
value = aws_db_instance.main.port
|
||||
}
|
||||
|
||||
output "db_name" {
|
||||
description = "Name of the database created on the RDS instance."
|
||||
value = aws_db_instance.main.db_name
|
||||
}
|
||||
|
||||
output "db_username" {
|
||||
description = "Master username for the RDS instance."
|
||||
value = aws_db_instance.main.username
|
||||
}
|
||||
|
||||
output "instance_id" {
|
||||
description = "RDS instance identifier."
|
||||
value = aws_db_instance.main.identifier
|
||||
}
|
||||
|
||||
output "instance_arn" {
|
||||
description = "ARN of the RDS instance."
|
||||
value = aws_db_instance.main.arn
|
||||
}
|
||||
|
||||
output "security_group_id" {
|
||||
description = "Security group ID attached to the RDS instance. Use to add further ingress rules if needed."
|
||||
value = aws_security_group.rds.id
|
||||
}
|
||||
|
||||
output "db_subnet_group_name" {
|
||||
description = "Name of the DB subnet group."
|
||||
value = aws_db_subnet_group.main.name
|
||||
}
|
||||
133
terraform/modules/rds/variables.tf
Normal file
133
terraform/modules/rds/variables.tf
Normal file
@@ -0,0 +1,133 @@
|
||||
################################################################################
|
||||
# Module: rds
|
||||
# Variables — AWS RDS PostgreSQL 14
|
||||
################################################################################
|
||||
|
||||
variable "environment" {
|
||||
description = "Deployment environment label (e.g. production, staging)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "project" {
|
||||
description = "Project identifier used in resource names and tags."
|
||||
type = string
|
||||
default = "sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
description = "VPC ID in which to create the RDS subnet group and security group."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of private subnet IDs for the RDS DB subnet group. Must span at least 2 AZs for Multi-AZ."
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "allowed_security_group_ids" {
|
||||
description = "List of security group IDs (e.g. ECS app SG) permitted to connect to RDS on port 5432."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "db_name" {
|
||||
description = "Name of the initial PostgreSQL database to create."
|
||||
type = string
|
||||
default = "sentryagent_idp"
|
||||
}
|
||||
|
||||
variable "db_username" {
|
||||
description = "Master username for the RDS instance."
|
||||
type = string
|
||||
default = "sentryagent"
|
||||
}
|
||||
|
||||
variable "db_password" {
|
||||
description = "Master password for the RDS instance. Store this in Secrets Manager; do not hardcode."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "instance_class" {
|
||||
description = "RDS instance class."
|
||||
type = string
|
||||
default = "db.t3.medium"
|
||||
}
|
||||
|
||||
variable "allocated_storage" {
|
||||
description = "Initial storage allocated in GiB."
|
||||
type = number
|
||||
default = 50
|
||||
}
|
||||
|
||||
variable "max_allocated_storage" {
|
||||
description = "Upper bound for RDS storage autoscaling in GiB. Set to 0 to disable autoscaling."
|
||||
type = number
|
||||
default = 500
|
||||
}
|
||||
|
||||
variable "multi_az" {
|
||||
description = "Enable Multi-AZ deployment for high availability."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "backup_retention_days" {
|
||||
description = "Number of days to retain automated backups. Must be >= 1 for Multi-AZ."
|
||||
type = number
|
||||
default = 7
|
||||
}
|
||||
|
||||
variable "backup_window" {
|
||||
description = "Preferred daily backup window in UTC (hh24:mi-hh24:mi)."
|
||||
type = string
|
||||
default = "03:00-04:00"
|
||||
}
|
||||
|
||||
variable "maintenance_window" {
|
||||
description = "Preferred weekly maintenance window (ddd:hh24:mi-ddd:hh24:mi in UTC)."
|
||||
type = string
|
||||
default = "sun:05:00-sun:06:00"
|
||||
}
|
||||
|
||||
variable "deletion_protection" {
|
||||
description = "Enable deletion protection. Set to false only when decommissioning."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "skip_final_snapshot" {
|
||||
description = "Whether to skip the final DB snapshot on destroy. Should be false in production."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "performance_insights_enabled" {
|
||||
description = "Enable RDS Performance Insights."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "performance_insights_retention_period" {
|
||||
description = "Performance Insights data retention in days. Free tier = 7; paid tiers = 731."
|
||||
type = number
|
||||
default = 7
|
||||
}
|
||||
|
||||
variable "monitoring_interval" {
|
||||
description = "Enhanced monitoring interval in seconds (0 to disable, valid: 1, 5, 10, 15, 30, 60)."
|
||||
type = number
|
||||
default = 60
|
||||
}
|
||||
|
||||
variable "monitoring_role_arn" {
|
||||
description = "IAM role ARN for RDS Enhanced Monitoring. Required when monitoring_interval > 0."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "parameter_group_family" {
|
||||
description = "DB parameter group family."
|
||||
type = string
|
||||
default = "postgres14"
|
||||
}
|
||||
176
terraform/modules/redis/main.tf
Normal file
176
terraform/modules/redis/main.tf
Normal file
@@ -0,0 +1,176 @@
|
||||
################################################################################
|
||||
# Module: redis
|
||||
# Main — AWS ElastiCache Redis 7
|
||||
#
|
||||
# - Single shard (cluster mode disabled): one primary + one replica
|
||||
# - Encryption at rest and in transit (TLS)
|
||||
# - AUTH token required when transit encryption is enabled
|
||||
# - VPC-internal only — no public access
|
||||
# - Access restricted to explicitly allowed security groups (app only)
|
||||
# - Slow log + engine log delivery to CloudWatch
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
identifier = "${var.project}-${var.environment}"
|
||||
|
||||
common_tags = {
|
||||
environment = var.environment
|
||||
project = var.project
|
||||
managed_by = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group for Redis logs
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "redis" {
|
||||
count = var.log_delivery_enabled ? 1 : 0
|
||||
|
||||
name = var.log_group_name
|
||||
retention_in_days = 30
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group — only the app SGs may connect on 6379
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "redis" {
|
||||
name = "${local.identifier}-redis-sg"
|
||||
description = "Controls inbound access to ElastiCache Redis — allow only app SG on 6379"
|
||||
vpc_id = var.vpc_id
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = "${local.identifier}-redis-sg"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "redis_ingress_from_app" {
|
||||
for_each = toset(var.allowed_security_group_ids)
|
||||
|
||||
type = "ingress"
|
||||
description = "Redis from app security group"
|
||||
from_port = 6379
|
||||
to_port = 6379
|
||||
protocol = "tcp"
|
||||
source_security_group_id = each.value
|
||||
security_group_id = aws_security_group.redis.id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ElastiCache Subnet Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_elasticache_subnet_group" "main" {
|
||||
name = "${local.identifier}-redis-subnet-group"
|
||||
description = "Private subnets for AgentIdP ElastiCache Redis"
|
||||
subnet_ids = var.subnet_ids
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ElastiCache Parameter Group — Redis 7.x defaults are fine; custom group
|
||||
# allows future tuning without recreating the replication group.
|
||||
################################################################################
|
||||
|
||||
resource "aws_elasticache_parameter_group" "main" {
|
||||
name = "${local.identifier}-redis7-params"
|
||||
family = "redis7"
|
||||
description = "AgentIdP Redis 7 parameter group"
|
||||
|
||||
# Disable dangerous commands that could truncate data in production
|
||||
parameter {
|
||||
name = "lazyfree-lazy-eviction"
|
||||
value = "yes"
|
||||
}
|
||||
|
||||
parameter {
|
||||
name = "lazyfree-lazy-expire"
|
||||
value = "yes"
|
||||
}
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ElastiCache Replication Group (cluster mode disabled)
|
||||
#
|
||||
# cluster_mode = 0 (disabled) gives a single-shard setup:
|
||||
# - 1 primary node
|
||||
# - num_cache_clusters - 1 replica nodes
|
||||
# This matches the application usage: token revocation (SET/GET/DEL),
|
||||
# rate limiting (INCR/EXPIRE), and monthly counters (INCR) — no sharding needed.
|
||||
################################################################################
|
||||
|
||||
resource "aws_elasticache_replication_group" "main" {
|
||||
replication_group_id = local.identifier
|
||||
description = "AgentIdP Redis 7 — token revocation, rate limiting, counters"
|
||||
|
||||
# Engine
|
||||
engine = "redis"
|
||||
engine_version = var.engine_version
|
||||
node_type = var.node_type
|
||||
parameter_group_name = aws_elasticache_parameter_group.main.name
|
||||
port = 6379
|
||||
|
||||
# Topology — single shard, primary + replica
|
||||
num_cache_clusters = var.num_cache_clusters
|
||||
automatic_failover_enabled = var.automatic_failover_enabled
|
||||
multi_az_enabled = var.multi_az_enabled
|
||||
|
||||
# Network — VPC-internal, no public endpoints
|
||||
subnet_group_name = aws_elasticache_subnet_group.main.name
|
||||
security_group_ids = [aws_security_group.redis.id]
|
||||
|
||||
# Security
|
||||
at_rest_encryption_enabled = var.at_rest_encryption_enabled
|
||||
transit_encryption_enabled = var.transit_encryption_enabled
|
||||
auth_token = var.transit_encryption_enabled && var.auth_token != "" ? var.auth_token : null
|
||||
|
||||
# Maintenance and snapshots
|
||||
maintenance_window = var.maintenance_window
|
||||
snapshot_retention_limit = var.snapshot_retention_limit
|
||||
snapshot_window = var.snapshot_window
|
||||
apply_immediately = var.apply_immediately
|
||||
|
||||
# Log delivery to CloudWatch
|
||||
dynamic "log_delivery_configuration" {
|
||||
for_each = var.log_delivery_enabled ? [
|
||||
{ log_type = "slow-log", log_format = "json" },
|
||||
{ log_type = "engine-log", log_format = "json" }
|
||||
] : []
|
||||
|
||||
content {
|
||||
destination = var.log_delivery_enabled ? aws_cloudwatch_log_group.redis[0].name : ""
|
||||
destination_type = "cloudwatch-logs"
|
||||
log_format = log_delivery_configuration.value.log_format
|
||||
log_type = log_delivery_configuration.value.log_type
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(local.common_tags, {
|
||||
Name = local.identifier
|
||||
})
|
||||
}
|
||||
34
terraform/modules/redis/outputs.tf
Normal file
34
terraform/modules/redis/outputs.tf
Normal file
@@ -0,0 +1,34 @@
|
||||
################################################################################
|
||||
# Module: redis
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "primary_endpoint" {
|
||||
description = "Primary endpoint hostname for write operations. Use to construct REDIS_URL."
|
||||
value = aws_elasticache_replication_group.main.primary_endpoint_address
|
||||
}
|
||||
|
||||
output "reader_endpoint" {
|
||||
description = "Reader endpoint for read operations (load-balanced across replicas)."
|
||||
value = aws_elasticache_replication_group.main.reader_endpoint_address
|
||||
}
|
||||
|
||||
output "port" {
|
||||
description = "Port the Redis replication group listens on (always 6379)."
|
||||
value = aws_elasticache_replication_group.main.port
|
||||
}
|
||||
|
||||
output "replication_group_id" {
|
||||
description = "ID of the ElastiCache replication group."
|
||||
value = aws_elasticache_replication_group.main.replication_group_id
|
||||
}
|
||||
|
||||
output "security_group_id" {
|
||||
description = "Security group ID attached to the replication group. Use to add further ingress rules."
|
||||
value = aws_security_group.redis.id
|
||||
}
|
||||
|
||||
output "redis_url" {
|
||||
description = "Constructed REDIS_URL using the primary endpoint. Includes rediss:// (TLS) scheme when transit encryption is enabled."
|
||||
value = var.transit_encryption_enabled ? "rediss://${aws_elasticache_replication_group.main.primary_endpoint_address}:${aws_elasticache_replication_group.main.port}" : "redis://${aws_elasticache_replication_group.main.primary_endpoint_address}:${aws_elasticache_replication_group.main.port}"
|
||||
}
|
||||
116
terraform/modules/redis/variables.tf
Normal file
116
terraform/modules/redis/variables.tf
Normal file
@@ -0,0 +1,116 @@
|
||||
################################################################################
|
||||
# Module: redis
|
||||
# Variables — AWS ElastiCache Redis 7
|
||||
################################################################################
|
||||
|
||||
variable "environment" {
|
||||
description = "Deployment environment label (e.g. production, staging)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "project" {
|
||||
description = "Project identifier used in resource names and tags."
|
||||
type = string
|
||||
default = "sentryagent-agentidp"
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
description = "VPC ID in which to create the ElastiCache subnet group and security group."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of private subnet IDs for the ElastiCache subnet group. Span at least 2 AZs."
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "allowed_security_group_ids" {
|
||||
description = "List of security group IDs (e.g. ECS app SG) permitted to connect to Redis on port 6379."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "node_type" {
|
||||
description = "ElastiCache node instance type."
|
||||
type = string
|
||||
default = "cache.t3.medium"
|
||||
}
|
||||
|
||||
variable "engine_version" {
|
||||
description = "Redis engine version. Use 7.x for Redis 7."
|
||||
type = string
|
||||
default = "7.1"
|
||||
}
|
||||
|
||||
variable "num_cache_clusters" {
|
||||
description = "Total number of cache clusters in the replication group (1 primary + N replicas). Minimum 2 for HA."
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "automatic_failover_enabled" {
|
||||
description = "Enable automatic failover. Required when num_cache_clusters > 1."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "multi_az_enabled" {
|
||||
description = "Enable Multi-AZ for the replication group."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "at_rest_encryption_enabled" {
|
||||
description = "Encrypt data at rest."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "transit_encryption_enabled" {
|
||||
description = "Enable TLS for data in transit."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "auth_token" {
|
||||
description = "AUTH token (password) for Redis AUTH command. Required when transit_encryption_enabled = true. Minimum 16 characters."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "maintenance_window" {
|
||||
description = "Preferred weekly maintenance window (ddd:hh24:mi-ddd:hh24:mi in UTC)."
|
||||
type = string
|
||||
default = "sun:06:00-sun:07:00"
|
||||
}
|
||||
|
||||
variable "snapshot_retention_limit" {
|
||||
description = "Number of days to retain automatic Redis snapshots. 0 disables snapshots."
|
||||
type = number
|
||||
default = 7
|
||||
}
|
||||
|
||||
variable "snapshot_window" {
|
||||
description = "Daily time range for automatic snapshots (hh24:mi-hh24:mi in UTC). Must not overlap maintenance_window."
|
||||
type = string
|
||||
default = "04:00-05:00"
|
||||
}
|
||||
|
||||
variable "apply_immediately" {
|
||||
description = "Apply changes immediately. Set to false to wait for the next maintenance window in production."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "log_delivery_enabled" {
|
||||
description = "Enable delivery of Redis slow logs and engine logs to CloudWatch."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "log_group_name" {
|
||||
description = "CloudWatch log group name for Redis logs. Created if it does not exist."
|
||||
type = string
|
||||
default = "/elasticache/sentryagent-agentidp/redis"
|
||||
}
|
||||
Reference in New Issue
Block a user