mirror of
https://github.com/ghndrx/terraform-foundation.git
synced 2026-02-10 06:45:06 +00:00
feat: Terraform Foundation - AWS Landing Zone
Enterprise-grade multi-tenant AWS cloud foundation. Modules: - GitHub OIDC for keyless CI/CD authentication - IAM account settings and security baseline - AWS Config Rules for compliance - ABAC (Attribute-Based Access Control) - SCPs (Service Control Policies) Features: - Multi-account architecture - Cost optimization patterns - Security best practices - Comprehensive documentation Tech: Terraform, AWS Organizations, IAM Identity Center
This commit is contained in:
532
terraform/05-workloads/_template/api-gateway/main.tf
Normal file
532
terraform/05-workloads/_template/api-gateway/main.tf
Normal file
@@ -0,0 +1,532 @@
|
||||
################################################################################
|
||||
# Workload: API Gateway REST API
|
||||
#
|
||||
# Deploys a REST API with:
|
||||
# - API Gateway with stages
|
||||
# - Lambda or HTTP backend integrations
|
||||
# - Custom domain with ACM
|
||||
# - WAF integration (optional)
|
||||
# - CloudWatch logging
|
||||
# - Usage plans and API keys
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<api-name>/
|
||||
# Update locals
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-api/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
api_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# API Type: REST or HTTP
|
||||
api_type = "REST" # REST for full features, HTTP for simpler/cheaper
|
||||
|
||||
# Custom domain (set to null to skip)
|
||||
domain_name = null # e.g., "api.example.com"
|
||||
hosted_zone_id = null # Route53 zone ID
|
||||
|
||||
# WAF (requires waf-alb module deployed)
|
||||
waf_acl_arn = null
|
||||
|
||||
# Stages
|
||||
stages = ["prod", "staging"]
|
||||
|
||||
# Throttling defaults
|
||||
throttle_burst_limit = 100
|
||||
throttle_rate_limit = 50
|
||||
|
||||
# CloudWatch logging
|
||||
logging_level = "INFO" # OFF, ERROR, INFO
|
||||
|
||||
# Lambda integrations (map of path -> lambda ARN)
|
||||
lambda_integrations = {
|
||||
# "GET /users" = "arn:aws:lambda:us-east-1:123456789012:function:get-users"
|
||||
# "POST /users" = "arn:aws:lambda:us-east-1:123456789012:function:create-user"
|
||||
# "GET /users/{id}" = "arn:aws:lambda:us-east-1:123456789012:function:get-user"
|
||||
}
|
||||
|
||||
# HTTP proxy integrations (map of path -> HTTP endpoint)
|
||||
http_integrations = {
|
||||
# "GET /health" = "https://backend.example.com/health"
|
||||
}
|
||||
|
||||
# Mock integrations for static responses
|
||||
mock_integrations = {
|
||||
"GET /health" = {
|
||||
status_code = "200"
|
||||
response = jsonencode({ status = "healthy" })
|
||||
}
|
||||
}
|
||||
|
||||
# CORS configuration
|
||||
cors_enabled = true
|
||||
cors_origins = ["*"]
|
||||
cors_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
cors_headers = ["Content-Type", "Authorization", "X-Api-Key"]
|
||||
|
||||
# API Keys and Usage Plans
|
||||
create_api_key = true
|
||||
usage_plans = {
|
||||
basic = {
|
||||
quota_limit = 1000
|
||||
quota_period = "MONTH"
|
||||
throttle_burst = 10
|
||||
throttle_rate = 5
|
||||
}
|
||||
premium = {
|
||||
quota_limit = 100000
|
||||
quota_period = "MONTH"
|
||||
throttle_burst = 100
|
||||
throttle_rate = 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# REST API
|
||||
################################################################################
|
||||
|
||||
resource "aws_api_gateway_rest_api" "main" {
|
||||
name = local.api_name
|
||||
description = "REST API for ${local.tenant} ${local.name}"
|
||||
|
||||
endpoint_configuration {
|
||||
types = ["REGIONAL"]
|
||||
}
|
||||
|
||||
tags = { Name = local.api_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Logging
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "api" {
|
||||
name = "/aws/api-gateway/${local.api_name}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = local.api_name }
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_account" "main" {
|
||||
cloudwatch_role_arn = aws_iam_role.api_logging.arn
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "api_logging" {
|
||||
name = "${local.api_name}-api-logging"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "apigateway.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.api_name}-api-logging" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "api_logging" {
|
||||
role = aws_iam_role.api_logging.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Mock Integration - Health Check
|
||||
################################################################################
|
||||
|
||||
resource "aws_api_gateway_resource" "health" {
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
parent_id = aws_api_gateway_rest_api.main.root_resource_id
|
||||
path_part = "health"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method" "health_get" {
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = "GET"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "health_get" {
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = aws_api_gateway_method.health_get.http_method
|
||||
type = "MOCK"
|
||||
|
||||
request_templates = {
|
||||
"application/json" = jsonencode({ statusCode = 200 })
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method_response" "health_get" {
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = aws_api_gateway_method.health_get.http_method
|
||||
status_code = "200"
|
||||
|
||||
response_models = {
|
||||
"application/json" = "Empty"
|
||||
}
|
||||
|
||||
response_parameters = {
|
||||
"method.response.header.Access-Control-Allow-Origin" = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration_response" "health_get" {
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = aws_api_gateway_method.health_get.http_method
|
||||
status_code = aws_api_gateway_method_response.health_get.status_code
|
||||
|
||||
response_templates = {
|
||||
"application/json" = jsonencode({
|
||||
status = "healthy"
|
||||
timestamp = "$context.requestTime"
|
||||
})
|
||||
}
|
||||
|
||||
response_parameters = {
|
||||
"method.response.header.Access-Control-Allow-Origin" = "'*'"
|
||||
}
|
||||
|
||||
depends_on = [aws_api_gateway_integration.health_get]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CORS - OPTIONS method for health
|
||||
################################################################################
|
||||
|
||||
resource "aws_api_gateway_method" "health_options" {
|
||||
count = local.cors_enabled ? 1 : 0
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = "OPTIONS"
|
||||
authorization = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration" "health_options" {
|
||||
count = local.cors_enabled ? 1 : 0
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = aws_api_gateway_method.health_options[0].http_method
|
||||
type = "MOCK"
|
||||
|
||||
request_templates = {
|
||||
"application/json" = jsonencode({ statusCode = 200 })
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method_response" "health_options" {
|
||||
count = local.cors_enabled ? 1 : 0
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = aws_api_gateway_method.health_options[0].http_method
|
||||
status_code = "200"
|
||||
|
||||
response_parameters = {
|
||||
"method.response.header.Access-Control-Allow-Headers" = true
|
||||
"method.response.header.Access-Control-Allow-Methods" = true
|
||||
"method.response.header.Access-Control-Allow-Origin" = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_integration_response" "health_options" {
|
||||
count = local.cors_enabled ? 1 : 0
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
resource_id = aws_api_gateway_resource.health.id
|
||||
http_method = aws_api_gateway_method.health_options[0].http_method
|
||||
status_code = "200"
|
||||
|
||||
response_parameters = {
|
||||
"method.response.header.Access-Control-Allow-Headers" = "'${join(",", local.cors_headers)}'"
|
||||
"method.response.header.Access-Control-Allow-Methods" = "'${join(",", local.cors_methods)}'"
|
||||
"method.response.header.Access-Control-Allow-Origin" = "'${join(",", local.cors_origins)}'"
|
||||
}
|
||||
|
||||
depends_on = [aws_api_gateway_integration.health_options]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Deployment & Stages
|
||||
################################################################################
|
||||
|
||||
resource "aws_api_gateway_deployment" "main" {
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
|
||||
triggers = {
|
||||
redeployment = sha1(jsonencode([
|
||||
aws_api_gateway_resource.health.id,
|
||||
aws_api_gateway_method.health_get.id,
|
||||
aws_api_gateway_integration.health_get.id,
|
||||
]))
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
|
||||
depends_on = [
|
||||
aws_api_gateway_integration.health_get,
|
||||
aws_api_gateway_integration_response.health_get,
|
||||
]
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_stage" "stages" {
|
||||
for_each = toset(local.stages)
|
||||
|
||||
deployment_id = aws_api_gateway_deployment.main.id
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
stage_name = each.value
|
||||
|
||||
access_log_settings {
|
||||
destination_arn = aws_cloudwatch_log_group.api.arn
|
||||
format = jsonencode({
|
||||
requestId = "$context.requestId"
|
||||
ip = "$context.identity.sourceIp"
|
||||
caller = "$context.identity.caller"
|
||||
user = "$context.identity.user"
|
||||
requestTime = "$context.requestTime"
|
||||
httpMethod = "$context.httpMethod"
|
||||
resourcePath = "$context.resourcePath"
|
||||
status = "$context.status"
|
||||
protocol = "$context.protocol"
|
||||
responseLength = "$context.responseLength"
|
||||
integrationLatency = "$context.integrationLatency"
|
||||
})
|
||||
}
|
||||
|
||||
tags = { Name = "${local.api_name}-${each.value}" }
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_method_settings" "stages" {
|
||||
for_each = toset(local.stages)
|
||||
|
||||
rest_api_id = aws_api_gateway_rest_api.main.id
|
||||
stage_name = aws_api_gateway_stage.stages[each.value].stage_name
|
||||
method_path = "*/*"
|
||||
|
||||
settings {
|
||||
logging_level = local.logging_level
|
||||
data_trace_enabled = local.logging_level != "OFF"
|
||||
metrics_enabled = true
|
||||
|
||||
throttling_burst_limit = local.throttle_burst_limit
|
||||
throttling_rate_limit = local.throttle_rate_limit
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# WAF Association (Optional)
|
||||
################################################################################
|
||||
|
||||
resource "aws_wafv2_web_acl_association" "api" {
|
||||
count = local.waf_acl_arn != null ? length(local.stages) : 0
|
||||
resource_arn = aws_api_gateway_stage.stages[local.stages[count.index]].arn
|
||||
web_acl_arn = local.waf_acl_arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Custom Domain (Optional)
|
||||
################################################################################
|
||||
|
||||
resource "aws_acm_certificate" "api" {
|
||||
count = local.domain_name != null ? 1 : 0
|
||||
domain_name = local.domain_name
|
||||
validation_method = "DNS"
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
|
||||
tags = { Name = local.domain_name }
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "cert_validation" {
|
||||
for_each = local.domain_name != null ? {
|
||||
for dvo in aws_acm_certificate.api[0].domain_validation_options : dvo.domain_name => {
|
||||
name = dvo.resource_record_name
|
||||
record = dvo.resource_record_value
|
||||
type = dvo.resource_record_type
|
||||
}
|
||||
} : {}
|
||||
|
||||
zone_id = local.hosted_zone_id
|
||||
name = each.value.name
|
||||
type = each.value.type
|
||||
records = [each.value.record]
|
||||
ttl = 60
|
||||
}
|
||||
|
||||
resource "aws_acm_certificate_validation" "api" {
|
||||
count = local.domain_name != null ? 1 : 0
|
||||
certificate_arn = aws_acm_certificate.api[0].arn
|
||||
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_domain_name" "main" {
|
||||
count = local.domain_name != null ? 1 : 0
|
||||
domain_name = local.domain_name
|
||||
certificate_arn = aws_acm_certificate_validation.api[0].certificate_arn
|
||||
|
||||
endpoint_configuration {
|
||||
types = ["REGIONAL"]
|
||||
}
|
||||
|
||||
tags = { Name = local.domain_name }
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_base_path_mapping" "main" {
|
||||
count = local.domain_name != null ? 1 : 0
|
||||
api_id = aws_api_gateway_rest_api.main.id
|
||||
stage_name = aws_api_gateway_stage.stages["prod"].stage_name
|
||||
domain_name = aws_api_gateway_domain_name.main[0].domain_name
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "api" {
|
||||
count = local.domain_name != null ? 1 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = local.domain_name
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = aws_api_gateway_domain_name.main[0].regional_domain_name
|
||||
zone_id = aws_api_gateway_domain_name.main[0].regional_zone_id
|
||||
evaluate_target_health = false
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# API Keys & Usage Plans
|
||||
################################################################################
|
||||
|
||||
resource "aws_api_gateway_api_key" "main" {
|
||||
count = local.create_api_key ? 1 : 0
|
||||
name = "${local.api_name}-key"
|
||||
enabled = true
|
||||
|
||||
tags = { Name = "${local.api_name}-key" }
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_usage_plan" "plans" {
|
||||
for_each = local.usage_plans
|
||||
|
||||
name = "${local.api_name}-${each.key}"
|
||||
|
||||
api_stages {
|
||||
api_id = aws_api_gateway_rest_api.main.id
|
||||
stage = aws_api_gateway_stage.stages["prod"].stage_name
|
||||
}
|
||||
|
||||
quota_settings {
|
||||
limit = each.value.quota_limit
|
||||
period = each.value.quota_period
|
||||
}
|
||||
|
||||
throttle_settings {
|
||||
burst_limit = each.value.throttle_burst
|
||||
rate_limit = each.value.throttle_rate
|
||||
}
|
||||
|
||||
tags = { Name = "${local.api_name}-${each.key}" }
|
||||
}
|
||||
|
||||
resource "aws_api_gateway_usage_plan_key" "main" {
|
||||
count = local.create_api_key ? 1 : 0
|
||||
key_id = aws_api_gateway_api_key.main[0].id
|
||||
key_type = "API_KEY"
|
||||
usage_plan_id = aws_api_gateway_usage_plan.plans["basic"].id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "api_id" {
|
||||
value = aws_api_gateway_rest_api.main.id
|
||||
}
|
||||
|
||||
output "api_name" {
|
||||
value = aws_api_gateway_rest_api.main.name
|
||||
}
|
||||
|
||||
output "stage_urls" {
|
||||
value = { for stage in local.stages : stage => aws_api_gateway_stage.stages[stage].invoke_url }
|
||||
}
|
||||
|
||||
output "custom_domain_url" {
|
||||
value = local.domain_name != null ? "https://${local.domain_name}" : null
|
||||
}
|
||||
|
||||
output "api_key" {
|
||||
value = local.create_api_key ? aws_api_gateway_api_key.main[0].value : null
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "health_endpoint" {
|
||||
value = "${aws_api_gateway_stage.stages["prod"].invoke_url}health"
|
||||
}
|
||||
473
terraform/05-workloads/_template/aurora-serverless/main.tf
Normal file
473
terraform/05-workloads/_template/aurora-serverless/main.tf
Normal file
@@ -0,0 +1,473 @@
|
||||
################################################################################
|
||||
# Workload: Aurora Serverless v2
|
||||
#
|
||||
# Auto-scaling PostgreSQL/MySQL with:
|
||||
# - Scale to zero (cost savings for dev)
|
||||
# - Instant scaling (0.5 ACU increments)
|
||||
# - Multi-AZ by default
|
||||
# - IAM authentication
|
||||
# - Data API (HTTP queries)
|
||||
# - Secrets Manager integration
|
||||
#
|
||||
# Cost: ~$0.12/ACU-hour (scales 0.5-128 ACUs)
|
||||
# Use cases: Variable workloads, dev/test, bursty traffic
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-aurora/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
cluster_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Engine
|
||||
engine = "aurora-postgresql" # aurora-postgresql or aurora-mysql
|
||||
engine_version = "15.4" # PostgreSQL 15.4 / MySQL 8.0
|
||||
|
||||
# Serverless v2 capacity
|
||||
min_capacity = 0.5 # Minimum ACUs (0.5 = scale to near-zero)
|
||||
max_capacity = 16 # Maximum ACUs (adjust based on needs)
|
||||
|
||||
# For true scale-to-zero (pauses after idle):
|
||||
# Note: Only available in some regions
|
||||
enable_pause = false
|
||||
pause_after_seconds = 300 # 5 minutes idle
|
||||
|
||||
# Database
|
||||
database_name = replace(local.name, "-", "_")
|
||||
port = local.engine == "aurora-postgresql" ? 5432 : 3306
|
||||
|
||||
# Master credentials (stored in Secrets Manager)
|
||||
master_username = "admin"
|
||||
|
||||
# Network (get from remote state or hardcode)
|
||||
vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id
|
||||
private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
|
||||
# Features
|
||||
enable_iam_auth = true
|
||||
enable_data_api = true # HTTP Data API (for Lambda/serverless)
|
||||
enable_performance_insights = true
|
||||
performance_insights_retention = 7 # days (7 = free tier)
|
||||
|
||||
# Backup
|
||||
backup_retention_period = 7
|
||||
preferred_backup_window = "03:00-04:00"
|
||||
|
||||
# Maintenance
|
||||
preferred_maintenance_window = "sun:04:00-sun:05:00"
|
||||
auto_minor_version_upgrade = true
|
||||
|
||||
# Deletion protection (enable for production)
|
||||
deletion_protection = local.env == "prod"
|
||||
skip_final_snapshot = local.env != "prod"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Random Password
|
||||
################################################################################
|
||||
|
||||
resource "random_password" "master" {
|
||||
length = 32
|
||||
special = false # Aurora has special char restrictions
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets Manager
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret" "db" {
|
||||
name = "${local.tenant}/${local.env}/${local.name}/aurora"
|
||||
description = "Aurora Serverless credentials for ${local.cluster_name}"
|
||||
|
||||
tags = { Name = "${local.cluster_name}-credentials" }
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "db" {
|
||||
secret_id = aws_secretsmanager_secret.db.id
|
||||
secret_string = jsonencode({
|
||||
username = local.master_username
|
||||
password = random_password.master.result
|
||||
engine = local.engine
|
||||
host = aws_rds_cluster.main.endpoint
|
||||
port = local.port
|
||||
dbname = local.database_name
|
||||
dbClusterIdentifier = aws_rds_cluster.main.id
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "aurora" {
|
||||
count = length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = "${local.cluster_name}-aurora"
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "Database from VPC"
|
||||
from_port = local.port
|
||||
to_port = local.port
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["10.0.0.0/8"] # Adjust to your VPC CIDR
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.cluster_name}-aurora" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DB Subnet Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_subnet_group" "main" {
|
||||
count = length(local.private_subnet_ids) > 0 ? 1 : 0
|
||||
name = local.cluster_name
|
||||
subnet_ids = local.private_subnet_ids
|
||||
|
||||
tags = { Name = local.cluster_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Aurora Serverless v2 Cluster
|
||||
################################################################################
|
||||
|
||||
resource "aws_rds_cluster" "main" {
|
||||
cluster_identifier = local.cluster_name
|
||||
engine = local.engine
|
||||
engine_mode = "provisioned" # Required for Serverless v2
|
||||
engine_version = local.engine_version
|
||||
|
||||
database_name = local.database_name
|
||||
master_username = local.master_username
|
||||
master_password = random_password.master.result
|
||||
port = local.port
|
||||
|
||||
# Serverless v2 scaling
|
||||
serverlessv2_scaling_configuration {
|
||||
min_capacity = local.min_capacity
|
||||
max_capacity = local.max_capacity
|
||||
}
|
||||
|
||||
# Network
|
||||
db_subnet_group_name = length(aws_db_subnet_group.main) > 0 ? aws_db_subnet_group.main[0].name : null
|
||||
vpc_security_group_ids = length(aws_security_group.aurora) > 0 ? [aws_security_group.aurora[0].id] : []
|
||||
|
||||
# Storage
|
||||
storage_encrypted = true
|
||||
kms_key_id = null # Uses AWS managed key
|
||||
|
||||
# Features
|
||||
enable_http_endpoint = local.enable_data_api
|
||||
iam_database_authentication_enabled = local.enable_iam_auth
|
||||
|
||||
# Backup
|
||||
backup_retention_period = local.backup_retention_period
|
||||
preferred_backup_window = local.preferred_backup_window
|
||||
copy_tags_to_snapshot = true
|
||||
skip_final_snapshot = local.skip_final_snapshot
|
||||
final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.cluster_name}-final"
|
||||
|
||||
# Maintenance
|
||||
preferred_maintenance_window = local.preferred_maintenance_window
|
||||
apply_immediately = false
|
||||
|
||||
# Protection
|
||||
deletion_protection = local.deletion_protection
|
||||
|
||||
tags = { Name = local.cluster_name }
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
master_password, # Managed in Secrets Manager
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Aurora Serverless v2 Instance
|
||||
################################################################################
|
||||
|
||||
resource "aws_rds_cluster_instance" "main" {
|
||||
count = 1 # Add more for read replicas
|
||||
|
||||
identifier = "${local.cluster_name}-${count.index + 1}"
|
||||
cluster_identifier = aws_rds_cluster.main.id
|
||||
instance_class = "db.serverless" # Required for Serverless v2
|
||||
engine = local.engine
|
||||
engine_version = local.engine_version
|
||||
|
||||
# Performance Insights
|
||||
performance_insights_enabled = local.enable_performance_insights
|
||||
performance_insights_retention_period = local.enable_performance_insights ? local.performance_insights_retention : null
|
||||
|
||||
# Maintenance
|
||||
auto_minor_version_upgrade = local.auto_minor_version_upgrade
|
||||
|
||||
tags = { Name = "${local.cluster_name}-${count.index + 1}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role for IAM Authentication
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "db_connect" {
|
||||
count = local.enable_iam_auth ? 1 : 0
|
||||
name = "${local.cluster_name}-db-connect"
|
||||
description = "IAM authentication to ${local.cluster_name}"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowDBConnect"
|
||||
Effect = "Allow"
|
||||
Action = "rds-db:connect"
|
||||
Resource = "arn:aws:rds-db:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_rds_cluster.main.cluster_resource_id}/*"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.cluster_name}-db-connect" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data API Access Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "data_api" {
|
||||
count = local.enable_data_api ? 1 : 0
|
||||
name = "${local.cluster_name}-data-api"
|
||||
description = "Data API access to ${local.cluster_name}"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "ExecuteStatement"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"rds-data:ExecuteStatement",
|
||||
"rds-data:BatchExecuteStatement",
|
||||
"rds-data:BeginTransaction",
|
||||
"rds-data:CommitTransaction",
|
||||
"rds-data:RollbackTransaction"
|
||||
]
|
||||
Resource = aws_rds_cluster.main.arn
|
||||
},
|
||||
{
|
||||
Sid = "GetSecret"
|
||||
Effect = "Allow"
|
||||
Action = "secretsmanager:GetSecretValue"
|
||||
Resource = aws_secretsmanager_secret.db.arn
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.cluster_name}-data-api" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Alarms
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "cpu" {
|
||||
alarm_name = "${local.cluster_name}-cpu-high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "CPUUtilization"
|
||||
namespace = "AWS/RDS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 80
|
||||
alarm_description = "Aurora CPU > 80%"
|
||||
|
||||
dimensions = {
|
||||
DBClusterIdentifier = aws_rds_cluster.main.id
|
||||
}
|
||||
|
||||
tags = { Name = "${local.cluster_name}-cpu-high" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "connections" {
|
||||
alarm_name = "${local.cluster_name}-connections-high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "DatabaseConnections"
|
||||
namespace = "AWS/RDS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 100
|
||||
alarm_description = "Aurora connections > 100"
|
||||
|
||||
dimensions = {
|
||||
DBClusterIdentifier = aws_rds_cluster.main.id
|
||||
}
|
||||
|
||||
tags = { Name = "${local.cluster_name}-connections-high" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "capacity" {
|
||||
alarm_name = "${local.cluster_name}-acu-high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 3
|
||||
metric_name = "ServerlessDatabaseCapacity"
|
||||
namespace = "AWS/RDS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = local.max_capacity * 0.8
|
||||
alarm_description = "Aurora ACU > 80% of max"
|
||||
|
||||
dimensions = {
|
||||
DBClusterIdentifier = aws_rds_cluster.main.id
|
||||
}
|
||||
|
||||
tags = { Name = "${local.cluster_name}-acu-high" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "cluster_endpoint" {
|
||||
value = aws_rds_cluster.main.endpoint
|
||||
description = "Writer endpoint"
|
||||
}
|
||||
|
||||
output "reader_endpoint" {
|
||||
value = aws_rds_cluster.main.reader_endpoint
|
||||
description = "Reader endpoint"
|
||||
}
|
||||
|
||||
output "cluster_arn" {
|
||||
value = aws_rds_cluster.main.arn
|
||||
description = "Cluster ARN"
|
||||
}
|
||||
|
||||
output "cluster_id" {
|
||||
value = aws_rds_cluster.main.id
|
||||
description = "Cluster identifier"
|
||||
}
|
||||
|
||||
output "port" {
|
||||
value = local.port
|
||||
description = "Database port"
|
||||
}
|
||||
|
||||
output "database_name" {
|
||||
value = local.database_name
|
||||
description = "Database name"
|
||||
}
|
||||
|
||||
output "secret_arn" {
|
||||
value = aws_secretsmanager_secret.db.arn
|
||||
description = "Secrets Manager ARN"
|
||||
}
|
||||
|
||||
output "iam_auth_policy_arn" {
|
||||
value = length(aws_iam_policy.db_connect) > 0 ? aws_iam_policy.db_connect[0].arn : null
|
||||
description = "IAM policy for database authentication"
|
||||
}
|
||||
|
||||
output "data_api_policy_arn" {
|
||||
value = length(aws_iam_policy.data_api) > 0 ? aws_iam_policy.data_api[0].arn : null
|
||||
description = "IAM policy for Data API access"
|
||||
}
|
||||
|
||||
output "connection_string" {
|
||||
value = "${local.engine == "aurora-postgresql" ? "postgresql" : "mysql"}://${local.master_username}:****@${aws_rds_cluster.main.endpoint}:${local.port}/${local.database_name}"
|
||||
description = "Connection string template (password in Secrets Manager)"
|
||||
sensitive = false
|
||||
}
|
||||
|
||||
output "data_api_example" {
|
||||
value = local.enable_data_api ? <<-EOF
|
||||
aws rds-data execute-statement \
|
||||
--resource-arn '${aws_rds_cluster.main.arn}' \
|
||||
--secret-arn '${aws_secretsmanager_secret.db.arn}' \
|
||||
--database '${local.database_name}' \
|
||||
--sql 'SELECT NOW()'
|
||||
EOF
|
||||
: null
|
||||
description = "Data API example command"
|
||||
}
|
||||
|
||||
output "cost_estimate" {
|
||||
value = {
|
||||
acu_hour = "$0.12/ACU-hour"
|
||||
min_idle = "$${local.min_capacity * 0.12 * 24 * 30}/month (${local.min_capacity} ACU 24/7)"
|
||||
storage = "$0.10/GB-month"
|
||||
io = "$0.20/million requests"
|
||||
data_api = "$0.35/million Data API requests"
|
||||
}
|
||||
description = "Cost breakdown"
|
||||
}
|
||||
501
terraform/05-workloads/_template/cognito-auth/main.tf
Normal file
501
terraform/05-workloads/_template/cognito-auth/main.tf
Normal file
@@ -0,0 +1,501 @@
|
||||
################################################################################
|
||||
# Workload: Cognito User Pool
|
||||
#
|
||||
# User authentication infrastructure:
|
||||
# - User Pool with customizable password policy
|
||||
# - App clients (web, mobile, machine-to-machine)
|
||||
# - Identity Pool for AWS credential federation
|
||||
# - Social/SAML/OIDC identity providers
|
||||
# - Custom domain
|
||||
# - Lambda triggers (pre/post auth, migration)
|
||||
#
|
||||
# Use cases: Web/mobile auth, B2C apps, admin portals
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-auth/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
pool_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Email configuration
|
||||
email_sending_account = "COGNITO_DEFAULT" # COGNITO_DEFAULT or DEVELOPER
|
||||
ses_email_from = null # Required if DEVELOPER
|
||||
|
||||
# Password policy
|
||||
password_minimum_length = 12
|
||||
password_require_lowercase = true
|
||||
password_require_numbers = true
|
||||
password_require_symbols = true
|
||||
password_require_uppercase = true
|
||||
temporary_password_validity_days = 7
|
||||
|
||||
# MFA
|
||||
mfa_configuration = "OPTIONAL" # OFF, ON, OPTIONAL
|
||||
mfa_methods = ["SOFTWARE_TOKEN_MFA"] # SOFTWARE_TOKEN_MFA, SMS_MFA
|
||||
|
||||
# Account recovery
|
||||
recovery_mechanisms = [
|
||||
{ name = "verified_email", priority = 1 },
|
||||
{ name = "verified_phone_number", priority = 2 }
|
||||
]
|
||||
|
||||
# User attributes
|
||||
auto_verified_attributes = ["email"]
|
||||
username_attributes = ["email"] # email, phone_number
|
||||
alias_attributes = [] # email, phone_number, preferred_username
|
||||
|
||||
# Custom attributes
|
||||
custom_attributes = {
|
||||
# "tenant_id" = {
|
||||
# type = "String"
|
||||
# mutable = false
|
||||
# min_length = 1
|
||||
# max_length = 50
|
||||
# }
|
||||
}
|
||||
|
||||
# App clients
|
||||
app_clients = {
|
||||
web = {
|
||||
generate_secret = false
|
||||
explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
|
||||
supported_identity_providers = ["COGNITO"]
|
||||
callback_urls = ["https://example.com/callback"]
|
||||
logout_urls = ["https://example.com/logout"]
|
||||
allowed_oauth_flows = ["code"]
|
||||
allowed_oauth_scopes = ["email", "openid", "profile"]
|
||||
allowed_oauth_flows_user_pool_client = true
|
||||
access_token_validity = 60 # minutes
|
||||
id_token_validity = 60
|
||||
refresh_token_validity = 30 # days
|
||||
}
|
||||
mobile = {
|
||||
generate_secret = false
|
||||
explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
|
||||
supported_identity_providers = ["COGNITO"]
|
||||
callback_urls = ["myapp://callback"]
|
||||
logout_urls = ["myapp://logout"]
|
||||
allowed_oauth_flows = ["code"]
|
||||
allowed_oauth_scopes = ["email", "openid", "profile"]
|
||||
allowed_oauth_flows_user_pool_client = true
|
||||
access_token_validity = 60
|
||||
id_token_validity = 60
|
||||
refresh_token_validity = 30
|
||||
}
|
||||
# m2m = {
|
||||
# generate_secret = true
|
||||
# explicit_auth_flows = ["ALLOW_ADMIN_USER_PASSWORD_AUTH"]
|
||||
# supported_identity_providers = ["COGNITO"]
|
||||
# allowed_oauth_flows = ["client_credentials"]
|
||||
# allowed_oauth_scopes = ["api/read", "api/write"]
|
||||
# allowed_oauth_flows_user_pool_client = true
|
||||
# }
|
||||
}
|
||||
|
||||
# Custom domain (requires ACM cert in us-east-1 for CloudFront)
|
||||
custom_domain = null # e.g., "auth.example.com"
|
||||
custom_domain_cert = null # ACM certificate ARN
|
||||
hosted_zone_id = null
|
||||
|
||||
# Identity Pool (for AWS credential federation)
|
||||
enable_identity_pool = false
|
||||
|
||||
# Lambda triggers
|
||||
lambda_triggers = {
|
||||
# pre_sign_up = "arn:aws:lambda:..."
|
||||
# post_confirmation = "arn:aws:lambda:..."
|
||||
# pre_authentication = "arn:aws:lambda:..."
|
||||
# post_authentication = "arn:aws:lambda:..."
|
||||
# pre_token_generation = "arn:aws:lambda:..."
|
||||
# user_migration = "arn:aws:lambda:..."
|
||||
# custom_message = "arn:aws:lambda:..."
|
||||
}
|
||||
|
||||
# Social identity providers
|
||||
social_providers = {
|
||||
# google = {
|
||||
# client_id = "..."
|
||||
# client_secret = "..."
|
||||
# scopes = ["email", "profile", "openid"]
|
||||
# }
|
||||
# facebook = {
|
||||
# client_id = "..."
|
||||
# client_secret = "..."
|
||||
# scopes = ["email", "public_profile"]
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Cognito User Pool
|
||||
################################################################################
|
||||
|
||||
resource "aws_cognito_user_pool" "main" {
|
||||
name = local.pool_name
|
||||
|
||||
# Username configuration
|
||||
username_attributes = local.username_attributes
|
||||
alias_attributes = length(local.alias_attributes) > 0 ? local.alias_attributes : null
|
||||
auto_verified_attributes = local.auto_verified_attributes
|
||||
|
||||
# Password policy
|
||||
password_policy {
|
||||
minimum_length = local.password_minimum_length
|
||||
require_lowercase = local.password_require_lowercase
|
||||
require_numbers = local.password_require_numbers
|
||||
require_symbols = local.password_require_symbols
|
||||
require_uppercase = local.password_require_uppercase
|
||||
temporary_password_validity_days = local.temporary_password_validity_days
|
||||
}
|
||||
|
||||
# MFA
|
||||
mfa_configuration = local.mfa_configuration
|
||||
|
||||
dynamic "software_token_mfa_configuration" {
|
||||
for_each = contains(local.mfa_methods, "SOFTWARE_TOKEN_MFA") && local.mfa_configuration != "OFF" ? [1] : []
|
||||
content {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
# Account recovery
|
||||
account_recovery_setting {
|
||||
dynamic "recovery_mechanism" {
|
||||
for_each = local.recovery_mechanisms
|
||||
content {
|
||||
name = recovery_mechanism.value.name
|
||||
priority = recovery_mechanism.value.priority
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Email configuration
|
||||
email_configuration {
|
||||
email_sending_account = local.email_sending_account
|
||||
source_arn = local.email_sending_account == "DEVELOPER" ? local.ses_email_from : null
|
||||
}
|
||||
|
||||
# User attribute verification
|
||||
user_attribute_update_settings {
|
||||
attributes_require_verification_before_update = ["email"]
|
||||
}
|
||||
|
||||
# Admin create user config
|
||||
admin_create_user_config {
|
||||
allow_admin_create_user_only = false
|
||||
|
||||
invite_message_template {
|
||||
email_subject = "Your ${local.pool_name} account"
|
||||
email_message = "Your username is {username} and temporary password is {####}"
|
||||
sms_message = "Your username is {username} and temporary password is {####}"
|
||||
}
|
||||
}
|
||||
|
||||
# Verification message
|
||||
verification_message_template {
|
||||
default_email_option = "CONFIRM_WITH_CODE"
|
||||
email_subject = "Verify your email for ${local.pool_name}"
|
||||
email_message = "Your verification code is {####}"
|
||||
}
|
||||
|
||||
# Schema (custom attributes)
|
||||
dynamic "schema" {
|
||||
for_each = local.custom_attributes
|
||||
content {
|
||||
name = schema.key
|
||||
attribute_data_type = schema.value.type
|
||||
mutable = schema.value.mutable
|
||||
required = false
|
||||
developer_only_attribute = false
|
||||
|
||||
dynamic "string_attribute_constraints" {
|
||||
for_each = schema.value.type == "String" ? [1] : []
|
||||
content {
|
||||
min_length = lookup(schema.value, "min_length", 0)
|
||||
max_length = lookup(schema.value, "max_length", 2048)
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "number_attribute_constraints" {
|
||||
for_each = schema.value.type == "Number" ? [1] : []
|
||||
content {
|
||||
min_value = lookup(schema.value, "min_value", null)
|
||||
max_value = lookup(schema.value, "max_value", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Lambda triggers
|
||||
lambda_config {
|
||||
pre_sign_up = lookup(local.lambda_triggers, "pre_sign_up", null)
|
||||
post_confirmation = lookup(local.lambda_triggers, "post_confirmation", null)
|
||||
pre_authentication = lookup(local.lambda_triggers, "pre_authentication", null)
|
||||
post_authentication = lookup(local.lambda_triggers, "post_authentication", null)
|
||||
pre_token_generation = lookup(local.lambda_triggers, "pre_token_generation", null)
|
||||
user_migration = lookup(local.lambda_triggers, "user_migration", null)
|
||||
custom_message = lookup(local.lambda_triggers, "custom_message", null)
|
||||
}
|
||||
|
||||
tags = { Name = local.pool_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# User Pool Domain
|
||||
################################################################################
|
||||
|
||||
resource "aws_cognito_user_pool_domain" "main" {
|
||||
domain = local.custom_domain != null ? local.custom_domain : local.pool_name
|
||||
user_pool_id = aws_cognito_user_pool.main.id
|
||||
certificate_arn = local.custom_domain_cert
|
||||
}
|
||||
|
||||
# Route53 record for custom domain
|
||||
resource "aws_route53_record" "cognito" {
|
||||
count = local.custom_domain != null ? 1 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = local.custom_domain
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = aws_cognito_user_pool_domain.main.cloudfront_distribution_arn
|
||||
zone_id = "Z2FDTNDATAQYW2" # CloudFront zone ID
|
||||
evaluate_target_health = false
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# App Clients
|
||||
################################################################################
|
||||
|
||||
resource "aws_cognito_user_pool_client" "clients" {
|
||||
for_each = local.app_clients
|
||||
|
||||
name = "${local.pool_name}-${each.key}"
|
||||
user_pool_id = aws_cognito_user_pool.main.id
|
||||
|
||||
generate_secret = each.value.generate_secret
|
||||
explicit_auth_flows = each.value.explicit_auth_flows
|
||||
supported_identity_providers = each.value.supported_identity_providers
|
||||
callback_urls = lookup(each.value, "callback_urls", null)
|
||||
logout_urls = lookup(each.value, "logout_urls", null)
|
||||
allowed_oauth_flows = lookup(each.value, "allowed_oauth_flows", null)
|
||||
allowed_oauth_scopes = lookup(each.value, "allowed_oauth_scopes", null)
|
||||
allowed_oauth_flows_user_pool_client = lookup(each.value, "allowed_oauth_flows_user_pool_client", false)
|
||||
|
||||
access_token_validity = lookup(each.value, "access_token_validity", 60)
|
||||
id_token_validity = lookup(each.value, "id_token_validity", 60)
|
||||
refresh_token_validity = lookup(each.value, "refresh_token_validity", 30)
|
||||
|
||||
token_validity_units {
|
||||
access_token = "minutes"
|
||||
id_token = "minutes"
|
||||
refresh_token = "days"
|
||||
}
|
||||
|
||||
prevent_user_existence_errors = "ENABLED"
|
||||
enable_token_revocation = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Social Identity Providers
|
||||
################################################################################
|
||||
|
||||
resource "aws_cognito_identity_provider" "google" {
|
||||
count = contains(keys(local.social_providers), "google") ? 1 : 0
|
||||
user_pool_id = aws_cognito_user_pool.main.id
|
||||
provider_name = "Google"
|
||||
provider_type = "Google"
|
||||
|
||||
provider_details = {
|
||||
client_id = local.social_providers.google.client_id
|
||||
client_secret = local.social_providers.google.client_secret
|
||||
authorize_scopes = join(" ", local.social_providers.google.scopes)
|
||||
}
|
||||
|
||||
attribute_mapping = {
|
||||
email = "email"
|
||||
username = "sub"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_cognito_identity_provider" "facebook" {
|
||||
count = contains(keys(local.social_providers), "facebook") ? 1 : 0
|
||||
user_pool_id = aws_cognito_user_pool.main.id
|
||||
provider_name = "Facebook"
|
||||
provider_type = "Facebook"
|
||||
|
||||
provider_details = {
|
||||
client_id = local.social_providers.facebook.client_id
|
||||
client_secret = local.social_providers.facebook.client_secret
|
||||
authorize_scopes = join(",", local.social_providers.facebook.scopes)
|
||||
}
|
||||
|
||||
attribute_mapping = {
|
||||
email = "email"
|
||||
username = "id"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Identity Pool (Optional)
|
||||
################################################################################
|
||||
|
||||
resource "aws_cognito_identity_pool" "main" {
|
||||
count = local.enable_identity_pool ? 1 : 0
|
||||
identity_pool_name = replace(local.pool_name, "-", "_")
|
||||
allow_unauthenticated_identities = false
|
||||
|
||||
cognito_identity_providers {
|
||||
client_id = aws_cognito_user_pool_client.clients["web"].id
|
||||
provider_name = aws_cognito_user_pool.main.endpoint
|
||||
server_side_token_check = true
|
||||
}
|
||||
|
||||
tags = { Name = local.pool_name }
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "authenticated" {
|
||||
count = local.enable_identity_pool ? 1 : 0
|
||||
name = "${local.pool_name}-authenticated"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Federated = "cognito-identity.amazonaws.com"
|
||||
}
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.main[0].id
|
||||
}
|
||||
"ForAnyValue:StringLike" = {
|
||||
"cognito-identity.amazonaws.com:amr" = "authenticated"
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.pool_name}-authenticated" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "authenticated" {
|
||||
count = local.enable_identity_pool ? 1 : 0
|
||||
name = "authenticated-policy"
|
||||
role = aws_iam_role.authenticated[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"mobileanalytics:PutEvents",
|
||||
"cognito-sync:*",
|
||||
"cognito-identity:*"
|
||||
]
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_cognito_identity_pool_roles_attachment" "main" {
|
||||
count = local.enable_identity_pool ? 1 : 0
|
||||
identity_pool_id = aws_cognito_identity_pool.main[0].id
|
||||
|
||||
roles = {
|
||||
authenticated = aws_iam_role.authenticated[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "user_pool_id" {
|
||||
value = aws_cognito_user_pool.main.id
|
||||
}
|
||||
|
||||
output "user_pool_arn" {
|
||||
value = aws_cognito_user_pool.main.arn
|
||||
}
|
||||
|
||||
output "user_pool_endpoint" {
|
||||
value = aws_cognito_user_pool.main.endpoint
|
||||
}
|
||||
|
||||
output "user_pool_domain" {
|
||||
value = local.custom_domain != null ? "https://${local.custom_domain}" : "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"
|
||||
}
|
||||
|
||||
output "client_ids" {
|
||||
value = { for k, v in aws_cognito_user_pool_client.clients : k => v.id }
|
||||
}
|
||||
|
||||
output "identity_pool_id" {
|
||||
value = local.enable_identity_pool ? aws_cognito_identity_pool.main[0].id : null
|
||||
}
|
||||
|
||||
output "hosted_ui_url" {
|
||||
value = "${local.custom_domain != null ? "https://${local.custom_domain}" : "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"}/login?client_id=${aws_cognito_user_pool_client.clients["web"].id}&response_type=code&redirect_uri=${urlencode(local.app_clients.web.callback_urls[0])}"
|
||||
}
|
||||
439
terraform/05-workloads/_template/dynamodb-table/main.tf
Normal file
439
terraform/05-workloads/_template/dynamodb-table/main.tf
Normal file
@@ -0,0 +1,439 @@
|
||||
################################################################################
|
||||
# Workload: DynamoDB Table
|
||||
#
|
||||
# Deploys a NoSQL database table:
|
||||
# - On-demand or provisioned capacity
|
||||
# - Encryption at rest with KMS
|
||||
# - Point-in-time recovery
|
||||
# - TTL support
|
||||
# - Global Secondary Indexes
|
||||
# - Streams for event-driven patterns
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<table-name>/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-table/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
table_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Capacity mode: "PAY_PER_REQUEST" (on-demand) or "PROVISIONED"
|
||||
billing_mode = "PAY_PER_REQUEST"
|
||||
|
||||
# Provisioned capacity (only used if billing_mode = "PROVISIONED")
|
||||
read_capacity = 5
|
||||
write_capacity = 5
|
||||
|
||||
# Auto-scaling for provisioned mode
|
||||
enable_autoscaling = local.billing_mode == "PROVISIONED"
|
||||
autoscaling_min_read = 5
|
||||
autoscaling_max_read = 100
|
||||
autoscaling_min_write = 5
|
||||
autoscaling_max_write = 100
|
||||
autoscaling_target_utilization = 70
|
||||
|
||||
# Primary key
|
||||
hash_key = "pk" # Partition key
|
||||
hash_key_type = "S" # S = String, N = Number, B = Binary
|
||||
range_key = "sk" # Sort key (optional, set to null to disable)
|
||||
range_key_type = "S"
|
||||
|
||||
# TTL (set to null to disable)
|
||||
ttl_attribute = "ttl"
|
||||
|
||||
# Streams (set to null to disable)
|
||||
# Options: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES
|
||||
stream_view_type = null
|
||||
|
||||
# Point-in-time recovery
|
||||
point_in_time_recovery = true
|
||||
|
||||
# Global Secondary Indexes (GSI)
|
||||
global_secondary_indexes = [
|
||||
# {
|
||||
# name = "gsi1"
|
||||
# hash_key = "gsi1pk"
|
||||
# range_key = "gsi1sk"
|
||||
# projection_type = "ALL" # ALL, KEYS_ONLY, or INCLUDE
|
||||
# non_key_attributes = [] # Only for INCLUDE
|
||||
# }
|
||||
]
|
||||
|
||||
# Local Secondary Indexes (LSI) - must be defined at table creation
|
||||
local_secondary_indexes = [
|
||||
# {
|
||||
# name = "lsi1"
|
||||
# range_key = "lsi1sk"
|
||||
# projection_type = "ALL"
|
||||
# non_key_attributes = []
|
||||
# }
|
||||
]
|
||||
|
||||
# Table class: STANDARD or STANDARD_INFREQUENT_ACCESS
|
||||
table_class = "STANDARD"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "table" {
|
||||
description = "KMS key for ${local.table_name} DynamoDB encryption"
|
||||
deletion_window_in_days = 7
|
||||
enable_key_rotation = true
|
||||
|
||||
tags = { Name = "${local.table_name}-dynamodb" }
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "table" {
|
||||
name = "alias/${local.table_name}-dynamodb"
|
||||
target_key_id = aws_kms_key.table.key_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DynamoDB Table
|
||||
################################################################################
|
||||
|
||||
resource "aws_dynamodb_table" "main" {
|
||||
name = local.table_name
|
||||
billing_mode = local.billing_mode
|
||||
table_class = local.table_class
|
||||
|
||||
# Capacity (only for PROVISIONED)
|
||||
read_capacity = local.billing_mode == "PROVISIONED" ? local.read_capacity : null
|
||||
write_capacity = local.billing_mode == "PROVISIONED" ? local.write_capacity : null
|
||||
|
||||
# Primary key
|
||||
hash_key = local.hash_key
|
||||
range_key = local.range_key
|
||||
|
||||
# Key schema
|
||||
attribute {
|
||||
name = local.hash_key
|
||||
type = local.hash_key_type
|
||||
}
|
||||
|
||||
dynamic "attribute" {
|
||||
for_each = local.range_key != null ? [1] : []
|
||||
content {
|
||||
name = local.range_key
|
||||
type = local.range_key_type
|
||||
}
|
||||
}
|
||||
|
||||
# GSI attributes
|
||||
dynamic "attribute" {
|
||||
for_each = local.global_secondary_indexes
|
||||
content {
|
||||
name = attribute.value.hash_key
|
||||
type = "S"
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "attribute" {
|
||||
for_each = [for gsi in local.global_secondary_indexes : gsi if gsi.range_key != null]
|
||||
content {
|
||||
name = attribute.value.range_key
|
||||
type = "S"
|
||||
}
|
||||
}
|
||||
|
||||
# LSI attributes
|
||||
dynamic "attribute" {
|
||||
for_each = local.local_secondary_indexes
|
||||
content {
|
||||
name = attribute.value.range_key
|
||||
type = "S"
|
||||
}
|
||||
}
|
||||
|
||||
# Global Secondary Indexes
|
||||
dynamic "global_secondary_index" {
|
||||
for_each = local.global_secondary_indexes
|
||||
content {
|
||||
name = global_secondary_index.value.name
|
||||
hash_key = global_secondary_index.value.hash_key
|
||||
range_key = lookup(global_secondary_index.value, "range_key", null)
|
||||
projection_type = global_secondary_index.value.projection_type
|
||||
non_key_attributes = global_secondary_index.value.projection_type == "INCLUDE" ? global_secondary_index.value.non_key_attributes : null
|
||||
|
||||
# Capacity for provisioned mode
|
||||
read_capacity = local.billing_mode == "PROVISIONED" ? local.read_capacity : null
|
||||
write_capacity = local.billing_mode == "PROVISIONED" ? local.write_capacity : null
|
||||
}
|
||||
}
|
||||
|
||||
# Local Secondary Indexes
|
||||
dynamic "local_secondary_index" {
|
||||
for_each = local.local_secondary_indexes
|
||||
content {
|
||||
name = local_secondary_index.value.name
|
||||
range_key = local_secondary_index.value.range_key
|
||||
projection_type = local_secondary_index.value.projection_type
|
||||
non_key_attributes = local_secondary_index.value.projection_type == "INCLUDE" ? local_secondary_index.value.non_key_attributes : null
|
||||
}
|
||||
}
|
||||
|
||||
# TTL
|
||||
dynamic "ttl" {
|
||||
for_each = local.ttl_attribute != null ? [1] : []
|
||||
content {
|
||||
attribute_name = local.ttl_attribute
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
# Streams
|
||||
stream_enabled = local.stream_view_type != null
|
||||
stream_view_type = local.stream_view_type
|
||||
|
||||
# Encryption
|
||||
server_side_encryption {
|
||||
enabled = true
|
||||
kms_key_arn = aws_kms_key.table.arn
|
||||
}
|
||||
|
||||
# Point-in-time recovery
|
||||
point_in_time_recovery {
|
||||
enabled = local.point_in_time_recovery
|
||||
}
|
||||
|
||||
# Deletion protection for prod
|
||||
deletion_protection_enabled = local.env == "prod"
|
||||
|
||||
tags = {
|
||||
Name = local.table_name
|
||||
Backup = "true"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = false # Set to true for production
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Auto Scaling (Provisioned Mode Only)
|
||||
################################################################################
|
||||
|
||||
resource "aws_appautoscaling_target" "read" {
|
||||
count = local.enable_autoscaling ? 1 : 0
|
||||
max_capacity = local.autoscaling_max_read
|
||||
min_capacity = local.autoscaling_min_read
|
||||
resource_id = "table/${aws_dynamodb_table.main.name}"
|
||||
scalable_dimension = "dynamodb:table:ReadCapacityUnits"
|
||||
service_namespace = "dynamodb"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "read" {
|
||||
count = local.enable_autoscaling ? 1 : 0
|
||||
name = "${local.table_name}-read-autoscaling"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.read[0].resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.read[0].scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.read[0].service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "DynamoDBReadCapacityUtilization"
|
||||
}
|
||||
target_value = local.autoscaling_target_utilization
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_target" "write" {
|
||||
count = local.enable_autoscaling ? 1 : 0
|
||||
max_capacity = local.autoscaling_max_write
|
||||
min_capacity = local.autoscaling_min_write
|
||||
resource_id = "table/${aws_dynamodb_table.main.name}"
|
||||
scalable_dimension = "dynamodb:table:WriteCapacityUnits"
|
||||
service_namespace = "dynamodb"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "write" {
|
||||
count = local.enable_autoscaling ? 1 : 0
|
||||
name = "${local.table_name}-write-autoscaling"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.write[0].resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.write[0].scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.write[0].service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "DynamoDBWriteCapacityUtilization"
|
||||
}
|
||||
target_value = local.autoscaling_target_utilization
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Alarms
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "throttled_requests" {
|
||||
alarm_name = "${local.table_name}-throttled-requests"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "ThrottledRequests"
|
||||
namespace = "AWS/DynamoDB"
|
||||
period = 300
|
||||
statistic = "Sum"
|
||||
threshold = 0
|
||||
alarm_description = "DynamoDB throttled requests detected"
|
||||
|
||||
dimensions = {
|
||||
TableName = aws_dynamodb_table.main.name
|
||||
}
|
||||
|
||||
tags = { Name = "${local.table_name}-throttled" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "system_errors" {
|
||||
alarm_name = "${local.table_name}-system-errors"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "SystemErrors"
|
||||
namespace = "AWS/DynamoDB"
|
||||
period = 300
|
||||
statistic = "Sum"
|
||||
threshold = 0
|
||||
alarm_description = "DynamoDB system errors detected"
|
||||
|
||||
dimensions = {
|
||||
TableName = aws_dynamodb_table.main.name
|
||||
}
|
||||
|
||||
tags = { Name = "${local.table_name}-errors" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Policy Document (for application access)
|
||||
################################################################################
|
||||
|
||||
data "aws_iam_policy_document" "table_access" {
|
||||
statement {
|
||||
sid = "AllowTableOperations"
|
||||
effect = "Allow"
|
||||
|
||||
actions = [
|
||||
"dynamodb:BatchGetItem",
|
||||
"dynamodb:BatchWriteItem",
|
||||
"dynamodb:DeleteItem",
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:Query",
|
||||
"dynamodb:Scan",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:DescribeTable",
|
||||
]
|
||||
|
||||
resources = [
|
||||
aws_dynamodb_table.main.arn,
|
||||
"${aws_dynamodb_table.main.arn}/index/*",
|
||||
]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "AllowKMSDecrypt"
|
||||
effect = "Allow"
|
||||
|
||||
actions = [
|
||||
"kms:Decrypt",
|
||||
"kms:Encrypt",
|
||||
"kms:GenerateDataKey",
|
||||
]
|
||||
|
||||
resources = [aws_kms_key.table.arn]
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "table_name" {
|
||||
value = aws_dynamodb_table.main.name
|
||||
}
|
||||
|
||||
output "table_arn" {
|
||||
value = aws_dynamodb_table.main.arn
|
||||
}
|
||||
|
||||
output "table_id" {
|
||||
value = aws_dynamodb_table.main.id
|
||||
}
|
||||
|
||||
output "stream_arn" {
|
||||
value = aws_dynamodb_table.main.stream_arn
|
||||
}
|
||||
|
||||
output "kms_key_arn" {
|
||||
value = aws_kms_key.table.arn
|
||||
}
|
||||
|
||||
output "access_policy_json" {
|
||||
value = data.aws_iam_policy_document.table_access.json
|
||||
description = "IAM policy document for application access to this table"
|
||||
}
|
||||
400
terraform/05-workloads/_template/ecr-repository/main.tf
Normal file
400
terraform/05-workloads/_template/ecr-repository/main.tf
Normal file
@@ -0,0 +1,400 @@
|
||||
################################################################################
|
||||
# Workload: ECR Repository
|
||||
#
|
||||
# Container registry with:
|
||||
# - Image scanning on push
|
||||
# - Lifecycle policies (cleanup old images)
|
||||
# - Cross-account access
|
||||
# - Replication to other regions
|
||||
# - Immutable tags (optional)
|
||||
#
|
||||
# Use cases: Docker images, Lambda container images
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-ecr/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
# Multiple repositories can be created
|
||||
repositories = {
|
||||
api = {
|
||||
description = "API service container"
|
||||
}
|
||||
worker = {
|
||||
description = "Background worker container"
|
||||
}
|
||||
# Add more as needed
|
||||
}
|
||||
|
||||
# Image scanning
|
||||
scan_on_push = true
|
||||
|
||||
# Tag immutability (prevents overwriting tags)
|
||||
image_tag_mutability = "MUTABLE" # MUTABLE or IMMUTABLE
|
||||
|
||||
# Encryption
|
||||
encryption_type = "AES256" # AES256 or KMS
|
||||
kms_key_arn = null # Set if using KMS
|
||||
|
||||
# Lifecycle policy - cleanup old images
|
||||
lifecycle_policy = {
|
||||
# Keep last N tagged images
|
||||
keep_tagged_count = 30
|
||||
|
||||
# Delete untagged images older than N days
|
||||
untagged_expiry_days = 7
|
||||
|
||||
# Keep images with these tag prefixes forever
|
||||
keep_tag_prefixes = ["release-", "v"]
|
||||
}
|
||||
|
||||
# Cross-account access (account IDs that can pull)
|
||||
pull_access_accounts = [
|
||||
# "123456789012", # Dev account
|
||||
# "234567890123", # Staging account
|
||||
]
|
||||
|
||||
# Cross-account push access
|
||||
push_access_accounts = [
|
||||
# "345678901234", # CI/CD account
|
||||
]
|
||||
|
||||
# IAM principals with pull access
|
||||
pull_access_principals = [
|
||||
# "arn:aws:iam::123456789012:role/ecs-task-role",
|
||||
]
|
||||
|
||||
# Replication to other regions
|
||||
replication_regions = [
|
||||
# "us-west-2",
|
||||
# "eu-west-1",
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# ECR Repositories
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecr_repository" "repos" {
|
||||
for_each = local.repositories
|
||||
|
||||
name = "${local.tenant}/${local.name}/${each.key}"
|
||||
image_tag_mutability = local.image_tag_mutability
|
||||
|
||||
image_scanning_configuration {
|
||||
scan_on_push = local.scan_on_push
|
||||
}
|
||||
|
||||
encryption_configuration {
|
||||
encryption_type = local.encryption_type
|
||||
kms_key = local.kms_key_arn
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${local.tenant}/${local.name}/${each.key}"
|
||||
Description = each.value.description
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Lifecycle Policies
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecr_lifecycle_policy" "repos" {
|
||||
for_each = local.repositories
|
||||
repository = aws_ecr_repository.repos[each.key].name
|
||||
|
||||
policy = jsonencode({
|
||||
rules = [
|
||||
# Keep tagged images with specific prefixes
|
||||
{
|
||||
rulePriority = 1
|
||||
description = "Keep release images"
|
||||
selection = {
|
||||
tagStatus = "tagged"
|
||||
tagPrefixList = local.lifecycle_policy.keep_tag_prefixes
|
||||
countType = "imageCountMoreThan"
|
||||
countNumber = 9999
|
||||
}
|
||||
action = {
|
||||
type = "expire"
|
||||
}
|
||||
},
|
||||
# Keep last N tagged images
|
||||
{
|
||||
rulePriority = 10
|
||||
description = "Keep last ${local.lifecycle_policy.keep_tagged_count} tagged images"
|
||||
selection = {
|
||||
tagStatus = "tagged"
|
||||
tagPrefixList = [""]
|
||||
countType = "imageCountMoreThan"
|
||||
countNumber = local.lifecycle_policy.keep_tagged_count
|
||||
}
|
||||
action = {
|
||||
type = "expire"
|
||||
}
|
||||
},
|
||||
# Delete old untagged images
|
||||
{
|
||||
rulePriority = 20
|
||||
description = "Delete untagged images older than ${local.lifecycle_policy.untagged_expiry_days} days"
|
||||
selection = {
|
||||
tagStatus = "untagged"
|
||||
countType = "sinceImagePushed"
|
||||
countUnit = "days"
|
||||
countNumber = local.lifecycle_policy.untagged_expiry_days
|
||||
}
|
||||
action = {
|
||||
type = "expire"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Repository Policies (Cross-Account Access)
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecr_repository_policy" "repos" {
|
||||
for_each = length(local.pull_access_accounts) > 0 || length(local.push_access_accounts) > 0 || length(local.pull_access_principals) > 0 ? local.repositories : {}
|
||||
repository = aws_ecr_repository.repos[each.key].name
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
# Cross-account pull access
|
||||
length(local.pull_access_accounts) > 0 ? [{
|
||||
Sid = "CrossAccountPull"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for acct in local.pull_access_accounts : "arn:aws:iam::${acct}:root"]
|
||||
}
|
||||
Action = [
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:BatchCheckLayerAvailability"
|
||||
]
|
||||
}] : [],
|
||||
|
||||
# Cross-account push access
|
||||
length(local.push_access_accounts) > 0 ? [{
|
||||
Sid = "CrossAccountPush"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for acct in local.push_access_accounts : "arn:aws:iam::${acct}:root"]
|
||||
}
|
||||
Action = [
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:BatchCheckLayerAvailability",
|
||||
"ecr:PutImage",
|
||||
"ecr:InitiateLayerUpload",
|
||||
"ecr:UploadLayerPart",
|
||||
"ecr:CompleteLayerUpload"
|
||||
]
|
||||
}] : [],
|
||||
|
||||
# Principal-based pull access
|
||||
length(local.pull_access_principals) > 0 ? [{
|
||||
Sid = "PrincipalPull"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = local.pull_access_principals
|
||||
}
|
||||
Action = [
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:BatchCheckLayerAvailability"
|
||||
]
|
||||
}] : []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Replication Configuration
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecr_replication_configuration" "main" {
|
||||
count = length(local.replication_regions) > 0 ? 1 : 0
|
||||
|
||||
replication_configuration {
|
||||
rule {
|
||||
dynamic "destination" {
|
||||
for_each = local.replication_regions
|
||||
content {
|
||||
region = destination.value
|
||||
registry_id = data.aws_caller_identity.current.account_id
|
||||
}
|
||||
}
|
||||
|
||||
repository_filter {
|
||||
filter = "${local.tenant}/${local.name}/"
|
||||
filter_type = "PREFIX_MATCH"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Policy for CI/CD
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "push" {
|
||||
name = "${local.tenant}-${local.name}-ecr-push"
|
||||
description = "Push access to ${local.tenant}/${local.name} ECR repositories"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "GetAuthToken"
|
||||
Effect = "Allow"
|
||||
Action = "ecr:GetAuthorizationToken"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "PushImages"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ecr:BatchCheckLayerAvailability",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:PutImage",
|
||||
"ecr:InitiateLayerUpload",
|
||||
"ecr:UploadLayerPart",
|
||||
"ecr:CompleteLayerUpload"
|
||||
]
|
||||
Resource = [for repo in aws_ecr_repository.repos : repo.arn]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}-ecr-push" }
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "pull" {
|
||||
name = "${local.tenant}-${local.name}-ecr-pull"
|
||||
description = "Pull access to ${local.tenant}/${local.name} ECR repositories"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "GetAuthToken"
|
||||
Effect = "Allow"
|
||||
Action = "ecr:GetAuthorizationToken"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "PullImages"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ecr:BatchCheckLayerAvailability",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"ecr:BatchGetImage"
|
||||
]
|
||||
Resource = [for repo in aws_ecr_repository.repos : repo.arn]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}-ecr-pull" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "repository_urls" {
|
||||
value = { for k, v in aws_ecr_repository.repos : k => v.repository_url }
|
||||
description = "Repository URLs for docker push/pull"
|
||||
}
|
||||
|
||||
output "repository_arns" {
|
||||
value = { for k, v in aws_ecr_repository.repos : k => v.arn }
|
||||
description = "Repository ARNs"
|
||||
}
|
||||
|
||||
output "push_policy_arn" {
|
||||
value = aws_iam_policy.push.arn
|
||||
description = "IAM policy ARN for push access"
|
||||
}
|
||||
|
||||
output "pull_policy_arn" {
|
||||
value = aws_iam_policy.pull.arn
|
||||
description = "IAM policy ARN for pull access"
|
||||
}
|
||||
|
||||
output "docker_login_command" {
|
||||
value = "aws ecr get-login-password --region ${data.aws_region.current.name} | docker login --username AWS --password-stdin ${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.name}.amazonaws.com"
|
||||
description = "Command to authenticate Docker with ECR"
|
||||
}
|
||||
|
||||
output "push_commands" {
|
||||
value = { for k, v in aws_ecr_repository.repos : k => <<-EOF
|
||||
docker build -t ${v.repository_url}:latest .
|
||||
docker push ${v.repository_url}:latest
|
||||
EOF
|
||||
}
|
||||
description = "Docker build and push commands for each repository"
|
||||
}
|
||||
701
terraform/05-workloads/_template/ecs-fargate/main.tf
Normal file
701
terraform/05-workloads/_template/ecs-fargate/main.tf
Normal file
@@ -0,0 +1,701 @@
|
||||
################################################################################
|
||||
# Workload: ECS Fargate Service
|
||||
#
|
||||
# Container service with:
|
||||
# - Fargate (serverless containers)
|
||||
# - Auto-scaling
|
||||
# - ALB integration
|
||||
# - Service discovery
|
||||
# - Secrets/SSM integration
|
||||
# - CloudWatch logging
|
||||
# - X-Ray tracing
|
||||
#
|
||||
# Use cases: Web services, APIs, microservices
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-ecs/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
prefix = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Container configuration
|
||||
container = {
|
||||
image = "nginx:latest" # Update to your ECR image
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
# Resources (Fargate valid combinations)
|
||||
# CPU: 256, 512, 1024, 2048, 4096
|
||||
# Memory depends on CPU
|
||||
cpu = 256
|
||||
memory = 512
|
||||
|
||||
# Health check
|
||||
health_check_path = "/health"
|
||||
health_check_interval = 30
|
||||
|
||||
# Environment variables
|
||||
environment = {
|
||||
LOG_LEVEL = "info"
|
||||
NODE_ENV = local.env
|
||||
}
|
||||
|
||||
# Secrets from SSM Parameter Store
|
||||
secrets_ssm = {
|
||||
# DATABASE_URL = "/${local.tenant}/${local.env}/${local.name}/database/url"
|
||||
}
|
||||
|
||||
# Secrets from Secrets Manager
|
||||
secrets_sm = {
|
||||
# API_KEY = "myapp/api-key"
|
||||
}
|
||||
}
|
||||
|
||||
# Service configuration
|
||||
service = {
|
||||
desired_count = 2
|
||||
min_count = 1
|
||||
max_count = 10
|
||||
|
||||
# Deployment
|
||||
deployment_max_percent = 200
|
||||
deployment_min_healthy_percent = 100
|
||||
|
||||
# Enable execute command (for debugging)
|
||||
enable_execute_command = true
|
||||
}
|
||||
|
||||
# Network (get from remote state or hardcode)
|
||||
vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id
|
||||
private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
public_subnet_ids = [] # data.terraform_remote_state.network.outputs.public_subnet_ids
|
||||
|
||||
# Load balancer
|
||||
alb = {
|
||||
enabled = true
|
||||
internal = false
|
||||
certificate_arn = "" # ACM certificate ARN for HTTPS
|
||||
health_check_path = local.container.health_check_path
|
||||
}
|
||||
|
||||
# Auto-scaling
|
||||
autoscaling = {
|
||||
enabled = true
|
||||
|
||||
# CPU-based scaling
|
||||
cpu_target = 70
|
||||
|
||||
# Request count scaling (if ALB)
|
||||
requests_target = 1000 # requests per target per minute
|
||||
|
||||
# Scale-in cooldown
|
||||
scale_in_cooldown = 300
|
||||
scale_out_cooldown = 60
|
||||
}
|
||||
|
||||
# Service discovery (Cloud Map)
|
||||
service_discovery = {
|
||||
enabled = false
|
||||
namespace_id = "" # Cloud Map namespace ID
|
||||
dns_ttl = 10
|
||||
}
|
||||
|
||||
# Logging
|
||||
log_retention_days = 30
|
||||
|
||||
# X-Ray tracing
|
||||
enable_xray = false
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# ECS Cluster
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecs_cluster" "main" {
|
||||
name = local.prefix
|
||||
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
}
|
||||
|
||||
resource "aws_ecs_cluster_capacity_providers" "main" {
|
||||
cluster_name = aws_ecs_cluster.main.name
|
||||
|
||||
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
|
||||
|
||||
default_capacity_provider_strategy {
|
||||
base = 1
|
||||
weight = 100
|
||||
capacity_provider = "FARGATE"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Logs
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "app" {
|
||||
name = "/ecs/${local.prefix}"
|
||||
retention_in_days = local.log_retention_days
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Roles
|
||||
################################################################################
|
||||
|
||||
# Task execution role (ECS agent)
|
||||
resource "aws_iam_role" "execution" {
|
||||
name = "${local.prefix}-execution"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.prefix}-execution" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "execution" {
|
||||
role = aws_iam_role.execution.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "execution_secrets" {
|
||||
name = "secrets-access"
|
||||
role = aws_iam_role.execution.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "SSMParameters"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ssm:GetParameters",
|
||||
"ssm:GetParameter"
|
||||
]
|
||||
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${local.tenant}/*"
|
||||
},
|
||||
{
|
||||
Sid = "SecretsManager"
|
||||
Effect = "Allow"
|
||||
Action = "secretsmanager:GetSecretValue"
|
||||
Resource = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${local.tenant}/*"
|
||||
},
|
||||
{
|
||||
Sid = "KMSDecrypt"
|
||||
Effect = "Allow"
|
||||
Action = "kms:Decrypt"
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# Task role (application)
|
||||
resource "aws_iam_role" "task" {
|
||||
name = "${local.prefix}-task"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.prefix}-task" }
|
||||
}
|
||||
|
||||
# Allow ECS exec
|
||||
resource "aws_iam_role_policy" "task_exec" {
|
||||
count = local.service.enable_execute_command ? 1 : 0
|
||||
name = "ecs-exec"
|
||||
role = aws_iam_role.task.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ssmmessages:CreateControlChannel",
|
||||
"ssmmessages:CreateDataChannel",
|
||||
"ssmmessages:OpenControlChannel",
|
||||
"ssmmessages:OpenDataChannel"
|
||||
]
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# X-Ray tracing
|
||||
resource "aws_iam_role_policy" "task_xray" {
|
||||
count = local.enable_xray ? 1 : 0
|
||||
name = "xray"
|
||||
role = aws_iam_role.task.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"xray:PutTraceSegments",
|
||||
"xray:PutTelemetryRecords",
|
||||
"xray:GetSamplingRules",
|
||||
"xray:GetSamplingTargets",
|
||||
"xray:GetSamplingStatisticSummaries"
|
||||
]
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Groups
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "service" {
|
||||
count = length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = "${local.prefix}-service"
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "From ALB"
|
||||
from_port = local.container.port
|
||||
to_port = local.container.port
|
||||
protocol = "tcp"
|
||||
security_groups = local.alb.enabled ? [aws_security_group.alb[0].id] : []
|
||||
cidr_blocks = local.alb.enabled ? [] : ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.prefix}-service" }
|
||||
}
|
||||
|
||||
resource "aws_security_group" "alb" {
|
||||
count = local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = "${local.prefix}-alb"
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTPS"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTP redirect"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "To service"
|
||||
from_port = local.container.port
|
||||
to_port = local.container.port
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.prefix}-alb" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Application Load Balancer
|
||||
################################################################################
|
||||
|
||||
resource "aws_lb" "main" {
|
||||
count = local.alb.enabled && length(local.public_subnet_ids) > 0 ? 1 : 0
|
||||
name = local.prefix
|
||||
internal = local.alb.internal
|
||||
load_balancer_type = "application"
|
||||
security_groups = [aws_security_group.alb[0].id]
|
||||
subnets = local.alb.internal ? local.private_subnet_ids : local.public_subnet_ids
|
||||
|
||||
enable_deletion_protection = local.env == "prod"
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "main" {
|
||||
count = local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = local.prefix
|
||||
port = local.container.port
|
||||
protocol = "HTTP"
|
||||
vpc_id = local.vpc_id
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
enabled = true
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 3
|
||||
timeout = 5
|
||||
interval = local.container.health_check_interval
|
||||
path = local.alb.health_check_path
|
||||
matcher = "200-299"
|
||||
}
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "https" {
|
||||
count = local.alb.enabled && length(local.alb.certificate_arn) > 0 && length(local.public_subnet_ids) > 0 ? 1 : 0
|
||||
load_balancer_arn = aws_lb.main[0].arn
|
||||
port = 443
|
||||
protocol = "HTTPS"
|
||||
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
|
||||
certificate_arn = local.alb.certificate_arn
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.main[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "http_redirect" {
|
||||
count = local.alb.enabled && length(local.public_subnet_ids) > 0 ? 1 : 0
|
||||
load_balancer_arn = aws_lb.main[0].arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = length(local.alb.certificate_arn) > 0 ? "redirect" : "forward"
|
||||
|
||||
dynamic "redirect" {
|
||||
for_each = length(local.alb.certificate_arn) > 0 ? [1] : []
|
||||
content {
|
||||
port = "443"
|
||||
protocol = "HTTPS"
|
||||
status_code = "HTTP_301"
|
||||
}
|
||||
}
|
||||
|
||||
target_group_arn = length(local.alb.certificate_arn) > 0 ? null : aws_lb_target_group.main[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Task Definition
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecs_task_definition" "main" {
|
||||
family = local.prefix
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
cpu = local.container.cpu
|
||||
memory = local.container.memory
|
||||
execution_role_arn = aws_iam_role.execution.arn
|
||||
task_role_arn = aws_iam_role.task.arn
|
||||
|
||||
container_definitions = jsonencode(concat(
|
||||
[{
|
||||
name = "app"
|
||||
image = local.container.image
|
||||
essential = true
|
||||
|
||||
portMappings = [{
|
||||
containerPort = local.container.port
|
||||
protocol = "tcp"
|
||||
}]
|
||||
|
||||
environment = [
|
||||
for k, v in local.container.environment : { name = k, value = v }
|
||||
]
|
||||
|
||||
secrets = concat(
|
||||
[for k, v in local.container.secrets_ssm : {
|
||||
name = k
|
||||
valueFrom = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${v}"
|
||||
}],
|
||||
[for k, v in local.container.secrets_sm : {
|
||||
name = k
|
||||
valueFrom = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${v}"
|
||||
}]
|
||||
)
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
"awslogs-group" = aws_cloudwatch_log_group.app.name
|
||||
"awslogs-region" = data.aws_region.current.name
|
||||
"awslogs-stream-prefix" = "app"
|
||||
}
|
||||
}
|
||||
|
||||
healthCheck = {
|
||||
command = ["CMD-SHELL", "curl -f http://localhost:${local.container.port}${local.container.health_check_path} || exit 1"]
|
||||
interval = 30
|
||||
timeout = 5
|
||||
retries = 3
|
||||
startPeriod = 60
|
||||
}
|
||||
}],
|
||||
local.enable_xray ? [{
|
||||
name = "xray-daemon"
|
||||
image = "amazon/aws-xray-daemon:latest"
|
||||
essential = false
|
||||
cpu = 32
|
||||
memory = 256
|
||||
portMappings = [{
|
||||
containerPort = 2000
|
||||
protocol = "udp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
"awslogs-group" = aws_cloudwatch_log_group.app.name
|
||||
"awslogs-region" = data.aws_region.current.name
|
||||
"awslogs-stream-prefix" = "xray"
|
||||
}
|
||||
}
|
||||
}] : []
|
||||
))
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ECS Service
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecs_service" "main" {
|
||||
count = length(local.vpc_id) > 0 && length(local.private_subnet_ids) > 0 ? 1 : 0
|
||||
name = local.prefix
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.main.arn
|
||||
desired_count = local.service.desired_count
|
||||
launch_type = "FARGATE"
|
||||
|
||||
deployment_maximum_percent = local.service.deployment_max_percent
|
||||
deployment_minimum_healthy_percent = local.service.deployment_min_healthy_percent
|
||||
enable_execute_command = local.service.enable_execute_command
|
||||
|
||||
network_configuration {
|
||||
subnets = local.private_subnet_ids
|
||||
security_groups = [aws_security_group.service[0].id]
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
dynamic "load_balancer" {
|
||||
for_each = local.alb.enabled ? [1] : []
|
||||
content {
|
||||
target_group_arn = aws_lb_target_group.main[0].arn
|
||||
container_name = "app"
|
||||
container_port = local.container.port
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "service_registries" {
|
||||
for_each = local.service_discovery.enabled ? [1] : []
|
||||
content {
|
||||
registry_arn = aws_service_discovery_service.main[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [desired_count]
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Auto Scaling
|
||||
################################################################################
|
||||
|
||||
resource "aws_appautoscaling_target" "main" {
|
||||
count = local.autoscaling.enabled && length(local.vpc_id) > 0 ? 1 : 0
|
||||
max_capacity = local.service.max_count
|
||||
min_capacity = local.service.min_count
|
||||
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main[0].name}"
|
||||
scalable_dimension = "ecs:service:DesiredCount"
|
||||
service_namespace = "ecs"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "cpu" {
|
||||
count = local.autoscaling.enabled && length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = "${local.prefix}-cpu"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.main[0].resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.main[0].service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ECSServiceAverageCPUUtilization"
|
||||
}
|
||||
target_value = local.autoscaling.cpu_target
|
||||
scale_in_cooldown = local.autoscaling.scale_in_cooldown
|
||||
scale_out_cooldown = local.autoscaling.scale_out_cooldown
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "requests" {
|
||||
count = local.autoscaling.enabled && local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = "${local.prefix}-requests"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.main[0].resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.main[0].service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ALBRequestCountPerTarget"
|
||||
resource_label = "${aws_lb.main[0].arn_suffix}/${aws_lb_target_group.main[0].arn_suffix}"
|
||||
}
|
||||
target_value = local.autoscaling.requests_target
|
||||
scale_in_cooldown = local.autoscaling.scale_in_cooldown
|
||||
scale_out_cooldown = local.autoscaling.scale_out_cooldown
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Service Discovery
|
||||
################################################################################
|
||||
|
||||
resource "aws_service_discovery_service" "main" {
|
||||
count = local.service_discovery.enabled ? 1 : 0
|
||||
name = local.name
|
||||
|
||||
dns_config {
|
||||
namespace_id = local.service_discovery.namespace_id
|
||||
|
||||
dns_records {
|
||||
ttl = local.service_discovery.dns_ttl
|
||||
type = "A"
|
||||
}
|
||||
|
||||
routing_policy = "MULTIVALUE"
|
||||
}
|
||||
|
||||
health_check_custom_config {
|
||||
failure_threshold = 1
|
||||
}
|
||||
|
||||
tags = { Name = local.prefix }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "cluster_name" {
|
||||
value = aws_ecs_cluster.main.name
|
||||
description = "ECS cluster name"
|
||||
}
|
||||
|
||||
output "service_name" {
|
||||
value = length(aws_ecs_service.main) > 0 ? aws_ecs_service.main[0].name : null
|
||||
description = "ECS service name"
|
||||
}
|
||||
|
||||
output "alb_dns_name" {
|
||||
value = length(aws_lb.main) > 0 ? aws_lb.main[0].dns_name : null
|
||||
description = "ALB DNS name"
|
||||
}
|
||||
|
||||
output "alb_zone_id" {
|
||||
value = length(aws_lb.main) > 0 ? aws_lb.main[0].zone_id : null
|
||||
description = "ALB hosted zone ID (for Route53 alias)"
|
||||
}
|
||||
|
||||
output "task_definition_arn" {
|
||||
value = aws_ecs_task_definition.main.arn
|
||||
description = "Task definition ARN"
|
||||
}
|
||||
|
||||
output "log_group" {
|
||||
value = aws_cloudwatch_log_group.app.name
|
||||
description = "CloudWatch log group"
|
||||
}
|
||||
|
||||
output "exec_command" {
|
||||
value = length(aws_ecs_service.main) > 0 ? "aws ecs execute-command --cluster ${aws_ecs_cluster.main.name} --task <task-id> --container app --interactive --command '/bin/sh'" : null
|
||||
description = "ECS exec command for debugging"
|
||||
}
|
||||
|
||||
output "update_command" {
|
||||
value = length(aws_ecs_service.main) > 0 ? "aws ecs update-service --cluster ${aws_ecs_cluster.main.name} --service ${aws_ecs_service.main[0].name} --force-new-deployment" : null
|
||||
description = "Force new deployment command"
|
||||
}
|
||||
560
terraform/05-workloads/_template/ecs-service/main.tf
Normal file
560
terraform/05-workloads/_template/ecs-service/main.tf
Normal file
@@ -0,0 +1,560 @@
|
||||
################################################################################
|
||||
# Workload: ECS Fargate Service
|
||||
#
|
||||
# Deploys a containerized application on ECS Fargate:
|
||||
# - ECS Service with Fargate launch type
|
||||
# - Application Load Balancer (optional)
|
||||
# - Auto-scaling based on CPU/Memory
|
||||
# - CloudWatch logging
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<app>/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<APP>/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
app = "<APP>"
|
||||
env = "prod" # prod, staging, dev
|
||||
name = "${local.tenant}-${local.app}-${local.env}"
|
||||
|
||||
# Short name for resources with strict limits (ALB: 32 chars, TG: 32 chars)
|
||||
# Uses first 10 chars of tenant + first 10 of app + env suffix
|
||||
short_name = "${substr(local.tenant, 0, min(10, length(local.tenant)))}-${substr(local.app, 0, min(10, length(local.app)))}-${substr(local.env, 0, 4)}"
|
||||
|
||||
# Container config
|
||||
container_image = "nginx:latest" # Replace with your ECR image
|
||||
container_port = 8080
|
||||
cpu = 256 # 0.25 vCPU
|
||||
memory = 512 # MB
|
||||
|
||||
# Scaling
|
||||
desired_count = 2
|
||||
min_count = 1
|
||||
max_count = 10
|
||||
|
||||
# Load balancer
|
||||
enable_alb = true
|
||||
health_check_path = "/health"
|
||||
|
||||
# Environment variables (non-sensitive)
|
||||
environment = {
|
||||
APP_ENV = local.env
|
||||
LOG_LEVEL = "info"
|
||||
}
|
||||
|
||||
# Secrets from SSM/Secrets Manager (ARNs)
|
||||
secrets = {
|
||||
# DATABASE_URL = "arn:aws:secretsmanager:us-east-1:123456789:secret:mydb-xxx"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.app
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "network" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "02-network/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "tenant" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "04-tenants/${local.tenant}/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "bootstrap" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "00-bootstrap/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# ECS Cluster
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecs_cluster" "main" {
|
||||
name = local.name
|
||||
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_ecs_cluster_capacity_providers" "main" {
|
||||
cluster_name = aws_ecs_cluster.main.name
|
||||
|
||||
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
|
||||
|
||||
default_capacity_provider_strategy {
|
||||
base = 1
|
||||
weight = 100
|
||||
capacity_provider = "FARGATE"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "main" {
|
||||
name = "/ecs/${local.name}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM - Task Execution Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "execution" {
|
||||
name = "${local.name}-execution"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-execution" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "execution" {
|
||||
role = aws_iam_role.execution.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "execution_secrets" {
|
||||
count = length(local.secrets) > 0 ? 1 : 0
|
||||
name = "secrets-access"
|
||||
role = aws_iam_role.execution.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
Resource = values(local.secrets)
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["ssm:GetParameters"]
|
||||
Resource = values(local.secrets)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM - Task Role (app permissions)
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "task" {
|
||||
name = "${local.name}-task"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-task" }
|
||||
}
|
||||
|
||||
# Add app-specific permissions here
|
||||
resource "aws_iam_role_policy" "task" {
|
||||
name = "app-permissions"
|
||||
role = aws_iam_role.task.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowTaggedResources"
|
||||
Effect = "Allow"
|
||||
Action = ["s3:GetObject", "s3:PutObject", "dynamodb:*"]
|
||||
Resource = "*"
|
||||
Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Task Definition
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecs_task_definition" "main" {
|
||||
family = local.name
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
cpu = local.cpu
|
||||
memory = local.memory
|
||||
execution_role_arn = aws_iam_role.execution.arn
|
||||
task_role_arn = aws_iam_role.task.arn
|
||||
|
||||
container_definitions = jsonencode([
|
||||
{
|
||||
name = local.app
|
||||
image = local.container_image
|
||||
essential = true
|
||||
|
||||
portMappings = [{
|
||||
containerPort = local.container_port
|
||||
protocol = "tcp"
|
||||
}]
|
||||
|
||||
environment = [
|
||||
for k, v in local.environment : { name = k, value = v }
|
||||
]
|
||||
|
||||
secrets = [
|
||||
for k, v in local.secrets : { name = k, valueFrom = v }
|
||||
]
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.main.name
|
||||
awslogs-region = data.aws_region.current.name
|
||||
awslogs-stream-prefix = "ecs"
|
||||
}
|
||||
}
|
||||
|
||||
healthCheck = {
|
||||
command = ["CMD-SHELL", "curl -f http://localhost:${local.container_port}${local.health_check_path} || exit 1"]
|
||||
interval = 30
|
||||
timeout = 5
|
||||
retries = 3
|
||||
startPeriod = 60
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group - Service
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "service" {
|
||||
name = "${local.name}-service"
|
||||
description = "ECS service for ${local.name}"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "Allow all outbound traffic"
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-service" }
|
||||
}
|
||||
|
||||
# Separate ingress rules to handle conditional ALB
|
||||
resource "aws_security_group_rule" "service_from_alb" {
|
||||
count = local.enable_alb ? 1 : 0
|
||||
type = "ingress"
|
||||
from_port = local.container_port
|
||||
to_port = local.container_port
|
||||
protocol = "tcp"
|
||||
source_security_group_id = aws_security_group.alb[0].id
|
||||
security_group_id = aws_security_group.service.id
|
||||
description = "From ALB"
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "service_self" {
|
||||
count = local.enable_alb ? 0 : 1
|
||||
type = "ingress"
|
||||
from_port = local.container_port
|
||||
to_port = local.container_port
|
||||
protocol = "tcp"
|
||||
self = true
|
||||
security_group_id = aws_security_group.service.id
|
||||
description = "Self-referencing for service mesh"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ALB
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "alb" {
|
||||
count = local.enable_alb ? 1 : 0
|
||||
name = "${local.name}-alb"
|
||||
description = "ALB for ${local.name}"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTPS"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTP"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-alb" }
|
||||
}
|
||||
|
||||
resource "aws_lb" "main" {
|
||||
count = local.enable_alb ? 1 : 0
|
||||
name = local.short_name # ALB names max 32 chars
|
||||
internal = false
|
||||
load_balancer_type = "application"
|
||||
security_groups = [aws_security_group.alb[0].id]
|
||||
subnets = data.terraform_remote_state.network.outputs.public_subnet_ids
|
||||
|
||||
# Security: Drop invalid headers
|
||||
drop_invalid_header_fields = true
|
||||
|
||||
# Access logging for audit trail
|
||||
access_logs {
|
||||
bucket = data.terraform_remote_state.bootstrap.outputs.logs_bucket
|
||||
prefix = "alb/${local.name}"
|
||||
enabled = true
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "main" {
|
||||
count = local.enable_alb ? 1 : 0
|
||||
name = local.short_name # Target group names max 32 chars
|
||||
port = local.container_port
|
||||
protocol = "HTTP"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
enabled = true
|
||||
healthy_threshold = 2
|
||||
interval = 30
|
||||
matcher = "200"
|
||||
path = local.health_check_path
|
||||
port = "traffic-port"
|
||||
timeout = 5
|
||||
unhealthy_threshold = 3
|
||||
}
|
||||
|
||||
# Enable stickiness for stateful apps (disabled by default)
|
||||
stickiness {
|
||||
type = "lb_cookie"
|
||||
enabled = false
|
||||
cookie_duration = 86400
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "http" {
|
||||
count = local.enable_alb ? 1 : 0
|
||||
load_balancer_arn = aws_lb.main[0].arn
|
||||
port = "80"
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.main[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ECS Service
|
||||
################################################################################
|
||||
|
||||
resource "aws_ecs_service" "main" {
|
||||
name = local.app
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.main.arn
|
||||
desired_count = local.desired_count
|
||||
launch_type = "FARGATE"
|
||||
|
||||
network_configuration {
|
||||
subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
security_groups = [aws_security_group.service.id, data.terraform_remote_state.tenant.outputs.security_groups.base]
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
dynamic "load_balancer" {
|
||||
for_each = local.enable_alb ? [1] : []
|
||||
content {
|
||||
target_group_arn = aws_lb_target_group.main[0].arn
|
||||
container_name = local.app
|
||||
container_port = local.container_port
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [desired_count] # Managed by auto-scaling
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Auto Scaling
|
||||
################################################################################
|
||||
|
||||
resource "aws_appautoscaling_target" "ecs" {
|
||||
max_capacity = local.max_count
|
||||
min_capacity = local.min_count
|
||||
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}"
|
||||
scalable_dimension = "ecs:service:DesiredCount"
|
||||
service_namespace = "ecs"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "cpu" {
|
||||
name = "${local.name}-cpu"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.ecs.resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.ecs.service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ECSServiceAverageCPUUtilization"
|
||||
}
|
||||
target_value = 70
|
||||
scale_in_cooldown = 300
|
||||
scale_out_cooldown = 60
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "memory" {
|
||||
name = "${local.name}-memory"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.ecs.resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.ecs.service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
|
||||
}
|
||||
target_value = 80
|
||||
scale_in_cooldown = 300
|
||||
scale_out_cooldown = 60
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "cluster_name" {
|
||||
value = aws_ecs_cluster.main.name
|
||||
}
|
||||
|
||||
output "service_name" {
|
||||
value = aws_ecs_service.main.name
|
||||
}
|
||||
|
||||
output "alb_dns_name" {
|
||||
value = local.enable_alb ? aws_lb.main[0].dns_name : null
|
||||
}
|
||||
|
||||
output "alb_zone_id" {
|
||||
value = local.enable_alb ? aws_lb.main[0].zone_id : null
|
||||
}
|
||||
|
||||
output "log_group" {
|
||||
value = aws_cloudwatch_log_group.main.name
|
||||
}
|
||||
|
||||
output "task_role_arn" {
|
||||
value = aws_iam_role.task.arn
|
||||
}
|
||||
968
terraform/05-workloads/_template/eks-cluster/main.tf
Normal file
968
terraform/05-workloads/_template/eks-cluster/main.tf
Normal file
@@ -0,0 +1,968 @@
|
||||
################################################################################
|
||||
# Workload: EKS Cluster
|
||||
#
|
||||
# Deploys a managed Kubernetes cluster:
|
||||
# - EKS cluster with managed node groups
|
||||
# - Core addons (VPC CNI, CoreDNS, kube-proxy)
|
||||
# - IRSA (IAM Roles for Service Accounts)
|
||||
# - Cluster Autoscaler ready
|
||||
# - AWS Load Balancer Controller ready
|
||||
# - Optional Fargate profiles
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-eks/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-eks/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
env = "prod" # prod, staging, dev
|
||||
name = "${local.tenant}-${local.env}"
|
||||
|
||||
# EKS Version
|
||||
cluster_version = "1.29"
|
||||
|
||||
# Node Groups
|
||||
node_groups = {
|
||||
general = {
|
||||
instance_types = ["t3.medium"]
|
||||
capacity_type = "ON_DEMAND" # ON_DEMAND or SPOT
|
||||
min_size = 2
|
||||
max_size = 10
|
||||
desired_size = 2
|
||||
disk_size = 50
|
||||
labels = {
|
||||
role = "general"
|
||||
}
|
||||
taints = []
|
||||
}
|
||||
# Uncomment for spot instances
|
||||
# spot = {
|
||||
# instance_types = ["t3.medium", "t3.large", "t3a.medium"]
|
||||
# capacity_type = "SPOT"
|
||||
# min_size = 0
|
||||
# max_size = 20
|
||||
# desired_size = 0
|
||||
# disk_size = 50
|
||||
# labels = {
|
||||
# role = "spot"
|
||||
# }
|
||||
# taints = [{
|
||||
# key = "spot"
|
||||
# value = "true"
|
||||
# effect = "NO_SCHEDULE"
|
||||
# }]
|
||||
# }
|
||||
}
|
||||
|
||||
# Fargate (for serverless pods)
|
||||
enable_fargate = false
|
||||
fargate_namespaces = ["serverless"] # Namespaces to run on Fargate
|
||||
|
||||
# Addons
|
||||
enable_cluster_autoscaler = true
|
||||
enable_aws_lb_controller = true
|
||||
enable_ebs_csi_driver = true
|
||||
enable_metrics_server = true
|
||||
|
||||
# Logging
|
||||
cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
|
||||
log_retention_days = 30
|
||||
|
||||
# Access
|
||||
cluster_endpoint_public = true
|
||||
cluster_endpoint_private = true
|
||||
public_access_cidrs = ["0.0.0.0/0"] # Restrict in production!
|
||||
|
||||
# Admin access (IAM ARNs that can access cluster)
|
||||
admin_arns = [
|
||||
# "arn:aws:iam::123456789012:role/Admin",
|
||||
# "arn:aws:iam::123456789012:user/admin",
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "network" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "02-network/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "tenant" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "04-tenants/${local.tenant}/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key for Secrets Encryption
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "eks" {
|
||||
description = "EKS Secret Encryption Key for ${local.name}"
|
||||
deletion_window_in_days = 7
|
||||
enable_key_rotation = true
|
||||
|
||||
tags = { Name = "${local.name}-eks" }
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "eks" {
|
||||
name = "alias/${local.name}-eks"
|
||||
target_key_id = aws_kms_key.eks.key_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "eks" {
|
||||
name = "/aws/eks/${local.name}/cluster"
|
||||
retention_in_days = local.log_retention_days
|
||||
|
||||
tags = { Name = "${local.name}-eks" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM - Cluster Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "cluster" {
|
||||
name = "${local.name}-eks-cluster"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "eks.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-eks-cluster" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "cluster_policy" {
|
||||
role = aws_iam_role.cluster.name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSClusterPolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "cluster_vpc_policy" {
|
||||
role = aws_iam_role.cluster.name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSVPCResourceController"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM - Node Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "node" {
|
||||
name = "${local.name}-eks-node"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "ec2.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-eks-node" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "node_policy" {
|
||||
role = aws_iam_role.node.name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSWorkerNodePolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "node_cni_policy" {
|
||||
role = aws_iam_role.node.name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKS_CNI_Policy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "node_ecr_policy" {
|
||||
role = aws_iam_role.node.name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "node_ssm_policy" {
|
||||
role = aws_iam_role.node.name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM - Fargate Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "fargate" {
|
||||
count = local.enable_fargate ? 1 : 0
|
||||
name = "${local.name}-eks-fargate"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "eks-fargate-pods.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-eks-fargate" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "fargate_policy" {
|
||||
count = local.enable_fargate ? 1 : 0
|
||||
role = aws_iam_role.fargate[0].name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Groups
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "cluster" {
|
||||
name = "${local.name}-eks-cluster"
|
||||
description = "EKS cluster security group"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
|
||||
tags = { Name = "${local.name}-eks-cluster" }
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "cluster_egress" {
|
||||
type = "egress"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
security_group_id = aws_security_group.cluster.id
|
||||
description = "Allow all outbound"
|
||||
}
|
||||
|
||||
resource "aws_security_group" "node" {
|
||||
name = "${local.name}-eks-node"
|
||||
description = "EKS node security group"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
|
||||
tags = {
|
||||
Name = "${local.name}-eks-node"
|
||||
"kubernetes.io/cluster/${local.name}" = "owned"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "node_egress" {
|
||||
type = "egress"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
security_group_id = aws_security_group.node.id
|
||||
description = "Allow all outbound"
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "node_ingress_self" {
|
||||
type = "ingress"
|
||||
from_port = 0
|
||||
to_port = 65535
|
||||
protocol = "-1"
|
||||
source_security_group_id = aws_security_group.node.id
|
||||
security_group_id = aws_security_group.node.id
|
||||
description = "Node to node"
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "node_ingress_cluster" {
|
||||
type = "ingress"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
source_security_group_id = aws_security_group.cluster.id
|
||||
security_group_id = aws_security_group.node.id
|
||||
description = "Cluster to node (webhooks)"
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "node_ingress_cluster_kubelet" {
|
||||
type = "ingress"
|
||||
from_port = 10250
|
||||
to_port = 10250
|
||||
protocol = "tcp"
|
||||
source_security_group_id = aws_security_group.cluster.id
|
||||
security_group_id = aws_security_group.node.id
|
||||
description = "Cluster to node (kubelet)"
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "cluster_ingress_node" {
|
||||
type = "ingress"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
source_security_group_id = aws_security_group.node.id
|
||||
security_group_id = aws_security_group.cluster.id
|
||||
description = "Node to cluster API"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EKS Cluster
|
||||
################################################################################
|
||||
|
||||
resource "aws_eks_cluster" "main" {
|
||||
name = local.name
|
||||
version = local.cluster_version
|
||||
role_arn = aws_iam_role.cluster.arn
|
||||
|
||||
vpc_config {
|
||||
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
endpoint_private_access = local.cluster_endpoint_private
|
||||
endpoint_public_access = local.cluster_endpoint_public
|
||||
public_access_cidrs = local.public_access_cidrs
|
||||
security_group_ids = [aws_security_group.cluster.id]
|
||||
}
|
||||
|
||||
encryption_config {
|
||||
provider {
|
||||
key_arn = aws_kms_key.eks.arn
|
||||
}
|
||||
resources = ["secrets"]
|
||||
}
|
||||
|
||||
enabled_cluster_log_types = local.cluster_log_types
|
||||
|
||||
depends_on = [
|
||||
aws_iam_role_policy_attachment.cluster_policy,
|
||||
aws_iam_role_policy_attachment.cluster_vpc_policy,
|
||||
aws_cloudwatch_log_group.eks,
|
||||
]
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EKS Addons
|
||||
################################################################################
|
||||
|
||||
resource "aws_eks_addon" "vpc_cni" {
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
addon_name = "vpc-cni"
|
||||
|
||||
resolve_conflicts_on_create = "OVERWRITE"
|
||||
resolve_conflicts_on_update = "OVERWRITE"
|
||||
|
||||
tags = { Name = "${local.name}-vpc-cni" }
|
||||
}
|
||||
|
||||
resource "aws_eks_addon" "coredns" {
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
addon_name = "coredns"
|
||||
|
||||
resolve_conflicts_on_create = "OVERWRITE"
|
||||
resolve_conflicts_on_update = "OVERWRITE"
|
||||
|
||||
depends_on = [aws_eks_node_group.main]
|
||||
|
||||
tags = { Name = "${local.name}-coredns" }
|
||||
}
|
||||
|
||||
resource "aws_eks_addon" "kube_proxy" {
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
addon_name = "kube-proxy"
|
||||
|
||||
resolve_conflicts_on_create = "OVERWRITE"
|
||||
resolve_conflicts_on_update = "OVERWRITE"
|
||||
|
||||
tags = { Name = "${local.name}-kube-proxy" }
|
||||
}
|
||||
|
||||
resource "aws_eks_addon" "ebs_csi" {
|
||||
count = local.enable_ebs_csi_driver ? 1 : 0
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
addon_name = "aws-ebs-csi-driver"
|
||||
|
||||
service_account_role_arn = aws_iam_role.ebs_csi[0].arn
|
||||
|
||||
resolve_conflicts_on_create = "OVERWRITE"
|
||||
resolve_conflicts_on_update = "OVERWRITE"
|
||||
|
||||
depends_on = [aws_eks_node_group.main]
|
||||
|
||||
tags = { Name = "${local.name}-ebs-csi" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Node Groups
|
||||
################################################################################
|
||||
|
||||
resource "aws_eks_node_group" "main" {
|
||||
for_each = local.node_groups
|
||||
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
node_group_name = "${local.name}-${each.key}"
|
||||
node_role_arn = aws_iam_role.node.arn
|
||||
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
|
||||
instance_types = each.value.instance_types
|
||||
capacity_type = each.value.capacity_type
|
||||
disk_size = each.value.disk_size
|
||||
|
||||
scaling_config {
|
||||
min_size = each.value.min_size
|
||||
max_size = each.value.max_size
|
||||
desired_size = each.value.desired_size
|
||||
}
|
||||
|
||||
update_config {
|
||||
max_unavailable = 1
|
||||
}
|
||||
|
||||
labels = merge(each.value.labels, {
|
||||
Tenant = local.tenant
|
||||
})
|
||||
|
||||
dynamic "taint" {
|
||||
for_each = each.value.taints
|
||||
content {
|
||||
key = taint.value.key
|
||||
value = taint.value.value
|
||||
effect = taint.value.effect
|
||||
}
|
||||
}
|
||||
|
||||
# Launch template for security hardening
|
||||
launch_template {
|
||||
id = aws_launch_template.node[each.key].id
|
||||
version = aws_launch_template.node[each.key].latest_version
|
||||
}
|
||||
|
||||
depends_on = [
|
||||
aws_iam_role_policy_attachment.node_policy,
|
||||
aws_iam_role_policy_attachment.node_cni_policy,
|
||||
aws_iam_role_policy_attachment.node_ecr_policy,
|
||||
]
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [scaling_config[0].desired_size]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-${each.key}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Launch Template for Node Security Hardening
|
||||
################################################################################
|
||||
|
||||
resource "aws_launch_template" "node" {
|
||||
for_each = local.node_groups
|
||||
|
||||
name = "${local.name}-${each.key}"
|
||||
|
||||
# IMDSv2 enforcement - critical security control
|
||||
metadata_options {
|
||||
http_endpoint = "enabled"
|
||||
http_tokens = "required" # Enforces IMDSv2
|
||||
http_put_response_hop_limit = 1 # Prevent container credential theft
|
||||
instance_metadata_tags = "enabled"
|
||||
}
|
||||
|
||||
# EBS encryption
|
||||
block_device_mappings {
|
||||
device_name = "/dev/xvda"
|
||||
|
||||
ebs {
|
||||
volume_size = each.value.disk_size
|
||||
volume_type = "gp3"
|
||||
encrypted = true
|
||||
delete_on_termination = true
|
||||
}
|
||||
}
|
||||
|
||||
# Monitoring
|
||||
monitoring {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
tag_specifications {
|
||||
resource_type = "instance"
|
||||
tags = {
|
||||
Name = "${local.name}-${each.key}"
|
||||
Tenant = local.tenant
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-${each.key}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Fargate Profiles
|
||||
################################################################################
|
||||
|
||||
resource "aws_eks_fargate_profile" "main" {
|
||||
for_each = local.enable_fargate ? toset(local.fargate_namespaces) : []
|
||||
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
fargate_profile_name = "${local.name}-${each.key}"
|
||||
pod_execution_role_arn = aws_iam_role.fargate[0].arn
|
||||
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
|
||||
selector {
|
||||
namespace = each.key
|
||||
labels = {
|
||||
Tenant = local.tenant
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-${each.key}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# OIDC Provider for IRSA
|
||||
################################################################################
|
||||
|
||||
data "tls_certificate" "eks" {
|
||||
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
|
||||
}
|
||||
|
||||
resource "aws_iam_openid_connect_provider" "eks" {
|
||||
client_id_list = ["sts.amazonaws.com"]
|
||||
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
|
||||
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
|
||||
|
||||
tags = { Name = "${local.name}-eks-oidc" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IRSA - EBS CSI Driver
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "ebs_csi" {
|
||||
count = local.enable_ebs_csi_driver ? 1 : 0
|
||||
name = "${local.name}-ebs-csi"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = {
|
||||
Federated = aws_iam_openid_connect_provider.eks.arn
|
||||
}
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:ebs-csi-controller-sa"
|
||||
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-ebs-csi" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "ebs_csi" {
|
||||
count = local.enable_ebs_csi_driver ? 1 : 0
|
||||
role = aws_iam_role.ebs_csi[0].name
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IRSA - Cluster Autoscaler
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "cluster_autoscaler" {
|
||||
count = local.enable_cluster_autoscaler ? 1 : 0
|
||||
name = "${local.name}-cluster-autoscaler"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = {
|
||||
Federated = aws_iam_openid_connect_provider.eks.arn
|
||||
}
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:cluster-autoscaler"
|
||||
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-cluster-autoscaler" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "cluster_autoscaler" {
|
||||
count = local.enable_cluster_autoscaler ? 1 : 0
|
||||
name = "cluster-autoscaler"
|
||||
role = aws_iam_role.cluster_autoscaler[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"autoscaling:DescribeAutoScalingGroups",
|
||||
"autoscaling:DescribeAutoScalingInstances",
|
||||
"autoscaling:DescribeLaunchConfigurations",
|
||||
"autoscaling:DescribeScalingActivities",
|
||||
"autoscaling:DescribeTags",
|
||||
"ec2:DescribeInstanceTypes",
|
||||
"ec2:DescribeLaunchTemplateVersions",
|
||||
"ec2:DescribeImages",
|
||||
"ec2:GetInstanceTypesFromInstanceRequirements",
|
||||
"eks:DescribeNodegroup"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"autoscaling:SetDesiredCapacity",
|
||||
"autoscaling:TerminateInstanceInAutoScalingGroup"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"autoscaling:ResourceTag/k8s.io/cluster-autoscaler/${local.name}" = "owned"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IRSA - AWS Load Balancer Controller
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "lb_controller" {
|
||||
count = local.enable_aws_lb_controller ? 1 : 0
|
||||
name = "${local.name}-aws-lb-controller"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Principal = {
|
||||
Federated = aws_iam_openid_connect_provider.eks.arn
|
||||
}
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:aws-load-balancer-controller"
|
||||
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-aws-lb-controller" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "lb_controller" {
|
||||
count = local.enable_aws_lb_controller ? 1 : 0
|
||||
name = "aws-lb-controller"
|
||||
role = aws_iam_role.lb_controller[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["iam:CreateServiceLinkedRole"]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"iam:AWSServiceName" = "elasticloadbalancing.amazonaws.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ec2:DescribeAccountAttributes",
|
||||
"ec2:DescribeAddresses",
|
||||
"ec2:DescribeAvailabilityZones",
|
||||
"ec2:DescribeInternetGateways",
|
||||
"ec2:DescribeVpcs",
|
||||
"ec2:DescribeVpcPeeringConnections",
|
||||
"ec2:DescribeSubnets",
|
||||
"ec2:DescribeSecurityGroups",
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeNetworkInterfaces",
|
||||
"ec2:DescribeTags",
|
||||
"ec2:GetCoipPoolUsage",
|
||||
"ec2:DescribeCoipPools",
|
||||
"elasticloadbalancing:DescribeLoadBalancers",
|
||||
"elasticloadbalancing:DescribeLoadBalancerAttributes",
|
||||
"elasticloadbalancing:DescribeListeners",
|
||||
"elasticloadbalancing:DescribeListenerCertificates",
|
||||
"elasticloadbalancing:DescribeSSLPolicies",
|
||||
"elasticloadbalancing:DescribeRules",
|
||||
"elasticloadbalancing:DescribeTargetGroups",
|
||||
"elasticloadbalancing:DescribeTargetGroupAttributes",
|
||||
"elasticloadbalancing:DescribeTargetHealth",
|
||||
"elasticloadbalancing:DescribeTags"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"cognito-idp:DescribeUserPoolClient",
|
||||
"acm:ListCertificates",
|
||||
"acm:DescribeCertificate",
|
||||
"iam:ListServerCertificates",
|
||||
"iam:GetServerCertificate",
|
||||
"waf-regional:GetWebACL",
|
||||
"waf-regional:GetWebACLForResource",
|
||||
"waf-regional:AssociateWebACL",
|
||||
"waf-regional:DisassociateWebACL",
|
||||
"wafv2:GetWebACL",
|
||||
"wafv2:GetWebACLForResource",
|
||||
"wafv2:AssociateWebACL",
|
||||
"wafv2:DisassociateWebACL",
|
||||
"shield:GetSubscriptionState",
|
||||
"shield:DescribeProtection",
|
||||
"shield:CreateProtection",
|
||||
"shield:DeleteProtection"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ec2:AuthorizeSecurityGroupIngress",
|
||||
"ec2:RevokeSecurityGroupIngress",
|
||||
"ec2:CreateSecurityGroup",
|
||||
"ec2:CreateTags",
|
||||
"ec2:DeleteTags",
|
||||
"ec2:DeleteSecurityGroup"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"elasticloadbalancing:CreateLoadBalancer",
|
||||
"elasticloadbalancing:CreateTargetGroup"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
Null = {
|
||||
"aws:RequestTag/elbv2.k8s.aws/cluster" = "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"elasticloadbalancing:AddTags",
|
||||
"elasticloadbalancing:RemoveTags"
|
||||
]
|
||||
Resource = [
|
||||
"arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:targetgroup/*/*",
|
||||
"arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:loadbalancer/net/*/*",
|
||||
"arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:loadbalancer/app/*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"elasticloadbalancing:ModifyLoadBalancerAttributes",
|
||||
"elasticloadbalancing:SetIpAddressType",
|
||||
"elasticloadbalancing:SetSecurityGroups",
|
||||
"elasticloadbalancing:SetSubnets",
|
||||
"elasticloadbalancing:DeleteLoadBalancer",
|
||||
"elasticloadbalancing:ModifyTargetGroup",
|
||||
"elasticloadbalancing:ModifyTargetGroupAttributes",
|
||||
"elasticloadbalancing:DeleteTargetGroup",
|
||||
"elasticloadbalancing:RegisterTargets",
|
||||
"elasticloadbalancing:DeregisterTargets",
|
||||
"elasticloadbalancing:CreateListener",
|
||||
"elasticloadbalancing:DeleteListener",
|
||||
"elasticloadbalancing:CreateRule",
|
||||
"elasticloadbalancing:ModifyRule",
|
||||
"elasticloadbalancing:DeleteRule",
|
||||
"elasticloadbalancing:SetWebAcl",
|
||||
"elasticloadbalancing:ModifyListener",
|
||||
"elasticloadbalancing:AddListenerCertificates",
|
||||
"elasticloadbalancing:RemoveListenerCertificates"
|
||||
]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EKS Access Entries (K8s 1.29+)
|
||||
################################################################################
|
||||
|
||||
resource "aws_eks_access_entry" "admins" {
|
||||
for_each = toset(local.admin_arns)
|
||||
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
principal_arn = each.value
|
||||
type = "STANDARD"
|
||||
}
|
||||
|
||||
resource "aws_eks_access_policy_association" "admins" {
|
||||
for_each = toset(local.admin_arns)
|
||||
|
||||
cluster_name = aws_eks_cluster.main.name
|
||||
principal_arn = each.value
|
||||
policy_arn = "arn:${data.aws_partition.current.partition}:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
|
||||
|
||||
access_scope {
|
||||
type = "cluster"
|
||||
}
|
||||
|
||||
depends_on = [aws_eks_access_entry.admins]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "cluster_name" {
|
||||
value = aws_eks_cluster.main.name
|
||||
}
|
||||
|
||||
output "cluster_endpoint" {
|
||||
value = aws_eks_cluster.main.endpoint
|
||||
}
|
||||
|
||||
output "cluster_ca_certificate" {
|
||||
value = aws_eks_cluster.main.certificate_authority[0].data
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "cluster_version" {
|
||||
value = aws_eks_cluster.main.version
|
||||
}
|
||||
|
||||
output "cluster_security_group_id" {
|
||||
value = aws_security_group.cluster.id
|
||||
}
|
||||
|
||||
output "node_security_group_id" {
|
||||
value = aws_security_group.node.id
|
||||
}
|
||||
|
||||
output "oidc_provider_arn" {
|
||||
value = aws_iam_openid_connect_provider.eks.arn
|
||||
}
|
||||
|
||||
output "oidc_provider_url" {
|
||||
value = aws_iam_openid_connect_provider.eks.url
|
||||
}
|
||||
|
||||
output "cluster_autoscaler_role_arn" {
|
||||
value = local.enable_cluster_autoscaler ? aws_iam_role.cluster_autoscaler[0].arn : null
|
||||
}
|
||||
|
||||
output "lb_controller_role_arn" {
|
||||
value = local.enable_aws_lb_controller ? aws_iam_role.lb_controller[0].arn : null
|
||||
}
|
||||
|
||||
output "kubeconfig_command" {
|
||||
value = "aws eks update-kubeconfig --region ${data.aws_region.current.name} --name ${aws_eks_cluster.main.name}"
|
||||
}
|
||||
|
||||
output "next_steps" {
|
||||
value = <<-EOT
|
||||
|
||||
EKS Cluster Created: ${aws_eks_cluster.main.name}
|
||||
=============================================
|
||||
|
||||
1. Configure kubectl:
|
||||
${local.enable_cluster_autoscaler ? "aws eks update-kubeconfig --region ${data.aws_region.current.name} --name ${aws_eks_cluster.main.name}" : ""}
|
||||
|
||||
2. Install Cluster Autoscaler (if enabled):
|
||||
helm repo add autoscaler https://kubernetes.github.io/autoscaler
|
||||
helm install cluster-autoscaler autoscaler/cluster-autoscaler \
|
||||
--namespace kube-system \
|
||||
--set autoDiscovery.clusterName=${aws_eks_cluster.main.name} \
|
||||
--set awsRegion=${data.aws_region.current.name} \
|
||||
--set rbac.serviceAccount.create=true \
|
||||
--set rbac.serviceAccount.name=cluster-autoscaler \
|
||||
--set rbac.serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${local.enable_cluster_autoscaler ? aws_iam_role.cluster_autoscaler[0].arn : "N/A"}
|
||||
|
||||
3. Install AWS Load Balancer Controller (if enabled):
|
||||
helm repo add eks https://aws.github.io/eks-charts
|
||||
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
|
||||
--namespace kube-system \
|
||||
--set clusterName=${aws_eks_cluster.main.name} \
|
||||
--set serviceAccount.create=true \
|
||||
--set serviceAccount.name=aws-load-balancer-controller \
|
||||
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${local.enable_aws_lb_controller ? aws_iam_role.lb_controller[0].arn : "N/A"}
|
||||
|
||||
EOT
|
||||
}
|
||||
389
terraform/05-workloads/_template/elasticache-redis/main.tf
Normal file
389
terraform/05-workloads/_template/elasticache-redis/main.tf
Normal file
@@ -0,0 +1,389 @@
|
||||
################################################################################
|
||||
# Workload: ElastiCache Redis
|
||||
#
|
||||
# Deploys a managed Redis cluster:
|
||||
# - Redis cluster or replication group
|
||||
# - Encryption at rest and in transit
|
||||
# - Automatic failover (Multi-AZ)
|
||||
# - CloudWatch alarms
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-cache/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-cache/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
app = "cache"
|
||||
env = "prod" # prod, staging, dev
|
||||
name = "${local.tenant}-${local.app}-${local.env}"
|
||||
|
||||
# Redis version
|
||||
engine_version = "7.1"
|
||||
|
||||
# Node sizing
|
||||
# cache.t3.micro - Dev/test ($0.017/hr)
|
||||
# cache.t3.small - Small prod ($0.034/hr)
|
||||
# cache.r6g.large - Production ($0.158/hr)
|
||||
node_type = "cache.t3.micro"
|
||||
|
||||
# Cluster configuration
|
||||
num_cache_clusters = local.env == "prod" ? 2 : 1 # 2 for Multi-AZ
|
||||
automatic_failover = local.env == "prod"
|
||||
multi_az_enabled = local.env == "prod"
|
||||
|
||||
# Memory management
|
||||
maxmemory_policy = "volatile-lru" # Evict keys with TTL when memory full
|
||||
|
||||
# Maintenance
|
||||
maintenance_window = "sun:05:00-sun:06:00"
|
||||
snapshot_window = "04:00-05:00"
|
||||
snapshot_retention = local.env == "prod" ? 7 : 1
|
||||
|
||||
# Port
|
||||
port = 6379
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.app
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "network" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "02-network/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "tenant" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "04-tenants/${local.tenant}/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "redis" {
|
||||
description = "KMS key for ${local.name} Redis encryption"
|
||||
deletion_window_in_days = 7
|
||||
enable_key_rotation = true
|
||||
|
||||
tags = { Name = "${local.name}-redis" }
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "redis" {
|
||||
name = "alias/${local.name}-redis"
|
||||
target_key_id = aws_kms_key.redis.key_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Subnet Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_elasticache_subnet_group" "main" {
|
||||
name = local.name
|
||||
description = "Subnet group for ${local.name}"
|
||||
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Parameter Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_elasticache_parameter_group" "main" {
|
||||
name = local.name
|
||||
family = "redis7"
|
||||
description = "Parameter group for ${local.name}"
|
||||
|
||||
parameter {
|
||||
name = "maxmemory-policy"
|
||||
value = local.maxmemory_policy
|
||||
}
|
||||
|
||||
# Cluster mode disabled settings
|
||||
parameter {
|
||||
name = "cluster-enabled"
|
||||
value = "no"
|
||||
}
|
||||
|
||||
# Slow log for debugging
|
||||
parameter {
|
||||
name = "slowlog-log-slower-than"
|
||||
value = "10000" # 10ms
|
||||
}
|
||||
|
||||
parameter {
|
||||
name = "slowlog-max-len"
|
||||
value = "128"
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "redis" {
|
||||
name = "${local.name}-redis"
|
||||
description = "Redis cluster ${local.name}"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "Redis from tenant"
|
||||
from_port = local.port
|
||||
to_port = local.port
|
||||
protocol = "tcp"
|
||||
security_groups = [data.terraform_remote_state.tenant.outputs.security_groups.base]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "Allow all outbound"
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-redis" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Replication Group (Redis Cluster)
|
||||
################################################################################
|
||||
|
||||
resource "aws_elasticache_replication_group" "main" {
|
||||
replication_group_id = local.name
|
||||
description = "Redis cluster for ${local.name}"
|
||||
|
||||
engine = "redis"
|
||||
engine_version = local.engine_version
|
||||
node_type = local.node_type
|
||||
port = local.port
|
||||
parameter_group_name = aws_elasticache_parameter_group.main.name
|
||||
|
||||
# Cluster configuration
|
||||
num_cache_clusters = local.num_cache_clusters
|
||||
automatic_failover_enabled = local.automatic_failover
|
||||
multi_az_enabled = local.multi_az_enabled
|
||||
|
||||
# Network
|
||||
subnet_group_name = aws_elasticache_subnet_group.main.name
|
||||
security_group_ids = [aws_security_group.redis.id]
|
||||
|
||||
# Encryption
|
||||
at_rest_encryption_enabled = true
|
||||
kms_key_id = aws_kms_key.redis.arn
|
||||
transit_encryption_enabled = true
|
||||
auth_token = random_password.auth.result
|
||||
|
||||
# Maintenance
|
||||
maintenance_window = local.maintenance_window
|
||||
snapshot_window = local.snapshot_window
|
||||
snapshot_retention_limit = local.snapshot_retention
|
||||
auto_minor_version_upgrade = true
|
||||
|
||||
# Notifications
|
||||
notification_topic_arn = aws_sns_topic.redis.arn
|
||||
|
||||
# Apply changes immediately in non-prod, during maintenance in prod
|
||||
apply_immediately = local.env != "prod"
|
||||
|
||||
tags = {
|
||||
Name = local.name
|
||||
Backup = "true"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Auth Token (Password)
|
||||
################################################################################
|
||||
|
||||
resource "random_password" "auth" {
|
||||
length = 64
|
||||
special = false # Redis auth token doesn't support all special chars
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "redis" {
|
||||
name = "${local.name}-redis-auth"
|
||||
description = "Redis auth token for ${local.name}"
|
||||
recovery_window_in_days = local.env == "prod" ? 30 : 0
|
||||
|
||||
tags = { Name = "${local.name}-redis-auth" }
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "redis" {
|
||||
secret_id = aws_secretsmanager_secret.redis.id
|
||||
secret_string = jsonencode({
|
||||
auth_token = random_password.auth.result
|
||||
host = aws_elasticache_replication_group.main.primary_endpoint_address
|
||||
port = local.port
|
||||
url = "rediss://:${random_password.auth.result}@${aws_elasticache_replication_group.main.primary_endpoint_address}:${local.port}"
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic for Notifications
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "redis" {
|
||||
name = "${local.name}-redis-events"
|
||||
|
||||
tags = { Name = "${local.name}-redis-events" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Alarms
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "cpu" {
|
||||
alarm_name = "${local.name}-redis-cpu"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 3
|
||||
metric_name = "CPUUtilization"
|
||||
namespace = "AWS/ElastiCache"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 75
|
||||
alarm_description = "Redis CPU utilization high"
|
||||
|
||||
dimensions = {
|
||||
CacheClusterId = "${aws_elasticache_replication_group.main.id}-001"
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.redis.arn]
|
||||
|
||||
tags = { Name = "${local.name}-redis-cpu" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "memory" {
|
||||
alarm_name = "${local.name}-redis-memory"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "DatabaseMemoryUsagePercentage"
|
||||
namespace = "AWS/ElastiCache"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 80
|
||||
alarm_description = "Redis memory usage high"
|
||||
|
||||
dimensions = {
|
||||
CacheClusterId = "${aws_elasticache_replication_group.main.id}-001"
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.redis.arn]
|
||||
|
||||
tags = { Name = "${local.name}-redis-memory" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "connections" {
|
||||
alarm_name = "${local.name}-redis-connections"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "CurrConnections"
|
||||
namespace = "AWS/ElastiCache"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 1000
|
||||
alarm_description = "Redis connections high"
|
||||
|
||||
dimensions = {
|
||||
CacheClusterId = "${aws_elasticache_replication_group.main.id}-001"
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.redis.arn]
|
||||
|
||||
tags = { Name = "${local.name}-redis-connections" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "primary_endpoint" {
|
||||
value = aws_elasticache_replication_group.main.primary_endpoint_address
|
||||
}
|
||||
|
||||
output "reader_endpoint" {
|
||||
value = aws_elasticache_replication_group.main.reader_endpoint_address
|
||||
}
|
||||
|
||||
output "port" {
|
||||
value = local.port
|
||||
}
|
||||
|
||||
output "secret_arn" {
|
||||
value = aws_secretsmanager_secret.redis.arn
|
||||
}
|
||||
|
||||
output "security_group_id" {
|
||||
value = aws_security_group.redis.id
|
||||
}
|
||||
|
||||
output "connection_command" {
|
||||
value = "redis-cli -h ${aws_elasticache_replication_group.main.primary_endpoint_address} -p ${local.port} --tls --askpass"
|
||||
description = "Command to connect (retrieve password from Secrets Manager)"
|
||||
}
|
||||
385
terraform/05-workloads/_template/eventbridge-bus/main.tf
Normal file
385
terraform/05-workloads/_template/eventbridge-bus/main.tf
Normal file
@@ -0,0 +1,385 @@
|
||||
################################################################################
|
||||
# Workload: EventBridge Event Bus
|
||||
#
|
||||
# Deploys an event-driven architecture component:
|
||||
# - Custom event bus for tenant isolation
|
||||
# - Event rules with pattern matching
|
||||
# - Multiple targets (Lambda, SQS, Step Functions)
|
||||
# - Dead letter queue for failed events
|
||||
# - Event archiving for replay
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-events/
|
||||
# Update locals and rules
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-events/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "events"
|
||||
env = "prod"
|
||||
|
||||
bus_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Event archiving (for replay capability)
|
||||
enable_archive = true
|
||||
archive_retention_days = 30
|
||||
|
||||
# Dead letter queue for failed event delivery
|
||||
enable_dlq = true
|
||||
|
||||
# Schema discovery (for event schema registry)
|
||||
enable_schema_discovery = false
|
||||
|
||||
# Event rules - define your event routing here
|
||||
event_rules = {
|
||||
# Example: Route order events to SQS
|
||||
# order-created = {
|
||||
# description = "Route order.created events to processing queue"
|
||||
# event_pattern = {
|
||||
# source = ["${local.tenant}.orders"]
|
||||
# detail-type = ["order.created"]
|
||||
# }
|
||||
# targets = {
|
||||
# sqs = {
|
||||
# type = "sqs"
|
||||
# arn = "arn:aws:sqs:us-east-1:123456789012:order-processing"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# Example: Route all events to CloudWatch Logs for debugging
|
||||
all-events-log = {
|
||||
description = "Log all events for debugging"
|
||||
event_pattern = {
|
||||
source = [{ prefix = "${local.tenant}." }]
|
||||
}
|
||||
targets = {
|
||||
logs = {
|
||||
type = "cloudwatch"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Cross-account event sources (account IDs that can put events)
|
||||
allowed_source_accounts = []
|
||||
|
||||
# Cross-account event targets (account IDs that can receive events)
|
||||
allowed_target_accounts = []
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Event Bus
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_bus" "main" {
|
||||
name = local.bus_name
|
||||
|
||||
tags = { Name = local.bus_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Event Bus Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_bus_policy" "main" {
|
||||
count = length(local.allowed_source_accounts) > 0 ? 1 : 0
|
||||
event_bus_name = aws_cloudwatch_event_bus.main.name
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowCrossAccountPutEvents"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for account in local.allowed_source_accounts : "arn:aws:iam::${account}:root"]
|
||||
}
|
||||
Action = "events:PutEvents"
|
||||
Resource = aws_cloudwatch_event_bus.main.arn
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Event Archive
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_archive" "main" {
|
||||
count = local.enable_archive ? 1 : 0
|
||||
name = local.bus_name
|
||||
description = "Archive for ${local.bus_name}"
|
||||
event_source_arn = aws_cloudwatch_event_bus.main.arn
|
||||
retention_days = local.archive_retention_days
|
||||
|
||||
# Archive all events (can be filtered with event_pattern)
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Dead Letter Queue
|
||||
################################################################################
|
||||
|
||||
resource "aws_sqs_queue" "dlq" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
name = "${local.bus_name}-dlq"
|
||||
|
||||
message_retention_seconds = 1209600 # 14 days
|
||||
kms_master_key_id = "alias/aws/sqs"
|
||||
|
||||
tags = { Name = "${local.bus_name}-dlq" }
|
||||
}
|
||||
|
||||
resource "aws_sqs_queue_policy" "dlq" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
queue_url = aws_sqs_queue.dlq[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "AllowEventBridge"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "events.amazonaws.com"
|
||||
}
|
||||
Action = "sqs:SendMessage"
|
||||
Resource = aws_sqs_queue.dlq[0].arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group for Event Logging
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "events" {
|
||||
name = "/aws/events/${local.bus_name}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = local.bus_name }
|
||||
}
|
||||
|
||||
# Resource policy to allow EventBridge to write logs
|
||||
resource "aws_cloudwatch_log_resource_policy" "events" {
|
||||
policy_name = "${local.bus_name}-events"
|
||||
|
||||
policy_document = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = ["events.amazonaws.com", "delivery.logs.amazonaws.com"]
|
||||
}
|
||||
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
|
||||
Resource = "${aws_cloudwatch_log_group.events.arn}:*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role for EventBridge Targets
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "eventbridge" {
|
||||
name = "${local.bus_name}-eventbridge"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "events.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.bus_name}-eventbridge" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Event Rules and Targets
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "rules" {
|
||||
for_each = local.event_rules
|
||||
|
||||
name = "${local.bus_name}-${each.key}"
|
||||
description = each.value.description
|
||||
event_bus_name = aws_cloudwatch_event_bus.main.name
|
||||
event_pattern = jsonencode(each.value.event_pattern)
|
||||
state = "ENABLED"
|
||||
|
||||
tags = { Name = "${local.bus_name}-${each.key}" }
|
||||
}
|
||||
|
||||
# CloudWatch Logs targets
|
||||
resource "aws_cloudwatch_event_target" "logs" {
|
||||
for_each = {
|
||||
for k, v in local.event_rules : k => v
|
||||
if contains(keys(v.targets), "logs") && v.targets.logs.type == "cloudwatch"
|
||||
}
|
||||
|
||||
rule = aws_cloudwatch_event_rule.rules[each.key].name
|
||||
event_bus_name = aws_cloudwatch_event_bus.main.name
|
||||
target_id = "cloudwatch-logs"
|
||||
arn = aws_cloudwatch_log_group.events.arn
|
||||
|
||||
dead_letter_config {
|
||||
arn = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Schema Registry (Optional)
|
||||
################################################################################
|
||||
|
||||
resource "aws_schemas_discoverer" "main" {
|
||||
count = local.enable_schema_discovery ? 1 : 0
|
||||
source_arn = aws_cloudwatch_event_bus.main.arn
|
||||
description = "Schema discoverer for ${local.bus_name}"
|
||||
|
||||
tags = { Name = local.bus_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Alarms
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "dlq_messages" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
alarm_name = "${local.bus_name}-dlq-messages"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 1
|
||||
metric_name = "ApproximateNumberOfMessagesVisible"
|
||||
namespace = "AWS/SQS"
|
||||
period = 300
|
||||
statistic = "Sum"
|
||||
threshold = 0
|
||||
alarm_description = "Events failing to deliver to targets"
|
||||
|
||||
dimensions = {
|
||||
QueueName = aws_sqs_queue.dlq[0].name
|
||||
}
|
||||
|
||||
tags = { Name = "${local.bus_name}-dlq-alarm" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "failed_invocations" {
|
||||
alarm_name = "${local.bus_name}-failed-invocations"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "FailedInvocations"
|
||||
namespace = "AWS/Events"
|
||||
period = 300
|
||||
statistic = "Sum"
|
||||
threshold = 0
|
||||
alarm_description = "EventBridge rule invocations failing"
|
||||
|
||||
dimensions = {
|
||||
EventBusName = aws_cloudwatch_event_bus.main.name
|
||||
}
|
||||
|
||||
tags = { Name = "${local.bus_name}-failed-invocations" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "event_bus_name" {
|
||||
value = aws_cloudwatch_event_bus.main.name
|
||||
}
|
||||
|
||||
output "event_bus_arn" {
|
||||
value = aws_cloudwatch_event_bus.main.arn
|
||||
}
|
||||
|
||||
output "archive_arn" {
|
||||
value = local.enable_archive ? aws_cloudwatch_event_archive.main[0].arn : null
|
||||
}
|
||||
|
||||
output "dlq_url" {
|
||||
value = local.enable_dlq ? aws_sqs_queue.dlq[0].url : null
|
||||
}
|
||||
|
||||
output "dlq_arn" {
|
||||
value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
|
||||
}
|
||||
|
||||
output "log_group" {
|
||||
value = aws_cloudwatch_log_group.events.name
|
||||
}
|
||||
|
||||
output "rule_arns" {
|
||||
value = { for k, v in aws_cloudwatch_event_rule.rules : k => v.arn }
|
||||
}
|
||||
|
||||
output "put_event_example" {
|
||||
value = <<-EOF
|
||||
aws events put-events --entries '[{
|
||||
"EventBusName": "${aws_cloudwatch_event_bus.main.name}",
|
||||
"Source": "${local.tenant}.myservice",
|
||||
"DetailType": "order.created",
|
||||
"Detail": "{\"orderId\": \"12345\", \"amount\": 99.99}"
|
||||
}]'
|
||||
EOF
|
||||
description = "Example command to put an event"
|
||||
}
|
||||
422
terraform/05-workloads/_template/eventbridge-rules/main.tf
Normal file
422
terraform/05-workloads/_template/eventbridge-rules/main.tf
Normal file
@@ -0,0 +1,422 @@
|
||||
################################################################################
|
||||
# Workload: EventBridge Rules
|
||||
#
|
||||
# Event-driven automation with:
|
||||
# - Scheduled rules (cron/rate)
|
||||
# - Event pattern rules (AWS service events)
|
||||
# - Multiple targets (Lambda, SQS, SNS, Step Functions)
|
||||
# - Dead letter queues
|
||||
# - Input transformations
|
||||
#
|
||||
# Use cases: Scheduled jobs, event routing, service integration
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-events/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
prefix = "${local.tenant}-${local.name}"
|
||||
|
||||
# Use custom event bus (null = default bus)
|
||||
event_bus_name = null
|
||||
|
||||
# Scheduled rules
|
||||
scheduled_rules = {
|
||||
# Daily report at 9 AM UTC
|
||||
daily-report = {
|
||||
description = "Generate daily report"
|
||||
schedule_expression = "cron(0 9 * * ? *)"
|
||||
enabled = true
|
||||
target_type = "lambda"
|
||||
target_arn = "" # Lambda function ARN
|
||||
input = jsonencode({
|
||||
report_type = "daily"
|
||||
format = "pdf"
|
||||
})
|
||||
}
|
||||
|
||||
# Every 5 minutes health check
|
||||
health-check = {
|
||||
description = "Periodic health check"
|
||||
schedule_expression = "rate(5 minutes)"
|
||||
enabled = true
|
||||
target_type = "lambda"
|
||||
target_arn = "" # Lambda function ARN
|
||||
}
|
||||
|
||||
# Monthly cleanup (1st of month at midnight)
|
||||
monthly-cleanup = {
|
||||
description = "Monthly data cleanup"
|
||||
schedule_expression = "cron(0 0 1 * ? *)"
|
||||
enabled = true
|
||||
target_type = "step-function"
|
||||
target_arn = "" # State machine ARN
|
||||
input = jsonencode({
|
||||
retention_days = 90
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# Event pattern rules (react to AWS events)
|
||||
event_pattern_rules = {
|
||||
# EC2 instance state changes
|
||||
ec2-state-change = {
|
||||
description = "EC2 instance state changes"
|
||||
enabled = true
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.ec2"]
|
||||
detail-type = ["EC2 Instance State-change Notification"]
|
||||
detail = {
|
||||
state = ["stopped", "terminated"]
|
||||
}
|
||||
})
|
||||
target_type = "sns"
|
||||
target_arn = "" # SNS topic ARN
|
||||
}
|
||||
|
||||
# S3 object created
|
||||
s3-upload = {
|
||||
description = "S3 object created in uploads bucket"
|
||||
enabled = true
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.s3"]
|
||||
detail-type = ["Object Created"]
|
||||
detail = {
|
||||
bucket = {
|
||||
name = ["my-uploads-bucket"]
|
||||
}
|
||||
}
|
||||
})
|
||||
target_type = "lambda"
|
||||
target_arn = "" # Lambda function ARN
|
||||
input_transformer = {
|
||||
input_paths = {
|
||||
bucket = "$.detail.bucket.name"
|
||||
key = "$.detail.object.key"
|
||||
size = "$.detail.object.size"
|
||||
}
|
||||
input_template = <<-EOF
|
||||
{
|
||||
"bucket": <bucket>,
|
||||
"key": <key>,
|
||||
"size": <size>,
|
||||
"timestamp": "<aws.events.event.ingestion-time>"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
}
|
||||
|
||||
# CodePipeline state change
|
||||
pipeline-failed = {
|
||||
description = "CodePipeline execution failed"
|
||||
enabled = true
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.codepipeline"]
|
||||
detail-type = ["CodePipeline Pipeline Execution State Change"]
|
||||
detail = {
|
||||
state = ["FAILED"]
|
||||
}
|
||||
})
|
||||
target_type = "sns"
|
||||
target_arn = "" # SNS topic ARN
|
||||
}
|
||||
|
||||
# GuardDuty findings
|
||||
security-findings = {
|
||||
description = "GuardDuty security findings"
|
||||
enabled = false # Enable when GuardDuty is active
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.guardduty"]
|
||||
detail-type = ["GuardDuty Finding"]
|
||||
detail = {
|
||||
severity = [{ numeric = [">=", 7] }] # High severity
|
||||
}
|
||||
})
|
||||
target_type = "sns"
|
||||
target_arn = "" # SNS topic ARN
|
||||
}
|
||||
}
|
||||
|
||||
# Enable DLQ for failed deliveries
|
||||
enable_dlq = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Dead Letter Queue
|
||||
################################################################################
|
||||
|
||||
resource "aws_sqs_queue" "dlq" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
name = "${local.prefix}-events-dlq"
|
||||
|
||||
message_retention_seconds = 1209600 # 14 days
|
||||
kms_master_key_id = "alias/aws/sqs"
|
||||
|
||||
tags = { Name = "${local.prefix}-events-dlq" }
|
||||
}
|
||||
|
||||
resource "aws_sqs_queue_policy" "dlq" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
queue_url = aws_sqs_queue.dlq[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "AllowEventBridge"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "events.amazonaws.com"
|
||||
}
|
||||
Action = "sqs:SendMessage"
|
||||
Resource = aws_sqs_queue.dlq[0].arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role for EventBridge
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "eventbridge" {
|
||||
name = "${local.prefix}-eventbridge"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "events.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.prefix}-eventbridge" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "eventbridge" {
|
||||
name = "invoke-targets"
|
||||
role = aws_iam_role.eventbridge.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "InvokeStepFunctions"
|
||||
Effect = "Allow"
|
||||
Action = "states:StartExecution"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "InvokeLambda"
|
||||
Effect = "Allow"
|
||||
Action = "lambda:InvokeFunction"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "SendToSQS"
|
||||
Effect = "Allow"
|
||||
Action = "sqs:SendMessage"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "PublishToSNS"
|
||||
Effect = "Allow"
|
||||
Action = "sns:Publish"
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Scheduled Rules
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "scheduled" {
|
||||
for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" }
|
||||
|
||||
name = "${local.prefix}-${each.key}"
|
||||
description = lookup(each.value, "description", "Scheduled rule ${each.key}")
|
||||
schedule_expression = each.value.schedule_expression
|
||||
event_bus_name = local.event_bus_name
|
||||
state = each.value.enabled ? "ENABLED" : "DISABLED"
|
||||
|
||||
tags = { Name = "${local.prefix}-${each.key}" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "scheduled" {
|
||||
for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" }
|
||||
|
||||
rule = aws_cloudwatch_event_rule.scheduled[each.key].name
|
||||
event_bus_name = local.event_bus_name
|
||||
target_id = each.key
|
||||
arn = each.value.target_arn
|
||||
role_arn = each.value.target_type == "step-function" ? aws_iam_role.eventbridge.arn : null
|
||||
input = lookup(each.value, "input", null)
|
||||
|
||||
dynamic "dead_letter_config" {
|
||||
for_each = local.enable_dlq ? [1] : []
|
||||
content {
|
||||
arn = aws_sqs_queue.dlq[0].arn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Event Pattern Rules
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "pattern" {
|
||||
for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" }
|
||||
|
||||
name = "${local.prefix}-${each.key}"
|
||||
description = lookup(each.value, "description", "Event pattern rule ${each.key}")
|
||||
event_pattern = each.value.event_pattern
|
||||
event_bus_name = local.event_bus_name
|
||||
state = each.value.enabled ? "ENABLED" : "DISABLED"
|
||||
|
||||
tags = { Name = "${local.prefix}-${each.key}" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "pattern" {
|
||||
for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" }
|
||||
|
||||
rule = aws_cloudwatch_event_rule.pattern[each.key].name
|
||||
event_bus_name = local.event_bus_name
|
||||
target_id = each.key
|
||||
arn = each.value.target_arn
|
||||
role_arn = each.value.target_type == "step-function" ? aws_iam_role.eventbridge.arn : null
|
||||
input = lookup(each.value, "input", null)
|
||||
|
||||
dynamic "input_transformer" {
|
||||
for_each = lookup(each.value, "input_transformer", null) != null ? [each.value.input_transformer] : []
|
||||
content {
|
||||
input_paths = input_transformer.value.input_paths
|
||||
input_template = input_transformer.value.input_template
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "dead_letter_config" {
|
||||
for_each = local.enable_dlq ? [1] : []
|
||||
content {
|
||||
arn = aws_sqs_queue.dlq[0].arn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Lambda Permissions
|
||||
################################################################################
|
||||
|
||||
resource "aws_lambda_permission" "scheduled" {
|
||||
for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" && v.target_type == "lambda" }
|
||||
|
||||
statement_id = "AllowEventBridge-${each.key}"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = regex("function:([^:]+)", each.value.target_arn)[0]
|
||||
principal = "events.amazonaws.com"
|
||||
source_arn = aws_cloudwatch_event_rule.scheduled[each.key].arn
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "pattern" {
|
||||
for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" && v.target_type == "lambda" }
|
||||
|
||||
statement_id = "AllowEventBridge-${each.key}"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = regex("function:([^:]+)", each.value.target_arn)[0]
|
||||
principal = "events.amazonaws.com"
|
||||
source_arn = aws_cloudwatch_event_rule.pattern[each.key].arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "scheduled_rule_arns" {
|
||||
value = { for k, v in aws_cloudwatch_event_rule.scheduled : k => v.arn }
|
||||
description = "Scheduled rule ARNs"
|
||||
}
|
||||
|
||||
output "pattern_rule_arns" {
|
||||
value = { for k, v in aws_cloudwatch_event_rule.pattern : k => v.arn }
|
||||
description = "Event pattern rule ARNs"
|
||||
}
|
||||
|
||||
output "dlq_arn" {
|
||||
value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
|
||||
description = "Dead letter queue ARN"
|
||||
}
|
||||
|
||||
output "eventbridge_role_arn" {
|
||||
value = aws_iam_role.eventbridge.arn
|
||||
description = "EventBridge execution role ARN"
|
||||
}
|
||||
|
||||
output "cron_examples" {
|
||||
value = {
|
||||
every_5_min = "rate(5 minutes)"
|
||||
every_hour = "rate(1 hour)"
|
||||
daily_9am_utc = "cron(0 9 * * ? *)"
|
||||
weekdays_8am = "cron(0 8 ? * MON-FRI *)"
|
||||
monthly_1st = "cron(0 0 1 * ? *)"
|
||||
every_monday = "cron(0 12 ? * MON *)"
|
||||
}
|
||||
description = "Cron expression examples"
|
||||
}
|
||||
409
terraform/05-workloads/_template/lambda-function/main.tf
Normal file
409
terraform/05-workloads/_template/lambda-function/main.tf
Normal file
@@ -0,0 +1,409 @@
|
||||
################################################################################
|
||||
# Workload: Lambda Function
|
||||
#
|
||||
# Deploys a serverless function:
|
||||
# - Lambda function with VPC access (optional)
|
||||
# - API Gateway HTTP API (optional)
|
||||
# - CloudWatch logging & X-Ray tracing
|
||||
# - EventBridge rules for scheduled invocation (optional)
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<app>/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<APP>/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
app = "<APP>"
|
||||
env = "prod" # prod, staging, dev
|
||||
name = "${local.tenant}-${local.app}-${local.env}"
|
||||
|
||||
# Lambda config
|
||||
runtime = "python3.12" # python3.12, nodejs20.x, go1.x, etc.
|
||||
handler = "main.handler"
|
||||
memory_size = 256
|
||||
timeout = 30
|
||||
|
||||
# Source - provide ONE of these
|
||||
source_dir = null # Path to source directory (will be zipped)
|
||||
s3_bucket = null # S3 bucket containing deployment package
|
||||
s3_key = null # S3 key for deployment package
|
||||
image_uri = null # Container image URI
|
||||
|
||||
# VPC - set to true for database access
|
||||
enable_vpc = false
|
||||
|
||||
# API Gateway
|
||||
enable_api = true
|
||||
api_path = "/{proxy+}"
|
||||
|
||||
# Scheduled execution (cron or rate expression)
|
||||
schedule_expression = null # "rate(5 minutes)" or "cron(0 12 * * ? *)"
|
||||
|
||||
# Environment variables
|
||||
environment = {
|
||||
APP_ENV = local.env
|
||||
LOG_LEVEL = "INFO"
|
||||
}
|
||||
|
||||
# Secrets (ARNs to SSM/Secrets Manager)
|
||||
secrets = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.app
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "network" {
|
||||
count = local.enable_vpc ? 1 : 0
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "02-network/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "tenant" {
|
||||
count = local.enable_vpc ? 1 : 0
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "04-tenants/${local.tenant}/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "main" {
|
||||
name = "/aws/lambda/${local.name}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "lambda" {
|
||||
name = "${local.name}-lambda"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "lambda.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-lambda" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "lambda_basic" {
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "lambda_vpc" {
|
||||
count = local.enable_vpc ? 1 : 0
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "lambda_xray" {
|
||||
role = aws_iam_role.lambda.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "lambda_app" {
|
||||
name = "app-permissions"
|
||||
role = aws_iam_role.lambda.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowTaggedResources"
|
||||
Effect = "Allow"
|
||||
Action = ["s3:GetObject", "s3:PutObject", "dynamodb:*", "sqs:*", "sns:Publish"]
|
||||
Resource = "*"
|
||||
Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } }
|
||||
},
|
||||
{
|
||||
Sid = "SecretsAccess"
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue", "ssm:GetParameter", "ssm:GetParameters"]
|
||||
Resource = length(local.secrets) > 0 ? values(local.secrets) : ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${local.tenant}/*"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group (VPC mode)
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "lambda" {
|
||||
count = local.enable_vpc ? 1 : 0
|
||||
name = "${local.name}-lambda"
|
||||
description = "Lambda function ${local.name}"
|
||||
vpc_id = data.terraform_remote_state.network[0].outputs.vpc_id
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-lambda" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Lambda Function
|
||||
################################################################################
|
||||
|
||||
# Create zip from source directory if provided
|
||||
data "archive_file" "lambda" {
|
||||
count = local.source_dir != null ? 1 : 0
|
||||
type = "zip"
|
||||
source_dir = local.source_dir
|
||||
output_path = "${path.module}/lambda.zip"
|
||||
}
|
||||
|
||||
resource "aws_lambda_function" "main" {
|
||||
function_name = local.name
|
||||
description = "${local.tenant} ${local.app} function"
|
||||
role = aws_iam_role.lambda.arn
|
||||
|
||||
# Source - exactly one must be specified
|
||||
filename = local.source_dir != null ? data.archive_file.lambda[0].output_path : null
|
||||
source_code_hash = local.source_dir != null ? data.archive_file.lambda[0].output_base64sha256 : null
|
||||
s3_bucket = local.s3_bucket
|
||||
s3_key = local.s3_key
|
||||
image_uri = local.image_uri
|
||||
package_type = local.image_uri != null ? "Image" : "Zip"
|
||||
|
||||
# Only for Zip packages
|
||||
runtime = local.image_uri == null ? local.runtime : null
|
||||
handler = local.image_uri == null ? local.handler : null
|
||||
|
||||
memory_size = local.memory_size
|
||||
timeout = local.timeout
|
||||
|
||||
environment {
|
||||
variables = merge(local.environment, {
|
||||
for k, v in local.secrets : k => v
|
||||
})
|
||||
}
|
||||
|
||||
dynamic "vpc_config" {
|
||||
for_each = local.enable_vpc ? [1] : []
|
||||
content {
|
||||
subnet_ids = data.terraform_remote_state.network[0].outputs.private_subnet_ids
|
||||
security_group_ids = [
|
||||
aws_security_group.lambda[0].id,
|
||||
data.terraform_remote_state.tenant[0].outputs.security_groups.base
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
tracing_config {
|
||||
mode = "Active"
|
||||
}
|
||||
|
||||
depends_on = [aws_cloudwatch_log_group.main]
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# API Gateway HTTP API
|
||||
################################################################################
|
||||
|
||||
resource "aws_apigatewayv2_api" "main" {
|
||||
count = local.enable_api ? 1 : 0
|
||||
name = local.name
|
||||
protocol_type = "HTTP"
|
||||
|
||||
cors_configuration {
|
||||
allow_origins = ["*"]
|
||||
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
allow_headers = ["Content-Type", "Authorization"]
|
||||
max_age = 300
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_apigatewayv2_stage" "main" {
|
||||
count = local.enable_api ? 1 : 0
|
||||
api_id = aws_apigatewayv2_api.main[0].id
|
||||
name = "$default"
|
||||
auto_deploy = true
|
||||
|
||||
access_log_settings {
|
||||
destination_arn = aws_cloudwatch_log_group.api[0].arn
|
||||
format = jsonencode({
|
||||
requestId = "$context.requestId"
|
||||
ip = "$context.identity.sourceIp"
|
||||
requestTime = "$context.requestTime"
|
||||
httpMethod = "$context.httpMethod"
|
||||
routeKey = "$context.routeKey"
|
||||
status = "$context.status"
|
||||
responseLength = "$context.responseLength"
|
||||
integrationError = "$context.integrationErrorMessage"
|
||||
})
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "api" {
|
||||
count = local.enable_api ? 1 : 0
|
||||
name = "/aws/apigateway/${local.name}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = "${local.name}-api" }
|
||||
}
|
||||
|
||||
resource "aws_apigatewayv2_integration" "main" {
|
||||
count = local.enable_api ? 1 : 0
|
||||
api_id = aws_apigatewayv2_api.main[0].id
|
||||
integration_type = "AWS_PROXY"
|
||||
integration_uri = aws_lambda_function.main.invoke_arn
|
||||
payload_format_version = "2.0"
|
||||
}
|
||||
|
||||
resource "aws_apigatewayv2_route" "main" {
|
||||
count = local.enable_api ? 1 : 0
|
||||
api_id = aws_apigatewayv2_api.main[0].id
|
||||
route_key = "ANY ${local.api_path}"
|
||||
target = "integrations/${aws_apigatewayv2_integration.main[0].id}"
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "api" {
|
||||
count = local.enable_api ? 1 : 0
|
||||
statement_id = "AllowAPIGateway"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.main.function_name
|
||||
principal = "apigateway.amazonaws.com"
|
||||
source_arn = "${aws_apigatewayv2_api.main[0].execution_arn}/*/*"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EventBridge Schedule
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "schedule" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
name = "${local.name}-schedule"
|
||||
description = "Schedule for ${local.name}"
|
||||
schedule_expression = local.schedule_expression
|
||||
|
||||
tags = { Name = "${local.name}-schedule" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "schedule" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
rule = aws_cloudwatch_event_rule.schedule[0].name
|
||||
target_id = "lambda"
|
||||
arn = aws_lambda_function.main.arn
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "schedule" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
statement_id = "AllowEventBridge"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.main.function_name
|
||||
principal = "events.amazonaws.com"
|
||||
source_arn = aws_cloudwatch_event_rule.schedule[0].arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "function_name" {
|
||||
value = aws_lambda_function.main.function_name
|
||||
}
|
||||
|
||||
output "function_arn" {
|
||||
value = aws_lambda_function.main.arn
|
||||
}
|
||||
|
||||
output "invoke_arn" {
|
||||
value = aws_lambda_function.main.invoke_arn
|
||||
}
|
||||
|
||||
output "api_endpoint" {
|
||||
value = local.enable_api ? aws_apigatewayv2_api.main[0].api_endpoint : null
|
||||
}
|
||||
|
||||
output "log_group" {
|
||||
value = aws_cloudwatch_log_group.main.name
|
||||
}
|
||||
|
||||
output "role_arn" {
|
||||
value = aws_iam_role.lambda.arn
|
||||
}
|
||||
458
terraform/05-workloads/_template/opensearch/main.tf
Normal file
458
terraform/05-workloads/_template/opensearch/main.tf
Normal file
@@ -0,0 +1,458 @@
|
||||
################################################################################
|
||||
# Workload: OpenSearch (Elasticsearch)
|
||||
#
|
||||
# Search and analytics with:
|
||||
# - Serverless or provisioned clusters
|
||||
# - Fine-grained access control
|
||||
# - VPC or public access
|
||||
# - Cognito authentication
|
||||
# - UltraWarm for cost-effective storage
|
||||
# - Cross-cluster search
|
||||
#
|
||||
# Use cases: Log analytics, full-text search, observability
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-opensearch/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
domain_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Engine
|
||||
engine_version = "OpenSearch_2.11"
|
||||
|
||||
# Cluster sizing
|
||||
cluster = {
|
||||
# Data nodes
|
||||
instance_type = "t3.medium.search" # t3.small.search for dev
|
||||
instance_count = 2
|
||||
|
||||
# Dedicated master nodes (recommended for production)
|
||||
dedicated_master_enabled = local.env == "prod"
|
||||
dedicated_master_type = "t3.medium.search"
|
||||
dedicated_master_count = 3
|
||||
|
||||
# Multi-AZ
|
||||
zone_awareness_enabled = local.env == "prod"
|
||||
availability_zone_count = local.env == "prod" ? 2 : 1
|
||||
}
|
||||
|
||||
# Storage
|
||||
storage = {
|
||||
type = "gp3"
|
||||
size_gb = 100
|
||||
iops = 3000
|
||||
throughput = 125
|
||||
}
|
||||
|
||||
# UltraWarm (cost-effective warm storage)
|
||||
ultrawarm = {
|
||||
enabled = false
|
||||
type = "ultrawarm1.medium.search"
|
||||
count = 2
|
||||
}
|
||||
|
||||
# Network
|
||||
# Option 1: VPC (private, more secure)
|
||||
vpc_enabled = true
|
||||
vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id
|
||||
private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
|
||||
# Option 2: Public (set vpc_enabled = false)
|
||||
# Uses IP-based access policy
|
||||
|
||||
# Access control
|
||||
enable_fine_grained_access = true
|
||||
master_user_name = "admin"
|
||||
|
||||
# Cognito authentication (optional, for Dashboards)
|
||||
cognito = {
|
||||
enabled = false
|
||||
user_pool_id = ""
|
||||
identity_pool_id = ""
|
||||
role_arn = ""
|
||||
}
|
||||
|
||||
# Encryption
|
||||
encrypt_at_rest = true
|
||||
node_to_node_encryption = true
|
||||
|
||||
# Logging
|
||||
log_types = ["INDEX_SLOW_LOGS", "SEARCH_SLOW_LOGS", "ES_APPLICATION_LOGS"]
|
||||
|
||||
# Auto-tune
|
||||
auto_tune_enabled = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Random Password for Master User
|
||||
################################################################################
|
||||
|
||||
resource "random_password" "master" {
|
||||
count = local.enable_fine_grained_access ? 1 : 0
|
||||
length = 24
|
||||
special = true
|
||||
override_special = "!#$%&*()-_=+[]{}<>:?"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets Manager
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret" "opensearch" {
|
||||
count = local.enable_fine_grained_access ? 1 : 0
|
||||
name = "${local.tenant}/${local.env}/${local.name}/opensearch"
|
||||
description = "OpenSearch master credentials"
|
||||
|
||||
tags = { Name = "${local.domain_name}-credentials" }
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "opensearch" {
|
||||
count = local.enable_fine_grained_access ? 1 : 0
|
||||
secret_id = aws_secretsmanager_secret.opensearch[0].id
|
||||
secret_string = jsonencode({
|
||||
username = local.master_user_name
|
||||
password = random_password.master[0].result
|
||||
endpoint = aws_opensearch_domain.main.endpoint
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Groups
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "opensearch" {
|
||||
for_each = toset(local.log_types)
|
||||
name = "/aws/opensearch/${local.domain_name}/${lower(each.key)}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = "${local.domain_name}-${lower(each.key)}" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_resource_policy" "opensearch" {
|
||||
policy_name = "${local.domain_name}-logs"
|
||||
|
||||
policy_document = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "es.amazonaws.com"
|
||||
}
|
||||
Action = [
|
||||
"logs:PutLogEvents",
|
||||
"logs:CreateLogStream"
|
||||
]
|
||||
Resource = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/opensearch/${local.domain_name}/*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group (VPC mode)
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "opensearch" {
|
||||
count = local.vpc_enabled && length(local.vpc_id) > 0 ? 1 : 0
|
||||
name = "${local.domain_name}-opensearch"
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTPS from VPC"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["10.0.0.0/8"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.domain_name}-opensearch" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Service-Linked Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_service_linked_role" "opensearch" {
|
||||
count = local.vpc_enabled ? 1 : 0
|
||||
aws_service_name = "opensearchservice.amazonaws.com"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# OpenSearch Domain
|
||||
################################################################################
|
||||
|
||||
resource "aws_opensearch_domain" "main" {
|
||||
domain_name = local.domain_name
|
||||
engine_version = local.engine_version
|
||||
|
||||
# Cluster configuration
|
||||
cluster_config {
|
||||
instance_type = local.cluster.instance_type
|
||||
instance_count = local.cluster.instance_count
|
||||
|
||||
dedicated_master_enabled = local.cluster.dedicated_master_enabled
|
||||
dedicated_master_type = local.cluster.dedicated_master_enabled ? local.cluster.dedicated_master_type : null
|
||||
dedicated_master_count = local.cluster.dedicated_master_enabled ? local.cluster.dedicated_master_count : null
|
||||
|
||||
zone_awareness_enabled = local.cluster.zone_awareness_enabled
|
||||
|
||||
dynamic "zone_awareness_config" {
|
||||
for_each = local.cluster.zone_awareness_enabled ? [1] : []
|
||||
content {
|
||||
availability_zone_count = local.cluster.availability_zone_count
|
||||
}
|
||||
}
|
||||
|
||||
# UltraWarm
|
||||
warm_enabled = local.ultrawarm.enabled
|
||||
warm_type = local.ultrawarm.enabled ? local.ultrawarm.type : null
|
||||
warm_count = local.ultrawarm.enabled ? local.ultrawarm.count : null
|
||||
}
|
||||
|
||||
# Storage
|
||||
ebs_options {
|
||||
ebs_enabled = true
|
||||
volume_type = local.storage.type
|
||||
volume_size = local.storage.size_gb
|
||||
iops = local.storage.type == "gp3" ? local.storage.iops : null
|
||||
throughput = local.storage.type == "gp3" ? local.storage.throughput : null
|
||||
}
|
||||
|
||||
# VPC configuration
|
||||
dynamic "vpc_options" {
|
||||
for_each = local.vpc_enabled && length(local.private_subnet_ids) > 0 ? [1] : []
|
||||
content {
|
||||
subnet_ids = slice(local.private_subnet_ids, 0, local.cluster.availability_zone_count)
|
||||
security_group_ids = [aws_security_group.opensearch[0].id]
|
||||
}
|
||||
}
|
||||
|
||||
# Encryption
|
||||
encrypt_at_rest {
|
||||
enabled = local.encrypt_at_rest
|
||||
}
|
||||
|
||||
node_to_node_encryption {
|
||||
enabled = local.node_to_node_encryption
|
||||
}
|
||||
|
||||
domain_endpoint_options {
|
||||
enforce_https = true
|
||||
tls_security_policy = "Policy-Min-TLS-1-2-2019-07"
|
||||
}
|
||||
|
||||
# Fine-grained access control
|
||||
advanced_security_options {
|
||||
enabled = local.enable_fine_grained_access
|
||||
internal_user_database_enabled = local.enable_fine_grained_access
|
||||
|
||||
dynamic "master_user_options" {
|
||||
for_each = local.enable_fine_grained_access ? [1] : []
|
||||
content {
|
||||
master_user_name = local.master_user_name
|
||||
master_user_password = random_password.master[0].result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Cognito authentication
|
||||
dynamic "cognito_options" {
|
||||
for_each = local.cognito.enabled ? [1] : []
|
||||
content {
|
||||
enabled = true
|
||||
user_pool_id = local.cognito.user_pool_id
|
||||
identity_pool_id = local.cognito.identity_pool_id
|
||||
role_arn = local.cognito.role_arn
|
||||
}
|
||||
}
|
||||
|
||||
# Logging
|
||||
dynamic "log_publishing_options" {
|
||||
for_each = local.log_types
|
||||
content {
|
||||
cloudwatch_log_group_arn = aws_cloudwatch_log_group.opensearch[log_publishing_options.value].arn
|
||||
log_type = log_publishing_options.value
|
||||
}
|
||||
}
|
||||
|
||||
# Auto-tune
|
||||
auto_tune_options {
|
||||
desired_state = local.auto_tune_enabled ? "ENABLED" : "DISABLED"
|
||||
rollback_on_disable = "NO_ROLLBACK"
|
||||
}
|
||||
|
||||
# Access policy (for non-VPC or fine-grained access)
|
||||
access_policies = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "*"
|
||||
}
|
||||
Action = "es:*"
|
||||
Resource = "arn:aws:es:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:domain/${local.domain_name}/*"
|
||||
Condition = local.vpc_enabled ? {} : {
|
||||
IpAddress = {
|
||||
"aws:SourceIp" = ["0.0.0.0/0"] # Restrict in production!
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = local.domain_name }
|
||||
|
||||
depends_on = [
|
||||
aws_iam_service_linked_role.opensearch,
|
||||
aws_cloudwatch_log_resource_policy.opensearch
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Policy for Application Access
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "opensearch_access" {
|
||||
name = "${local.domain_name}-access"
|
||||
description = "Access to ${local.domain_name} OpenSearch domain"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "OpenSearchAccess"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"es:ESHttpGet",
|
||||
"es:ESHttpHead",
|
||||
"es:ESHttpPost",
|
||||
"es:ESHttpPut",
|
||||
"es:ESHttpDelete"
|
||||
]
|
||||
Resource = "${aws_opensearch_domain.main.arn}/*"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.domain_name}-access" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "domain_endpoint" {
|
||||
value = aws_opensearch_domain.main.endpoint
|
||||
description = "OpenSearch domain endpoint"
|
||||
}
|
||||
|
||||
output "dashboard_endpoint" {
|
||||
value = aws_opensearch_domain.main.dashboard_endpoint
|
||||
description = "OpenSearch Dashboards endpoint"
|
||||
}
|
||||
|
||||
output "domain_arn" {
|
||||
value = aws_opensearch_domain.main.arn
|
||||
description = "Domain ARN"
|
||||
}
|
||||
|
||||
output "domain_id" {
|
||||
value = aws_opensearch_domain.main.domain_id
|
||||
description = "Domain ID"
|
||||
}
|
||||
|
||||
output "secret_arn" {
|
||||
value = length(aws_secretsmanager_secret.opensearch) > 0 ? aws_secretsmanager_secret.opensearch[0].arn : null
|
||||
description = "Secrets Manager ARN for credentials"
|
||||
}
|
||||
|
||||
output "access_policy_arn" {
|
||||
value = aws_iam_policy.opensearch_access.arn
|
||||
description = "IAM policy for application access"
|
||||
}
|
||||
|
||||
output "kibana_url" {
|
||||
value = "https://${aws_opensearch_domain.main.dashboard_endpoint}/_dashboards"
|
||||
description = "OpenSearch Dashboards URL"
|
||||
}
|
||||
|
||||
output "curl_example" {
|
||||
value = local.enable_fine_grained_access ? <<-EOF
|
||||
# Get credentials from Secrets Manager
|
||||
SECRET=$(aws secretsmanager get-secret-value --secret-id ${aws_secretsmanager_secret.opensearch[0].arn} --query SecretString --output text)
|
||||
USER=$(echo $SECRET | jq -r .username)
|
||||
PASS=$(echo $SECRET | jq -r .password)
|
||||
|
||||
# Query cluster health
|
||||
curl -u "$USER:$PASS" "https://${aws_opensearch_domain.main.endpoint}/_cluster/health?pretty"
|
||||
EOF
|
||||
: null
|
||||
description = "Example curl commands"
|
||||
}
|
||||
544
terraform/05-workloads/_template/rds-database/main.tf
Normal file
544
terraform/05-workloads/_template/rds-database/main.tf
Normal file
@@ -0,0 +1,544 @@
|
||||
################################################################################
|
||||
# Workload: RDS Database
|
||||
#
|
||||
# Deploys a managed database:
|
||||
# - RDS PostgreSQL/MySQL instance or Aurora cluster
|
||||
# - Subnet group and security group
|
||||
# - Parameter group with optimized settings
|
||||
# - Secrets Manager for credentials
|
||||
# - Optional read replica
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<app>-db/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<APP>-db/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
app = "<APP>"
|
||||
env = "prod" # prod, staging, dev
|
||||
name = "${local.tenant}-${local.app}-${local.env}"
|
||||
|
||||
# Engine - "postgres", "mysql", "aurora-postgresql", "aurora-mysql"
|
||||
engine = "postgres"
|
||||
engine_version = "16.3"
|
||||
|
||||
# Instance sizing
|
||||
instance_class = "db.t3.micro" # db.t3.micro, db.t3.small, db.r6g.large, etc.
|
||||
storage_gb = 20
|
||||
max_storage_gb = 100 # Auto-scaling max (set to storage_gb to disable)
|
||||
|
||||
# High availability
|
||||
multi_az = false # true for prod
|
||||
read_replica = false # Create read replica
|
||||
|
||||
# Database config
|
||||
database_name = "app"
|
||||
port = 5432 # 5432 for postgres, 3306 for mysql
|
||||
|
||||
# Backup
|
||||
backup_retention_days = 7
|
||||
backup_window = "03:00-04:00" # UTC
|
||||
maintenance_window = "sun:04:00-sun:05:00"
|
||||
|
||||
# Deletion protection (disable for dev/test)
|
||||
deletion_protection = local.env == "prod"
|
||||
skip_final_snapshot = local.env != "prod"
|
||||
|
||||
# Performance Insights (free for 7 days retention)
|
||||
performance_insights = true
|
||||
|
||||
# IAM database authentication (recommended for apps)
|
||||
iam_auth_enabled = true
|
||||
|
||||
# Enhanced Monitoring interval (0 to disable, 1/5/10/15/30/60 seconds)
|
||||
monitoring_interval = local.env == "prod" ? 60 : 0
|
||||
|
||||
# Is this an Aurora cluster?
|
||||
is_aurora = startswith(local.engine, "aurora-")
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.app
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "network" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "02-network/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "tenant" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "04-tenants/${local.tenant}/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key for Encryption
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "db" {
|
||||
description = "KMS key for ${local.name} database encryption"
|
||||
deletion_window_in_days = 7
|
||||
enable_key_rotation = true
|
||||
|
||||
tags = { Name = "${local.name}-db" }
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "db" {
|
||||
name = "alias/${local.name}-db"
|
||||
target_key_id = aws_kms_key.db.key_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Random Password
|
||||
################################################################################
|
||||
|
||||
resource "random_password" "master" {
|
||||
length = 32
|
||||
special = true
|
||||
override_special = "!#$%&*()-_=+[]{}<>:?"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets Manager
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret" "db" {
|
||||
name = "${local.name}-db-credentials"
|
||||
description = "Database credentials for ${local.name}"
|
||||
recovery_window_in_days = local.env == "prod" ? 30 : 0
|
||||
|
||||
tags = { Name = "${local.name}-db-credentials" }
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "db" {
|
||||
secret_id = aws_secretsmanager_secret.db.id
|
||||
secret_string = jsonencode({
|
||||
username = "dbadmin"
|
||||
password = random_password.master.result
|
||||
engine = local.engine
|
||||
host = local.is_aurora ? aws_rds_cluster.main[0].endpoint : aws_db_instance.main[0].address
|
||||
port = local.port
|
||||
database = local.database_name
|
||||
url = local.is_aurora ? "postgresql://dbadmin:${random_password.master.result}@${aws_rds_cluster.main[0].endpoint}:${local.port}/${local.database_name}" : "postgresql://dbadmin:${random_password.master.result}@${aws_db_instance.main[0].address}:${local.port}/${local.database_name}"
|
||||
})
|
||||
|
||||
depends_on = [aws_db_instance.main, aws_rds_cluster.main]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Subnet Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_subnet_group" "main" {
|
||||
name = local.name
|
||||
description = "Subnet group for ${local.name}"
|
||||
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_security_group" "db" {
|
||||
name = "${local.name}-db"
|
||||
description = "Database ${local.name}"
|
||||
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "Database port from tenant"
|
||||
from_port = local.port
|
||||
to_port = local.port
|
||||
protocol = "tcp"
|
||||
security_groups = [data.terraform_remote_state.tenant.outputs.security_groups.base]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-db" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Parameter Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_parameter_group" "main" {
|
||||
count = local.is_aurora ? 0 : 1
|
||||
name = local.name
|
||||
family = "${local.engine}${split(".", local.engine_version)[0]}"
|
||||
|
||||
dynamic "parameter" {
|
||||
for_each = local.engine == "postgres" ? [
|
||||
{ name = "log_statement", value = "ddl" },
|
||||
{ name = "log_min_duration_statement", value = "1000" },
|
||||
{ name = "shared_preload_libraries", value = "pg_stat_statements", apply = "pending-reboot" }
|
||||
] : [
|
||||
{ name = "slow_query_log", value = "1" },
|
||||
{ name = "long_query_time", value = "1" }
|
||||
]
|
||||
content {
|
||||
name = parameter.value.name
|
||||
value = parameter.value.value
|
||||
apply_method = lookup(parameter.value, "apply", "immediate")
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_rds_cluster_parameter_group" "main" {
|
||||
count = local.is_aurora ? 1 : 0
|
||||
name = local.name
|
||||
family = local.engine == "aurora-postgresql" ? "aurora-postgresql16" : "aurora-mysql8.0"
|
||||
|
||||
dynamic "parameter" {
|
||||
for_each = local.engine == "aurora-postgresql" ? [
|
||||
{ name = "log_statement", value = "ddl" },
|
||||
{ name = "log_min_duration_statement", value = "1000" }
|
||||
] : [
|
||||
{ name = "slow_query_log", value = "1" },
|
||||
{ name = "long_query_time", value = "1" }
|
||||
]
|
||||
content {
|
||||
name = parameter.value.name
|
||||
value = parameter.value.value
|
||||
apply_method = lookup(parameter.value, "apply", "immediate")
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# RDS Instance (non-Aurora)
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_instance" "main" {
|
||||
count = local.is_aurora ? 0 : 1
|
||||
|
||||
identifier = local.name
|
||||
|
||||
engine = local.engine
|
||||
engine_version = local.engine_version
|
||||
instance_class = local.instance_class
|
||||
|
||||
allocated_storage = local.storage_gb
|
||||
max_allocated_storage = local.max_storage_gb
|
||||
storage_type = "gp3"
|
||||
storage_encrypted = true
|
||||
kms_key_id = aws_kms_key.db.arn
|
||||
|
||||
db_name = local.database_name
|
||||
username = "dbadmin"
|
||||
password = random_password.master.result
|
||||
port = local.port
|
||||
|
||||
multi_az = local.multi_az
|
||||
db_subnet_group_name = aws_db_subnet_group.main.name
|
||||
vpc_security_group_ids = [aws_security_group.db.id]
|
||||
parameter_group_name = aws_db_parameter_group.main[0].name
|
||||
publicly_accessible = false
|
||||
|
||||
# IAM authentication for better security
|
||||
iam_database_authentication_enabled = local.iam_auth_enabled
|
||||
|
||||
backup_retention_period = local.backup_retention_days
|
||||
backup_window = local.backup_window
|
||||
maintenance_window = local.maintenance_window
|
||||
copy_tags_to_snapshot = true
|
||||
|
||||
performance_insights_enabled = local.performance_insights
|
||||
performance_insights_retention_period = local.performance_insights ? 7 : null
|
||||
performance_insights_kms_key_id = local.performance_insights ? aws_kms_key.db.arn : null
|
||||
|
||||
# Enhanced monitoring
|
||||
monitoring_interval = local.monitoring_interval
|
||||
monitoring_role_arn = local.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null
|
||||
|
||||
deletion_protection = local.deletion_protection
|
||||
skip_final_snapshot = local.skip_final_snapshot
|
||||
final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.name}-final"
|
||||
|
||||
enabled_cloudwatch_logs_exports = local.engine == "postgres" ? ["postgresql", "upgrade"] : ["error", "slowquery"]
|
||||
|
||||
# Require TLS connections
|
||||
ca_cert_identifier = "rds-ca-rsa2048-g1"
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# RDS Read Replica (non-Aurora)
|
||||
################################################################################
|
||||
|
||||
resource "aws_db_instance" "replica" {
|
||||
count = !local.is_aurora && local.read_replica ? 1 : 0
|
||||
|
||||
identifier = "${local.name}-replica"
|
||||
replicate_source_db = aws_db_instance.main[0].identifier
|
||||
|
||||
instance_class = local.instance_class
|
||||
|
||||
vpc_security_group_ids = [aws_security_group.db.id]
|
||||
parameter_group_name = aws_db_parameter_group.main[0].name
|
||||
publicly_accessible = false
|
||||
|
||||
performance_insights_enabled = local.performance_insights
|
||||
performance_insights_retention_period = local.performance_insights ? 7 : null
|
||||
|
||||
skip_final_snapshot = true
|
||||
|
||||
tags = { Name = "${local.name}-replica" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Aurora Cluster
|
||||
################################################################################
|
||||
|
||||
resource "aws_rds_cluster" "main" {
|
||||
count = local.is_aurora ? 1 : 0
|
||||
|
||||
cluster_identifier = local.name
|
||||
|
||||
engine = local.engine
|
||||
engine_version = local.engine_version
|
||||
|
||||
database_name = local.database_name
|
||||
master_username = "dbadmin"
|
||||
master_password = random_password.master.result
|
||||
port = local.port
|
||||
|
||||
db_subnet_group_name = aws_db_subnet_group.main.name
|
||||
vpc_security_group_ids = [aws_security_group.db.id]
|
||||
db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.main[0].name
|
||||
|
||||
storage_encrypted = true
|
||||
kms_key_id = aws_kms_key.db.arn
|
||||
|
||||
# IAM authentication
|
||||
iam_database_authentication_enabled = local.iam_auth_enabled
|
||||
|
||||
backup_retention_period = local.backup_retention_days
|
||||
preferred_backup_window = local.backup_window
|
||||
preferred_maintenance_window = local.maintenance_window
|
||||
copy_tags_to_snapshot = true
|
||||
|
||||
deletion_protection = local.deletion_protection
|
||||
skip_final_snapshot = local.skip_final_snapshot
|
||||
final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.name}-final"
|
||||
|
||||
enabled_cloudwatch_logs_exports = local.engine == "aurora-postgresql" ? ["postgresql"] : ["error", "slowquery"]
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_rds_cluster_instance" "main" {
|
||||
count = local.is_aurora ? (local.multi_az ? 2 : 1) : 0
|
||||
|
||||
identifier = "${local.name}-${count.index}"
|
||||
cluster_identifier = aws_rds_cluster.main[0].id
|
||||
|
||||
engine = aws_rds_cluster.main[0].engine
|
||||
engine_version = aws_rds_cluster.main[0].engine_version
|
||||
instance_class = local.instance_class
|
||||
|
||||
publicly_accessible = false
|
||||
|
||||
performance_insights_enabled = local.performance_insights
|
||||
performance_insights_retention_period = local.performance_insights ? 7 : null
|
||||
|
||||
tags = { Name = "${local.name}-${count.index}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role for Enhanced Monitoring
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "rds_monitoring" {
|
||||
count = local.monitoring_interval > 0 ? 1 : 0
|
||||
name = "${local.name}-rds-monitoring"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "monitoring.rds.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.name}-rds-monitoring" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "rds_monitoring" {
|
||||
count = local.monitoring_interval > 0 ? 1 : 0
|
||||
role = aws_iam_role.rds_monitoring[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Alarms
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "cpu" {
|
||||
alarm_name = "${local.name}-cpu-high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 3
|
||||
metric_name = "CPUUtilization"
|
||||
namespace = "AWS/RDS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 80
|
||||
alarm_description = "Database CPU utilization high"
|
||||
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = local.is_aurora ? aws_rds_cluster_instance.main[0].identifier : aws_db_instance.main[0].identifier
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-cpu-high" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "storage" {
|
||||
count = local.is_aurora ? 0 : 1
|
||||
|
||||
alarm_name = "${local.name}-storage-low"
|
||||
comparison_operator = "LessThanThreshold"
|
||||
evaluation_periods = 1
|
||||
metric_name = "FreeStorageSpace"
|
||||
namespace = "AWS/RDS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 5368709120 # 5 GB
|
||||
alarm_description = "Database free storage space low"
|
||||
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = aws_db_instance.main[0].identifier
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-storage-low" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "connections" {
|
||||
alarm_name = "${local.name}-connections-high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "DatabaseConnections"
|
||||
namespace = "AWS/RDS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = 50 # Adjust based on instance class
|
||||
alarm_description = "Database connections high"
|
||||
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = local.is_aurora ? aws_rds_cluster_instance.main[0].identifier : aws_db_instance.main[0].identifier
|
||||
}
|
||||
|
||||
tags = { Name = "${local.name}-connections-high" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "endpoint" {
|
||||
value = local.is_aurora ? aws_rds_cluster.main[0].endpoint : aws_db_instance.main[0].address
|
||||
}
|
||||
|
||||
output "reader_endpoint" {
|
||||
value = local.is_aurora ? aws_rds_cluster.main[0].reader_endpoint : (local.read_replica ? aws_db_instance.replica[0].address : null)
|
||||
}
|
||||
|
||||
output "port" {
|
||||
value = local.port
|
||||
}
|
||||
|
||||
output "database_name" {
|
||||
value = local.database_name
|
||||
}
|
||||
|
||||
output "secret_arn" {
|
||||
value = aws_secretsmanager_secret.db.arn
|
||||
}
|
||||
|
||||
output "security_group_id" {
|
||||
value = aws_security_group.db.id
|
||||
}
|
||||
|
||||
output "connection_string_ssm" {
|
||||
value = "Retrieve from: aws secretsmanager get-secret-value --secret-id ${aws_secretsmanager_secret.db.name}"
|
||||
description = "Command to retrieve connection string"
|
||||
}
|
||||
527
terraform/05-workloads/_template/s3-bucket/main.tf
Normal file
527
terraform/05-workloads/_template/s3-bucket/main.tf
Normal file
@@ -0,0 +1,527 @@
|
||||
################################################################################
|
||||
# Workload: S3 Bucket
|
||||
#
|
||||
# Multi-purpose S3 bucket with:
|
||||
# - Versioning, encryption (KMS or S3)
|
||||
# - Lifecycle rules (tiering, expiration)
|
||||
# - Replication (cross-region DR)
|
||||
# - Access logging
|
||||
# - Event notifications (Lambda, SQS, SNS)
|
||||
# - Object Lock (compliance/governance)
|
||||
#
|
||||
# Use cases: Data lake, backups, artifacts, logs, media storage
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-bucket/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
bucket_name = "${local.tenant}-${local.name}-${local.env}-${data.aws_caller_identity.current.account_id}"
|
||||
|
||||
# Versioning
|
||||
versioning_enabled = true
|
||||
|
||||
# Encryption
|
||||
encryption_type = "SSE-S3" # SSE-S3, SSE-KMS, or KMS ARN
|
||||
kms_key_arn = null # Set if using SSE-KMS
|
||||
|
||||
# Public access (always blocked by default)
|
||||
block_public_access = true
|
||||
|
||||
# Access logging
|
||||
enable_logging = true
|
||||
logging_bucket = null # Set to existing logging bucket, or creates one
|
||||
logging_prefix = "s3-access-logs/${local.bucket_name}/"
|
||||
|
||||
# Lifecycle rules
|
||||
lifecycle_rules = {
|
||||
transition-to-ia = {
|
||||
enabled = true
|
||||
filter = {
|
||||
prefix = ""
|
||||
}
|
||||
transitions = [
|
||||
{
|
||||
days = 30
|
||||
storage_class = "STANDARD_IA"
|
||||
},
|
||||
{
|
||||
days = 90
|
||||
storage_class = "GLACIER"
|
||||
}
|
||||
]
|
||||
expiration_days = 365
|
||||
noncurrent_version_expiration_days = 90
|
||||
}
|
||||
}
|
||||
|
||||
# Cross-region replication
|
||||
enable_replication = false
|
||||
replication_region = "us-west-2"
|
||||
replication_bucket = null # Will create if null
|
||||
|
||||
# Event notifications
|
||||
lambda_notifications = {
|
||||
# "object-created" = {
|
||||
# lambda_arn = "arn:aws:lambda:..."
|
||||
# events = ["s3:ObjectCreated:*"]
|
||||
# prefix = "uploads/"
|
||||
# suffix = ".jpg"
|
||||
# }
|
||||
}
|
||||
|
||||
sqs_notifications = {
|
||||
# "new-files" = {
|
||||
# queue_arn = "arn:aws:sqs:..."
|
||||
# events = ["s3:ObjectCreated:*"]
|
||||
# }
|
||||
}
|
||||
|
||||
# Object Lock (for compliance - cannot be disabled once enabled)
|
||||
object_lock_enabled = false
|
||||
object_lock_mode = "GOVERNANCE" # GOVERNANCE or COMPLIANCE
|
||||
object_lock_days = 30
|
||||
|
||||
# CORS (for web access)
|
||||
cors_enabled = false
|
||||
cors_rules = [
|
||||
{
|
||||
allowed_headers = ["*"]
|
||||
allowed_methods = ["GET", "HEAD"]
|
||||
allowed_origins = ["*"]
|
||||
max_age_seconds = 3600
|
||||
}
|
||||
]
|
||||
|
||||
# Intelligent tiering
|
||||
intelligent_tiering_enabled = false
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
alias = "replication"
|
||||
region = local.replication_region
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# S3 Bucket
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "main" {
|
||||
bucket = local.bucket_name
|
||||
|
||||
dynamic "object_lock_configuration" {
|
||||
for_each = local.object_lock_enabled ? [1] : []
|
||||
content {
|
||||
object_lock_enabled = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = local.bucket_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Versioning
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_versioning" "main" {
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
versioning_configuration {
|
||||
status = local.versioning_enabled ? "Enabled" : "Suspended"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Encryption
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = local.encryption_type == "SSE-S3" ? "AES256" : "aws:kms"
|
||||
kms_master_key_id = local.encryption_type != "SSE-S3" ? (local.kms_key_arn != null ? local.kms_key_arn : null) : null
|
||||
}
|
||||
bucket_key_enabled = local.encryption_type != "SSE-S3"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Public Access Block
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_public_access_block" "main" {
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
block_public_acls = local.block_public_access
|
||||
block_public_policy = local.block_public_access
|
||||
ignore_public_acls = local.block_public_access
|
||||
restrict_public_buckets = local.block_public_access
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Access Logging
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "logs" {
|
||||
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
|
||||
bucket = "${local.bucket_name}-logs"
|
||||
|
||||
tags = { Name = "${local.bucket_name}-logs" }
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "logs" {
|
||||
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
|
||||
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = "AES256"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_public_access_block" "logs" {
|
||||
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
|
||||
count = local.enable_logging && local.logging_bucket == null ? 1 : 0
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
|
||||
rule {
|
||||
id = "expire-logs"
|
||||
status = "Enabled"
|
||||
|
||||
expiration {
|
||||
days = 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_logging" "main" {
|
||||
count = local.enable_logging ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
target_bucket = local.logging_bucket != null ? local.logging_bucket : aws_s3_bucket.logs[0].id
|
||||
target_prefix = local.logging_prefix
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Lifecycle Rules
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "main" {
|
||||
count = length(local.lifecycle_rules) > 0 ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
dynamic "rule" {
|
||||
for_each = local.lifecycle_rules
|
||||
content {
|
||||
id = rule.key
|
||||
status = rule.value.enabled ? "Enabled" : "Disabled"
|
||||
|
||||
filter {
|
||||
prefix = lookup(rule.value.filter, "prefix", "")
|
||||
}
|
||||
|
||||
dynamic "transition" {
|
||||
for_each = lookup(rule.value, "transitions", [])
|
||||
content {
|
||||
days = transition.value.days
|
||||
storage_class = transition.value.storage_class
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "expiration" {
|
||||
for_each = lookup(rule.value, "expiration_days", null) != null ? [1] : []
|
||||
content {
|
||||
days = rule.value.expiration_days
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "noncurrent_version_expiration" {
|
||||
for_each = lookup(rule.value, "noncurrent_version_expiration_days", null) != null ? [1] : []
|
||||
content {
|
||||
noncurrent_days = rule.value.noncurrent_version_expiration_days
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [aws_s3_bucket_versioning.main]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Intelligent Tiering
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_intelligent_tiering_configuration" "main" {
|
||||
count = local.intelligent_tiering_enabled ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
name = "EntireBucket"
|
||||
|
||||
tiering {
|
||||
access_tier = "DEEP_ARCHIVE_ACCESS"
|
||||
days = 180
|
||||
}
|
||||
|
||||
tiering {
|
||||
access_tier = "ARCHIVE_ACCESS"
|
||||
days = 90
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CORS
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_cors_configuration" "main" {
|
||||
count = local.cors_enabled ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
dynamic "cors_rule" {
|
||||
for_each = local.cors_rules
|
||||
content {
|
||||
allowed_headers = cors_rule.value.allowed_headers
|
||||
allowed_methods = cors_rule.value.allowed_methods
|
||||
allowed_origins = cors_rule.value.allowed_origins
|
||||
max_age_seconds = cors_rule.value.max_age_seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Object Lock
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_object_lock_configuration" "main" {
|
||||
count = local.object_lock_enabled ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
rule {
|
||||
default_retention {
|
||||
mode = local.object_lock_mode
|
||||
days = local.object_lock_days
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Event Notifications
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket_notification" "main" {
|
||||
count = length(local.lambda_notifications) > 0 || length(local.sqs_notifications) > 0 ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
|
||||
dynamic "lambda_function" {
|
||||
for_each = local.lambda_notifications
|
||||
content {
|
||||
lambda_function_arn = lambda_function.value.lambda_arn
|
||||
events = lambda_function.value.events
|
||||
filter_prefix = lookup(lambda_function.value, "prefix", null)
|
||||
filter_suffix = lookup(lambda_function.value, "suffix", null)
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "queue" {
|
||||
for_each = local.sqs_notifications
|
||||
content {
|
||||
queue_arn = queue.value.queue_arn
|
||||
events = queue.value.events
|
||||
filter_prefix = lookup(queue.value, "prefix", null)
|
||||
filter_suffix = lookup(queue.value, "suffix", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Replication
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "replica" {
|
||||
count = local.enable_replication && local.replication_bucket == null ? 1 : 0
|
||||
provider = aws.replication
|
||||
bucket = "${local.bucket_name}-replica"
|
||||
|
||||
tags = { Name = "${local.bucket_name}-replica" }
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "replica" {
|
||||
count = local.enable_replication && local.replication_bucket == null ? 1 : 0
|
||||
provider = aws.replication
|
||||
bucket = aws_s3_bucket.replica[0].id
|
||||
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "replication" {
|
||||
count = local.enable_replication ? 1 : 0
|
||||
name = "${local.bucket_name}-replication"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "s3.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "replication" {
|
||||
count = local.enable_replication ? 1 : 0
|
||||
name = "replication"
|
||||
role = aws_iam_role.replication[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:GetReplicationConfiguration",
|
||||
"s3:ListBucket"
|
||||
]
|
||||
Resource = aws_s3_bucket.main.arn
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:GetObjectVersionForReplication",
|
||||
"s3:GetObjectVersionAcl",
|
||||
"s3:GetObjectVersionTagging"
|
||||
]
|
||||
Resource = "${aws_s3_bucket.main.arn}/*"
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:ReplicateObject",
|
||||
"s3:ReplicateDelete",
|
||||
"s3:ReplicateTags"
|
||||
]
|
||||
Resource = "${local.replication_bucket != null ? local.replication_bucket : aws_s3_bucket.replica[0].arn}/*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_replication_configuration" "main" {
|
||||
count = local.enable_replication ? 1 : 0
|
||||
bucket = aws_s3_bucket.main.id
|
||||
role = aws_iam_role.replication[0].arn
|
||||
|
||||
rule {
|
||||
id = "replicate-all"
|
||||
status = "Enabled"
|
||||
|
||||
destination {
|
||||
bucket = local.replication_bucket != null ? local.replication_bucket : aws_s3_bucket.replica[0].arn
|
||||
storage_class = "STANDARD"
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [aws_s3_bucket_versioning.main]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "bucket_name" {
|
||||
value = aws_s3_bucket.main.id
|
||||
}
|
||||
|
||||
output "bucket_arn" {
|
||||
value = aws_s3_bucket.main.arn
|
||||
}
|
||||
|
||||
output "bucket_domain_name" {
|
||||
value = aws_s3_bucket.main.bucket_regional_domain_name
|
||||
}
|
||||
|
||||
output "replica_bucket" {
|
||||
value = local.enable_replication && local.replication_bucket == null ? aws_s3_bucket.replica[0].id : local.replication_bucket
|
||||
}
|
||||
|
||||
output "logging_bucket" {
|
||||
value = local.enable_logging && local.logging_bucket == null ? aws_s3_bucket.logs[0].id : local.logging_bucket
|
||||
}
|
||||
427
terraform/05-workloads/_template/secrets-manager/main.tf
Normal file
427
terraform/05-workloads/_template/secrets-manager/main.tf
Normal file
@@ -0,0 +1,427 @@
|
||||
################################################################################
|
||||
# Workload: Secrets Manager
|
||||
#
|
||||
# Secure secret storage:
|
||||
# - KMS encryption
|
||||
# - Automatic rotation (RDS, Redshift, DocumentDB, custom Lambda)
|
||||
# - Cross-account access policies
|
||||
# - Versioning and recovery
|
||||
# - Replication to other regions
|
||||
#
|
||||
# Use cases: DB credentials, API keys, certificates, config
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-secrets/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
prefix = "${local.tenant}/${local.env}"
|
||||
|
||||
# KMS encryption (null uses AWS managed key)
|
||||
kms_key_arn = null
|
||||
|
||||
# Recovery window (days) - 0 for immediate deletion
|
||||
recovery_window_days = 30
|
||||
|
||||
# Secrets to create
|
||||
secrets = {
|
||||
# Database credentials (auto-generated)
|
||||
"db/main" = {
|
||||
description = "Main database credentials"
|
||||
generate_password = true
|
||||
password_length = 32
|
||||
exclude_characters = "\"@/\\"
|
||||
secret_string_template = jsonencode({
|
||||
username = "admin"
|
||||
engine = "postgres"
|
||||
host = "db.example.internal"
|
||||
port = 5432
|
||||
dbname = "main"
|
||||
})
|
||||
# RDS rotation
|
||||
rotation = {
|
||||
enabled = false
|
||||
# lambda_arn = "arn:aws:lambda:..." # Rotation Lambda
|
||||
# days = 30
|
||||
}
|
||||
}
|
||||
|
||||
# API keys - Update via console or CLI after deployment:
|
||||
# aws secretsmanager put-secret-value --secret-id <arn> --secret-string '{"publishable_key":"pk_live_xxx","secret_key":"sk_live_xxx"}'
|
||||
"api/stripe" = {
|
||||
description = "Stripe API keys"
|
||||
secret_string = jsonencode({
|
||||
publishable_key = "pk_live_xxxxxxxxxxxx"
|
||||
secret_key = "sk_live_xxxxxxxxxxxx"
|
||||
})
|
||||
}
|
||||
|
||||
# Generic config
|
||||
"config/app" = {
|
||||
description = "Application configuration"
|
||||
secret_string = jsonencode({
|
||||
feature_flags = {
|
||||
new_checkout = true
|
||||
beta_features = false
|
||||
}
|
||||
limits = {
|
||||
max_upload_mb = 100
|
||||
rate_limit_rpm = 1000
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# Cross-account access
|
||||
allowed_accounts = [
|
||||
# "123456789012", # Dev account
|
||||
# "234567890123", # Staging account
|
||||
]
|
||||
|
||||
# IAM principals allowed to read secrets
|
||||
allowed_principals = [
|
||||
# "arn:aws:iam::123456789012:role/app-role",
|
||||
]
|
||||
|
||||
# Replication to other regions
|
||||
replica_regions = [
|
||||
# "us-west-2",
|
||||
# "eu-west-1",
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key (optional - for customer managed encryption)
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "secrets" {
|
||||
count = local.kms_key_arn == null ? 1 : 0
|
||||
|
||||
description = "KMS key for ${local.prefix} secrets"
|
||||
deletion_window_in_days = 30
|
||||
enable_key_rotation = true
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "Enable IAM policies"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = "kms:*"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "Allow Secrets Manager"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "secretsmanager.amazonaws.com"
|
||||
}
|
||||
Action = [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:ReEncrypt*",
|
||||
"kms:GenerateDataKey*",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.prefix}-secrets" }
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "secrets" {
|
||||
count = local.kms_key_arn == null ? 1 : 0
|
||||
name = "alias/${replace(local.prefix, "/", "-")}-secrets"
|
||||
target_key_id = aws_kms_key.secrets[0].key_id
|
||||
}
|
||||
|
||||
locals {
|
||||
effective_kms_key = local.kms_key_arn != null ? local.kms_key_arn : aws_kms_key.secrets[0].arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Random Passwords
|
||||
################################################################################
|
||||
|
||||
resource "random_password" "secrets" {
|
||||
for_each = { for k, v in local.secrets : k => v if lookup(v, "generate_password", false) }
|
||||
|
||||
length = lookup(each.value, "password_length", 32)
|
||||
special = true
|
||||
override_special = lookup(each.value, "override_special", "!#$%&*()-_=+[]{}<>:?")
|
||||
|
||||
# Exclude problematic characters
|
||||
min_lower = 1
|
||||
min_upper = 1
|
||||
min_numeric = 1
|
||||
min_special = 1
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret" "secrets" {
|
||||
for_each = local.secrets
|
||||
|
||||
name = "${local.prefix}/${each.key}"
|
||||
description = lookup(each.value, "description", "Secret for ${each.key}")
|
||||
kms_key_id = local.effective_kms_key
|
||||
|
||||
recovery_window_in_days = local.recovery_window_days
|
||||
|
||||
# Replication
|
||||
dynamic "replica" {
|
||||
for_each = local.replica_regions
|
||||
content {
|
||||
region = replica.value
|
||||
kms_key_id = null # Use default key in replica region
|
||||
}
|
||||
}
|
||||
|
||||
tags = { Name = "${local.prefix}/${each.key}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secret Values
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "secrets" {
|
||||
for_each = local.secrets
|
||||
|
||||
secret_id = aws_secretsmanager_secret.secrets[each.key].id
|
||||
|
||||
secret_string = lookup(each.value, "generate_password", false) ? jsonencode(merge(
|
||||
jsondecode(lookup(each.value, "secret_string_template", "{}")),
|
||||
{ password = random_password.secrets[each.key].result }
|
||||
)) : lookup(each.value, "secret_string", "{}")
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secret Rotation
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret_rotation" "secrets" {
|
||||
for_each = { for k, v in local.secrets : k => v if lookup(lookup(v, "rotation", {}), "enabled", false) }
|
||||
|
||||
secret_id = aws_secretsmanager_secret.secrets[each.key].id
|
||||
rotation_lambda_arn = each.value.rotation.lambda_arn
|
||||
|
||||
rotation_rules {
|
||||
automatically_after_days = lookup(each.value.rotation, "days", 30)
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Resource Policy (Cross-Account Access)
|
||||
################################################################################
|
||||
|
||||
resource "aws_secretsmanager_secret_policy" "cross_account" {
|
||||
for_each = length(local.allowed_accounts) > 0 || length(local.allowed_principals) > 0 ? local.secrets : {}
|
||||
|
||||
secret_arn = aws_secretsmanager_secret.secrets[each.key].arn
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
length(local.allowed_accounts) > 0 ? [{
|
||||
Sid = "AllowCrossAccountAccess"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for acct in local.allowed_accounts : "arn:aws:iam::${acct}:root"]
|
||||
}
|
||||
Action = [
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret"
|
||||
]
|
||||
Resource = "*"
|
||||
}] : [],
|
||||
length(local.allowed_principals) > 0 ? [{
|
||||
Sid = "AllowPrincipalAccess"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = local.allowed_principals
|
||||
}
|
||||
Action = [
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret"
|
||||
]
|
||||
Resource = "*"
|
||||
}] : []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Policy for Reading Secrets
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "read_secrets" {
|
||||
name = "${replace(local.prefix, "/", "-")}-secrets-read"
|
||||
description = "Read access to ${local.prefix} secrets"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "GetSecrets"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret"
|
||||
]
|
||||
Resource = [for s in aws_secretsmanager_secret.secrets : s.arn]
|
||||
},
|
||||
{
|
||||
Sid = "DecryptSecrets"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"kms:Decrypt",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = [local.effective_kms_key]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.prefix}-secrets-read" }
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "write_secrets" {
|
||||
name = "${replace(local.prefix, "/", "-")}-secrets-write"
|
||||
description = "Write access to ${local.prefix} secrets"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "ManageSecrets"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DescribeSecret",
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:UpdateSecret"
|
||||
]
|
||||
Resource = [for s in aws_secretsmanager_secret.secrets : s.arn]
|
||||
},
|
||||
{
|
||||
Sid = "EncryptDecryptSecrets"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:DescribeKey"
|
||||
]
|
||||
Resource = [local.effective_kms_key]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.prefix}-secrets-write" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "secret_arns" {
|
||||
value = { for k, v in aws_secretsmanager_secret.secrets : k => v.arn }
|
||||
description = "Secret ARNs"
|
||||
}
|
||||
|
||||
output "secret_names" {
|
||||
value = { for k, v in aws_secretsmanager_secret.secrets : k => v.name }
|
||||
description = "Secret names"
|
||||
}
|
||||
|
||||
output "kms_key_arn" {
|
||||
value = local.effective_kms_key
|
||||
description = "KMS key ARN used for encryption"
|
||||
}
|
||||
|
||||
output "read_policy_arn" {
|
||||
value = aws_iam_policy.read_secrets.arn
|
||||
description = "IAM policy ARN for reading secrets"
|
||||
}
|
||||
|
||||
output "write_policy_arn" {
|
||||
value = aws_iam_policy.write_secrets.arn
|
||||
description = "IAM policy ARN for writing secrets"
|
||||
}
|
||||
|
||||
output "secret_retrieval_commands" {
|
||||
value = {
|
||||
for k, v in aws_secretsmanager_secret.secrets : k =>
|
||||
"aws secretsmanager get-secret-value --secret-id ${v.name} --query SecretString --output text | jq ."
|
||||
}
|
||||
description = "CLI commands to retrieve each secret"
|
||||
}
|
||||
524
terraform/05-workloads/_template/ses-email/main.tf
Normal file
524
terraform/05-workloads/_template/ses-email/main.tf
Normal file
@@ -0,0 +1,524 @@
|
||||
################################################################################
|
||||
# Workload: SES Email Configuration
|
||||
#
|
||||
# Email sending infrastructure:
|
||||
# - Domain identity with DKIM
|
||||
# - Email identities for sending
|
||||
# - Configuration sets with tracking
|
||||
# - Event destinations (CloudWatch, SNS, Kinesis)
|
||||
# - Dedicated IP pools (optional)
|
||||
# - Suppression list management
|
||||
#
|
||||
# Use cases: Transactional email, marketing campaigns, notifications
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-email/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
config_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# Domain to verify (required)
|
||||
domain = "example.com"
|
||||
hosted_zone_id = null # Route53 zone ID for automatic DNS verification
|
||||
|
||||
# Additional email identities
|
||||
email_identities = [
|
||||
# "noreply@example.com",
|
||||
# "support@example.com",
|
||||
]
|
||||
|
||||
# MAIL FROM domain (optional custom subdomain)
|
||||
mail_from_subdomain = "mail" # Results in mail.example.com
|
||||
|
||||
# DMARC record
|
||||
enable_dmarc = true
|
||||
dmarc_policy = "none" # none, quarantine, reject
|
||||
dmarc_rua = null # Aggregate report email, e.g., "mailto:dmarc@example.com"
|
||||
|
||||
# Configuration set (for tracking)
|
||||
enable_config_set = true
|
||||
|
||||
# Event tracking
|
||||
tracking_options = {
|
||||
click = true
|
||||
open = true
|
||||
bounce = true
|
||||
complaint = true
|
||||
delivery = true
|
||||
reject = true
|
||||
send = true
|
||||
}
|
||||
|
||||
# Event destinations
|
||||
cloudwatch_destination = true
|
||||
sns_destination = true
|
||||
|
||||
# Reputation metrics
|
||||
reputation_metrics_enabled = true
|
||||
|
||||
# Sending quotas (request increase via AWS support)
|
||||
# These are informational - actual limits set by AWS
|
||||
|
||||
# Suppression list
|
||||
suppression_list_reasons = ["BOUNCE", "COMPLAINT"]
|
||||
|
||||
# Dedicated IPs (additional cost)
|
||||
enable_dedicated_ips = false
|
||||
dedicated_ip_count = 0
|
||||
|
||||
# IAM policy for sending
|
||||
create_sending_role = true
|
||||
sending_role_name = "${local.config_name}-ses-sender"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Domain Identity
|
||||
################################################################################
|
||||
|
||||
resource "aws_ses_domain_identity" "main" {
|
||||
domain = local.domain
|
||||
}
|
||||
|
||||
resource "aws_ses_domain_dkim" "main" {
|
||||
domain = aws_ses_domain_identity.main.domain
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# DNS Records (if hosted zone provided)
|
||||
################################################################################
|
||||
|
||||
# Domain verification
|
||||
resource "aws_route53_record" "ses_verification" {
|
||||
count = local.hosted_zone_id != null ? 1 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = "_amazonses.${local.domain}"
|
||||
type = "TXT"
|
||||
ttl = 600
|
||||
records = [aws_ses_domain_identity.main.verification_token]
|
||||
}
|
||||
|
||||
# DKIM records
|
||||
resource "aws_route53_record" "dkim" {
|
||||
count = local.hosted_zone_id != null ? 3 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey.${local.domain}"
|
||||
type = "CNAME"
|
||||
ttl = 600
|
||||
records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"]
|
||||
}
|
||||
|
||||
# MAIL FROM domain
|
||||
resource "aws_ses_domain_mail_from" "main" {
|
||||
domain = aws_ses_domain_identity.main.domain
|
||||
mail_from_domain = "${local.mail_from_subdomain}.${local.domain}"
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "mail_from_mx" {
|
||||
count = local.hosted_zone_id != null ? 1 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = "${local.mail_from_subdomain}.${local.domain}"
|
||||
type = "MX"
|
||||
ttl = 600
|
||||
records = ["10 feedback-smtp.${data.aws_region.current.name}.amazonses.com"]
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "mail_from_spf" {
|
||||
count = local.hosted_zone_id != null ? 1 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = "${local.mail_from_subdomain}.${local.domain}"
|
||||
type = "TXT"
|
||||
ttl = 600
|
||||
records = ["v=spf1 include:amazonses.com ~all"]
|
||||
}
|
||||
|
||||
# DMARC record
|
||||
resource "aws_route53_record" "dmarc" {
|
||||
count = local.hosted_zone_id != null && local.enable_dmarc ? 1 : 0
|
||||
zone_id = local.hosted_zone_id
|
||||
name = "_dmarc.${local.domain}"
|
||||
type = "TXT"
|
||||
ttl = 600
|
||||
records = [
|
||||
local.dmarc_rua != null
|
||||
? "v=DMARC1; p=${local.dmarc_policy}; rua=${local.dmarc_rua}"
|
||||
: "v=DMARC1; p=${local.dmarc_policy}"
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Email Identities
|
||||
################################################################################
|
||||
|
||||
resource "aws_ses_email_identity" "identities" {
|
||||
for_each = toset(local.email_identities)
|
||||
email = each.value
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration Set
|
||||
################################################################################
|
||||
|
||||
resource "aws_ses_configuration_set" "main" {
|
||||
count = local.enable_config_set ? 1 : 0
|
||||
name = local.config_name
|
||||
|
||||
reputation_metrics_enabled = local.reputation_metrics_enabled
|
||||
|
||||
delivery_options {
|
||||
tls_policy = "REQUIRE"
|
||||
}
|
||||
|
||||
tracking_options {
|
||||
custom_redirect_domain = null
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic for Events
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "ses_events" {
|
||||
count = local.sns_destination ? 1 : 0
|
||||
name = "${local.config_name}-ses-events"
|
||||
kms_master_key_id = "alias/aws/sns"
|
||||
|
||||
tags = { Name = "${local.config_name}-ses-events" }
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_policy" "ses_events" {
|
||||
count = local.sns_destination ? 1 : 0
|
||||
arn = aws_sns_topic.ses_events[0].arn
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "AllowSES"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "ses.amazonaws.com"
|
||||
}
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.ses_events[0].arn
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"AWS:SourceAccount" = data.aws_caller_identity.current.account_id
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Event Destinations
|
||||
################################################################################
|
||||
|
||||
resource "aws_ses_event_destination" "cloudwatch" {
|
||||
count = local.enable_config_set && local.cloudwatch_destination ? 1 : 0
|
||||
name = "cloudwatch"
|
||||
configuration_set_name = aws_ses_configuration_set.main[0].name
|
||||
enabled = true
|
||||
|
||||
matching_types = compact([
|
||||
local.tracking_options.bounce ? "bounce" : "",
|
||||
local.tracking_options.complaint ? "complaint" : "",
|
||||
local.tracking_options.delivery ? "delivery" : "",
|
||||
local.tracking_options.send ? "send" : "",
|
||||
local.tracking_options.reject ? "reject" : "",
|
||||
local.tracking_options.open ? "open" : "",
|
||||
local.tracking_options.click ? "click" : "",
|
||||
])
|
||||
|
||||
cloudwatch_destination {
|
||||
default_value = "default"
|
||||
dimension_name = "ses:source-ip"
|
||||
value_source = "messageTag"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ses_event_destination" "sns" {
|
||||
count = local.enable_config_set && local.sns_destination ? 1 : 0
|
||||
name = "sns"
|
||||
configuration_set_name = aws_ses_configuration_set.main[0].name
|
||||
enabled = true
|
||||
|
||||
matching_types = ["bounce", "complaint"]
|
||||
|
||||
sns_destination {
|
||||
topic_arn = aws_sns_topic.ses_events[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role for Sending
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "sending" {
|
||||
count = local.create_sending_role ? 1 : 0
|
||||
name = local.sending_role_name
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = [
|
||||
"lambda.amazonaws.com",
|
||||
"ecs-tasks.amazonaws.com",
|
||||
"ec2.amazonaws.com"
|
||||
]
|
||||
}
|
||||
Action = "sts:AssumeRole"
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = local.sending_role_name }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "sending" {
|
||||
count = local.create_sending_role ? 1 : 0
|
||||
name = "ses-sending"
|
||||
role = aws_iam_role.sending[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "SendEmail"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ses:SendEmail",
|
||||
"ses:SendRawEmail",
|
||||
"ses:SendTemplatedEmail",
|
||||
"ses:SendBulkTemplatedEmail"
|
||||
]
|
||||
Resource = "*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"ses:FromAddress" = [for e in local.email_identities : e]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "UseConfigSet"
|
||||
Effect = "Allow"
|
||||
Action = ["ses:SendEmail", "ses:SendRawEmail"]
|
||||
Resource = local.enable_config_set ? aws_ses_configuration_set.main[0].arn : "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SMTP Credentials (for apps that use SMTP)
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_user" "smtp" {
|
||||
name = "${local.config_name}-smtp"
|
||||
tags = { Name = "${local.config_name}-smtp" }
|
||||
}
|
||||
|
||||
resource "aws_iam_user_policy" "smtp" {
|
||||
name = "ses-smtp"
|
||||
user = aws_iam_user.smtp.name
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "ses:SendRawEmail"
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_access_key" "smtp" {
|
||||
user = aws_iam_user.smtp.name
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Email Templates (Examples)
|
||||
################################################################################
|
||||
|
||||
resource "aws_ses_template" "welcome" {
|
||||
name = "${local.config_name}-welcome"
|
||||
subject = "Welcome to {{company_name}}!"
|
||||
html = <<-HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<h1>Welcome, {{name}}!</h1>
|
||||
<p>Thank you for signing up for {{company_name}}.</p>
|
||||
<p>Click <a href="{{verification_link}}">here</a> to verify your email.</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
text = <<-TEXT
|
||||
Welcome, {{name}}!
|
||||
|
||||
Thank you for signing up for {{company_name}}.
|
||||
|
||||
Click the link below to verify your email:
|
||||
{{verification_link}}
|
||||
TEXT
|
||||
}
|
||||
|
||||
resource "aws_ses_template" "password_reset" {
|
||||
name = "${local.config_name}-password-reset"
|
||||
subject = "Reset your {{company_name}} password"
|
||||
html = <<-HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<h1>Password Reset Request</h1>
|
||||
<p>Hi {{name}},</p>
|
||||
<p>Click <a href="{{reset_link}}">here</a> to reset your password.</p>
|
||||
<p>This link expires in {{expiry_hours}} hours.</p>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
text = <<-TEXT
|
||||
Password Reset Request
|
||||
|
||||
Hi {{name}},
|
||||
|
||||
Click the link below to reset your password:
|
||||
{{reset_link}}
|
||||
|
||||
This link expires in {{expiry_hours}} hours.
|
||||
|
||||
If you didn't request this, please ignore this email.
|
||||
TEXT
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "domain_identity_arn" {
|
||||
value = aws_ses_domain_identity.main.arn
|
||||
}
|
||||
|
||||
output "domain_verification_token" {
|
||||
value = aws_ses_domain_identity.main.verification_token
|
||||
}
|
||||
|
||||
output "dkim_tokens" {
|
||||
value = aws_ses_domain_dkim.main.dkim_tokens
|
||||
}
|
||||
|
||||
output "configuration_set" {
|
||||
value = local.enable_config_set ? aws_ses_configuration_set.main[0].name : null
|
||||
}
|
||||
|
||||
output "sns_topic_arn" {
|
||||
value = local.sns_destination ? aws_sns_topic.ses_events[0].arn : null
|
||||
}
|
||||
|
||||
output "sending_role_arn" {
|
||||
value = local.create_sending_role ? aws_iam_role.sending[0].arn : null
|
||||
}
|
||||
|
||||
output "smtp_credentials" {
|
||||
value = {
|
||||
username = aws_iam_access_key.smtp.id
|
||||
password = aws_iam_access_key.smtp.ses_smtp_password_v4
|
||||
endpoint = "email-smtp.${data.aws_region.current.name}.amazonaws.com"
|
||||
port = 587
|
||||
}
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "dns_records_required" {
|
||||
value = local.hosted_zone_id == null ? {
|
||||
verification = {
|
||||
name = "_amazonses.${local.domain}"
|
||||
type = "TXT"
|
||||
value = aws_ses_domain_identity.main.verification_token
|
||||
}
|
||||
dkim = [
|
||||
for i, token in aws_ses_domain_dkim.main.dkim_tokens : {
|
||||
name = "${token}._domainkey.${local.domain}"
|
||||
type = "CNAME"
|
||||
value = "${token}.dkim.amazonses.com"
|
||||
}
|
||||
]
|
||||
mail_from_mx = {
|
||||
name = "${local.mail_from_subdomain}.${local.domain}"
|
||||
type = "MX"
|
||||
value = "10 feedback-smtp.${data.aws_region.current.name}.amazonses.com"
|
||||
}
|
||||
mail_from_spf = {
|
||||
name = "${local.mail_from_subdomain}.${local.domain}"
|
||||
type = "TXT"
|
||||
value = "v=spf1 include:amazonses.com ~all"
|
||||
}
|
||||
} : "DNS records created automatically"
|
||||
}
|
||||
|
||||
output "templates" {
|
||||
value = {
|
||||
welcome = aws_ses_template.welcome.name
|
||||
password_reset = aws_ses_template.password_reset.name
|
||||
}
|
||||
}
|
||||
383
terraform/05-workloads/_template/sns-topic/main.tf
Normal file
383
terraform/05-workloads/_template/sns-topic/main.tf
Normal file
@@ -0,0 +1,383 @@
|
||||
################################################################################
|
||||
# Workload: SNS Topic
|
||||
#
|
||||
# Pub/Sub messaging with:
|
||||
# - Multiple subscription types (Lambda, SQS, HTTP, Email, SMS)
|
||||
# - Message filtering
|
||||
# - Dead letter queue
|
||||
# - KMS encryption
|
||||
# - Cross-account publishing
|
||||
# - FIFO topics (ordered, exactly-once)
|
||||
#
|
||||
# Use cases: Event fan-out, notifications, decoupling services
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-sns/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
topic_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# FIFO topic (ordered, exactly-once delivery)
|
||||
fifo_topic = false
|
||||
content_based_deduplication = false
|
||||
|
||||
# Encryption
|
||||
kms_key_arn = null # null = AWS managed key
|
||||
|
||||
# Message delivery settings
|
||||
delivery_policy = {
|
||||
http = {
|
||||
defaultHealthyRetryPolicy = {
|
||||
minDelayTarget = 20
|
||||
maxDelayTarget = 20
|
||||
numRetries = 3
|
||||
numMaxDelayRetries = 0
|
||||
numNoDelayRetries = 0
|
||||
numMinDelayRetries = 0
|
||||
backoffFunction = "linear"
|
||||
}
|
||||
disableSubscriptionOverrides = false
|
||||
}
|
||||
}
|
||||
|
||||
# Subscriptions
|
||||
subscriptions = {
|
||||
# Lambda subscription
|
||||
# "process-events" = {
|
||||
# protocol = "lambda"
|
||||
# endpoint = "arn:aws:lambda:us-east-1:123456789012:function:process-events"
|
||||
# filter_policy = {
|
||||
# event_type = ["order.created", "order.updated"]
|
||||
# }
|
||||
# }
|
||||
|
||||
# SQS subscription
|
||||
# "event-queue" = {
|
||||
# protocol = "sqs"
|
||||
# endpoint = "arn:aws:sqs:us-east-1:123456789012:event-queue"
|
||||
# raw_message_delivery = true
|
||||
# }
|
||||
|
||||
# Email subscription
|
||||
# "alerts" = {
|
||||
# protocol = "email"
|
||||
# endpoint = "alerts@example.com"
|
||||
# }
|
||||
|
||||
# HTTP/HTTPS subscription
|
||||
# "webhook" = {
|
||||
# protocol = "https"
|
||||
# endpoint = "https://api.example.com/webhook"
|
||||
# filter_policy = {
|
||||
# severity = ["high", "critical"]
|
||||
# }
|
||||
# }
|
||||
}
|
||||
|
||||
# Cross-account publish access
|
||||
publish_accounts = [
|
||||
# "123456789012",
|
||||
]
|
||||
|
||||
# Cross-account subscribe access
|
||||
subscribe_accounts = [
|
||||
# "234567890123",
|
||||
]
|
||||
|
||||
# AWS service publish access
|
||||
aws_service_principals = [
|
||||
# "events.amazonaws.com", # EventBridge
|
||||
# "cloudwatch.amazonaws.com", # CloudWatch Alarms
|
||||
# "s3.amazonaws.com", # S3 Event Notifications
|
||||
# "ses.amazonaws.com", # SES Notifications
|
||||
]
|
||||
|
||||
# Dead letter queue for failed deliveries
|
||||
enable_dlq = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "main" {
|
||||
name = local.fifo_topic ? "${local.topic_name}.fifo" : local.topic_name
|
||||
|
||||
fifo_topic = local.fifo_topic
|
||||
content_based_deduplication = local.fifo_topic ? local.content_based_deduplication : null
|
||||
|
||||
kms_master_key_id = local.kms_key_arn != null ? local.kms_key_arn : "alias/aws/sns"
|
||||
|
||||
delivery_policy = jsonencode(local.delivery_policy)
|
||||
|
||||
tags = { Name = local.topic_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Dead Letter Queue
|
||||
################################################################################
|
||||
|
||||
resource "aws_sqs_queue" "dlq" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
name = local.fifo_topic ? "${local.topic_name}-dlq.fifo" : "${local.topic_name}-dlq"
|
||||
|
||||
fifo_queue = local.fifo_topic
|
||||
content_based_deduplication = local.fifo_topic
|
||||
|
||||
message_retention_seconds = 1209600 # 14 days
|
||||
kms_master_key_id = "alias/aws/sqs"
|
||||
|
||||
tags = { Name = "${local.topic_name}-dlq" }
|
||||
}
|
||||
|
||||
resource "aws_sqs_queue_policy" "dlq" {
|
||||
count = local.enable_dlq ? 1 : 0
|
||||
queue_url = aws_sqs_queue.dlq[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "AllowSNS"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "sns.amazonaws.com"
|
||||
}
|
||||
Action = "sqs:SendMessage"
|
||||
Resource = aws_sqs_queue.dlq[0].arn
|
||||
Condition = {
|
||||
ArnEquals = {
|
||||
"aws:SourceArn" = aws_sns_topic.main.arn
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Topic Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic_policy" "main" {
|
||||
arn = aws_sns_topic.main.arn
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = concat(
|
||||
# Allow account root
|
||||
[{
|
||||
Sid = "DefaultPolicy"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = [
|
||||
"sns:Publish",
|
||||
"sns:Subscribe",
|
||||
"sns:Receive",
|
||||
"sns:ListSubscriptionsByTopic",
|
||||
"sns:GetTopicAttributes"
|
||||
]
|
||||
Resource = aws_sns_topic.main.arn
|
||||
}],
|
||||
|
||||
# Cross-account publish
|
||||
length(local.publish_accounts) > 0 ? [{
|
||||
Sid = "CrossAccountPublish"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for acct in local.publish_accounts : "arn:aws:iam::${acct}:root"]
|
||||
}
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.main.arn
|
||||
}] : [],
|
||||
|
||||
# Cross-account subscribe
|
||||
length(local.subscribe_accounts) > 0 ? [{
|
||||
Sid = "CrossAccountSubscribe"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = [for acct in local.subscribe_accounts : "arn:aws:iam::${acct}:root"]
|
||||
}
|
||||
Action = "sns:Subscribe"
|
||||
Resource = aws_sns_topic.main.arn
|
||||
}] : [],
|
||||
|
||||
# AWS service access
|
||||
length(local.aws_service_principals) > 0 ? [{
|
||||
Sid = "AWSServicePublish"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = local.aws_service_principals
|
||||
}
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.main.arn
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
|
||||
}
|
||||
}
|
||||
}] : []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Subscriptions
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic_subscription" "subscriptions" {
|
||||
for_each = local.subscriptions
|
||||
|
||||
topic_arn = aws_sns_topic.main.arn
|
||||
protocol = each.value.protocol
|
||||
endpoint = each.value.endpoint
|
||||
|
||||
filter_policy = lookup(each.value, "filter_policy", null) != null ? jsonencode(each.value.filter_policy) : null
|
||||
filter_policy_scope = lookup(each.value, "filter_policy", null) != null ? "MessageAttributes" : null
|
||||
raw_message_delivery = lookup(each.value, "raw_message_delivery", false)
|
||||
|
||||
redrive_policy = local.enable_dlq ? jsonencode({
|
||||
deadLetterTargetArn = aws_sqs_queue.dlq[0].arn
|
||||
}) : null
|
||||
}
|
||||
|
||||
# Lambda permissions for SNS to invoke
|
||||
resource "aws_lambda_permission" "sns" {
|
||||
for_each = { for k, v in local.subscriptions : k => v if v.protocol == "lambda" }
|
||||
|
||||
statement_id = "AllowSNS-${each.key}"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = regex("function:([^:]+)$", each.value.endpoint)[0]
|
||||
principal = "sns.amazonaws.com"
|
||||
source_arn = aws_sns_topic.main.arn
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Policies
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "publish" {
|
||||
name = "${local.topic_name}-sns-publish"
|
||||
description = "Publish to ${local.topic_name} SNS topic"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "PublishToTopic"
|
||||
Effect = "Allow"
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.main.arn
|
||||
},
|
||||
{
|
||||
Sid = "DecryptKMS"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey"
|
||||
]
|
||||
Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/sns"]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.topic_name}-publish" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "topic_arn" {
|
||||
value = aws_sns_topic.main.arn
|
||||
description = "SNS topic ARN"
|
||||
}
|
||||
|
||||
output "topic_name" {
|
||||
value = aws_sns_topic.main.name
|
||||
description = "SNS topic name"
|
||||
}
|
||||
|
||||
output "dlq_arn" {
|
||||
value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
|
||||
description = "Dead letter queue ARN"
|
||||
}
|
||||
|
||||
output "dlq_url" {
|
||||
value = local.enable_dlq ? aws_sqs_queue.dlq[0].url : null
|
||||
description = "Dead letter queue URL"
|
||||
}
|
||||
|
||||
output "publish_policy_arn" {
|
||||
value = aws_iam_policy.publish.arn
|
||||
description = "IAM policy ARN for publishing"
|
||||
}
|
||||
|
||||
output "subscription_arns" {
|
||||
value = { for k, v in aws_sns_topic_subscription.subscriptions : k => v.arn }
|
||||
description = "Subscription ARNs"
|
||||
}
|
||||
|
||||
output "publish_example" {
|
||||
value = "aws sns publish --topic-arn ${aws_sns_topic.main.arn} --message '{\"event\": \"test\"}' --message-attributes '{\"event_type\": {\"DataType\": \"String\", \"StringValue\": \"test\"}}'"
|
||||
description = "Example publish command"
|
||||
}
|
||||
379
terraform/05-workloads/_template/sqs-queue/main.tf
Normal file
379
terraform/05-workloads/_template/sqs-queue/main.tf
Normal file
@@ -0,0 +1,379 @@
|
||||
################################################################################
|
||||
# Workload: SQS Queue
|
||||
#
|
||||
# Deploys a managed message queue:
|
||||
# - Main queue with DLQ (dead letter queue)
|
||||
# - Server-side encryption
|
||||
# - CloudWatch alarms
|
||||
# - Optional FIFO support
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<queue-name>-queue/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-queue/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod" # prod, staging, dev
|
||||
|
||||
# Queue name (will add .fifo suffix if FIFO enabled)
|
||||
queue_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# FIFO queue (exactly-once processing, ordered)
|
||||
fifo_queue = false
|
||||
content_based_deduplication = false # Only for FIFO
|
||||
|
||||
# Message settings
|
||||
message_retention_seconds = 1209600 # 14 days (max)
|
||||
max_message_size = 262144 # 256 KB (max)
|
||||
delay_seconds = 0 # Delay before message becomes visible
|
||||
receive_wait_time_seconds = 20 # Long polling (cost efficient)
|
||||
|
||||
# Visibility timeout (should be > consumer processing time)
|
||||
visibility_timeout_seconds = 300 # 5 minutes
|
||||
|
||||
# Dead letter queue settings
|
||||
max_receive_count = 3 # Messages go to DLQ after this many failed receives
|
||||
dlq_retention_days = 14
|
||||
|
||||
# Alarm thresholds
|
||||
alarm_age_threshold = 300 # 5 minutes - message age alarm
|
||||
alarm_messages_threshold = 1000 # Queue depth alarm
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "tenant" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "04-tenants/${local.tenant}/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# KMS Key
|
||||
################################################################################
|
||||
|
||||
resource "aws_kms_key" "sqs" {
|
||||
description = "KMS key for ${local.queue_name} SQS encryption"
|
||||
deletion_window_in_days = 7
|
||||
enable_key_rotation = true
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "Enable IAM User Permissions"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = "kms:*"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "Allow SQS Service"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "sqs.amazonaws.com"
|
||||
}
|
||||
Action = [
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "Allow SNS to use this key"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "sns.amazonaws.com"
|
||||
}
|
||||
Action = [
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey"
|
||||
]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.queue_name}-sqs" }
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "sqs" {
|
||||
name = "alias/${local.queue_name}-sqs"
|
||||
target_key_id = aws_kms_key.sqs.key_id
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Dead Letter Queue
|
||||
################################################################################
|
||||
|
||||
resource "aws_sqs_queue" "dlq" {
|
||||
name = local.fifo_queue ? "${local.queue_name}-dlq.fifo" : "${local.queue_name}-dlq"
|
||||
|
||||
fifo_queue = local.fifo_queue
|
||||
|
||||
message_retention_seconds = local.dlq_retention_days * 86400
|
||||
kms_master_key_id = aws_kms_key.sqs.id
|
||||
kms_data_key_reuse_period_seconds = 86400 # 24 hours
|
||||
|
||||
tags = { Name = "${local.queue_name}-dlq" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Main Queue
|
||||
################################################################################
|
||||
|
||||
resource "aws_sqs_queue" "main" {
|
||||
name = local.fifo_queue ? "${local.queue_name}.fifo" : local.queue_name
|
||||
|
||||
fifo_queue = local.fifo_queue
|
||||
content_based_deduplication = local.fifo_queue ? local.content_based_deduplication : null
|
||||
|
||||
message_retention_seconds = local.message_retention_seconds
|
||||
max_message_size = local.max_message_size
|
||||
delay_seconds = local.delay_seconds
|
||||
receive_message_wait_time_seconds = local.receive_wait_time_seconds
|
||||
visibility_timeout_seconds = local.visibility_timeout_seconds
|
||||
|
||||
# Encryption
|
||||
kms_master_key_id = aws_kms_key.sqs.id
|
||||
kms_data_key_reuse_period_seconds = 86400 # 24 hours
|
||||
|
||||
# Dead letter queue
|
||||
redrive_policy = jsonencode({
|
||||
deadLetterTargetArn = aws_sqs_queue.dlq.arn
|
||||
maxReceiveCount = local.max_receive_count
|
||||
})
|
||||
|
||||
tags = { Name = local.queue_name }
|
||||
}
|
||||
|
||||
# Allow DLQ redrive
|
||||
resource "aws_sqs_queue_redrive_allow_policy" "dlq" {
|
||||
queue_url = aws_sqs_queue.dlq.id
|
||||
|
||||
redrive_allow_policy = jsonencode({
|
||||
redrivePermission = "byQueue"
|
||||
sourceQueueArns = [aws_sqs_queue.main.arn]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Queue Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_sqs_queue_policy" "main" {
|
||||
queue_url = aws_sqs_queue.main.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowTenantAccess"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
}
|
||||
Action = [
|
||||
"sqs:SendMessage",
|
||||
"sqs:ReceiveMessage",
|
||||
"sqs:DeleteMessage",
|
||||
"sqs:GetQueueAttributes",
|
||||
"sqs:GetQueueUrl"
|
||||
]
|
||||
Resource = aws_sqs_queue.main.arn
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:PrincipalTag/Tenant" = local.tenant
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "DenyInsecureTransport"
|
||||
Effect = "Deny"
|
||||
Principal = "*"
|
||||
Action = "sqs:*"
|
||||
Resource = aws_sqs_queue.main.arn
|
||||
Condition = {
|
||||
Bool = {
|
||||
"aws:SecureTransport" = "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic for Alarms
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "alarms" {
|
||||
name = "${local.queue_name}-alarms"
|
||||
|
||||
tags = { Name = "${local.queue_name}-alarms" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Alarms
|
||||
################################################################################
|
||||
|
||||
# Queue depth alarm
|
||||
resource "aws_cloudwatch_metric_alarm" "depth" {
|
||||
alarm_name = "${local.queue_name}-depth"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "ApproximateNumberOfMessagesVisible"
|
||||
namespace = "AWS/SQS"
|
||||
period = 300
|
||||
statistic = "Average"
|
||||
threshold = local.alarm_messages_threshold
|
||||
alarm_description = "Queue depth high - messages may be backing up"
|
||||
|
||||
dimensions = {
|
||||
QueueName = aws_sqs_queue.main.name
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.alarms.arn]
|
||||
ok_actions = [aws_sns_topic.alarms.arn]
|
||||
|
||||
tags = { Name = "${local.queue_name}-depth" }
|
||||
}
|
||||
|
||||
# Message age alarm
|
||||
resource "aws_cloudwatch_metric_alarm" "age" {
|
||||
alarm_name = "${local.queue_name}-age"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 2
|
||||
metric_name = "ApproximateAgeOfOldestMessage"
|
||||
namespace = "AWS/SQS"
|
||||
period = 300
|
||||
statistic = "Maximum"
|
||||
threshold = local.alarm_age_threshold
|
||||
alarm_description = "Oldest message age high - consumers may be failing"
|
||||
|
||||
dimensions = {
|
||||
QueueName = aws_sqs_queue.main.name
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.alarms.arn]
|
||||
ok_actions = [aws_sns_topic.alarms.arn]
|
||||
|
||||
tags = { Name = "${local.queue_name}-age" }
|
||||
}
|
||||
|
||||
# DLQ messages alarm (critical - messages are failing)
|
||||
resource "aws_cloudwatch_metric_alarm" "dlq" {
|
||||
alarm_name = "${local.queue_name}-dlq"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 1
|
||||
metric_name = "ApproximateNumberOfMessagesVisible"
|
||||
namespace = "AWS/SQS"
|
||||
period = 300
|
||||
statistic = "Sum"
|
||||
threshold = 0
|
||||
alarm_description = "Messages in DLQ - processing failures detected"
|
||||
|
||||
dimensions = {
|
||||
QueueName = aws_sqs_queue.dlq.name
|
||||
}
|
||||
|
||||
alarm_actions = [aws_sns_topic.alarms.arn]
|
||||
|
||||
tags = { Name = "${local.queue_name}-dlq" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "queue_url" {
|
||||
value = aws_sqs_queue.main.url
|
||||
}
|
||||
|
||||
output "queue_arn" {
|
||||
value = aws_sqs_queue.main.arn
|
||||
}
|
||||
|
||||
output "queue_name" {
|
||||
value = aws_sqs_queue.main.name
|
||||
}
|
||||
|
||||
output "dlq_url" {
|
||||
value = aws_sqs_queue.dlq.url
|
||||
}
|
||||
|
||||
output "dlq_arn" {
|
||||
value = aws_sqs_queue.dlq.arn
|
||||
}
|
||||
|
||||
output "kms_key_arn" {
|
||||
value = aws_kms_key.sqs.arn
|
||||
}
|
||||
|
||||
output "alarm_topic_arn" {
|
||||
value = aws_sns_topic.alarms.arn
|
||||
}
|
||||
343
terraform/05-workloads/_template/ssm-parameters/main.tf
Normal file
343
terraform/05-workloads/_template/ssm-parameters/main.tf
Normal file
@@ -0,0 +1,343 @@
|
||||
################################################################################
|
||||
# Workload: SSM Parameter Store
|
||||
#
|
||||
# Configuration management (cheaper than Secrets Manager for non-secrets):
|
||||
# - String, StringList, SecureString parameters
|
||||
# - Hierarchical paths for organization
|
||||
# - KMS encryption for SecureString
|
||||
# - Parameter policies (expiration, notification)
|
||||
# - Cross-account access
|
||||
#
|
||||
# Cost: Free for standard parameters, $0.05/10K API calls for advanced
|
||||
# Use Secrets Manager for: rotation, cross-region replication, RDS integration
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-params/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
prefix = "/${local.tenant}/${local.env}/${local.name}"
|
||||
|
||||
# KMS key for SecureString (null = AWS managed key)
|
||||
kms_key_arn = null
|
||||
|
||||
# Parameter tier: Standard (free, 4KB) or Advanced ($0.05/param/mo, 8KB)
|
||||
tier = "Standard"
|
||||
|
||||
# Parameters to create
|
||||
parameters = {
|
||||
# Application config
|
||||
"config/app_name" = {
|
||||
type = "String"
|
||||
value = local.name
|
||||
description = "Application name"
|
||||
}
|
||||
|
||||
"config/environment" = {
|
||||
type = "String"
|
||||
value = local.env
|
||||
description = "Environment name"
|
||||
}
|
||||
|
||||
"config/log_level" = {
|
||||
type = "String"
|
||||
value = "INFO"
|
||||
description = "Application log level"
|
||||
}
|
||||
|
||||
"config/feature_flags" = {
|
||||
type = "String"
|
||||
value = jsonencode({
|
||||
new_checkout = true
|
||||
dark_mode = false
|
||||
beta_features = false
|
||||
})
|
||||
description = "Feature flags JSON"
|
||||
}
|
||||
|
||||
# Database config (non-secret parts)
|
||||
"database/host" = {
|
||||
type = "String"
|
||||
value = "db.example.internal"
|
||||
description = "Database hostname"
|
||||
}
|
||||
|
||||
"database/port" = {
|
||||
type = "String"
|
||||
value = "5432"
|
||||
description = "Database port"
|
||||
}
|
||||
|
||||
"database/name" = {
|
||||
type = "String"
|
||||
value = "myapp"
|
||||
description = "Database name"
|
||||
}
|
||||
|
||||
# Secure values (encrypted with KMS)
|
||||
# Note: Update this value after deployment via CLI:
|
||||
# aws ssm put-parameter --name "/<tenant>/<env>/<app>/secrets/api_key" --value "real-secret" --type SecureString --overwrite
|
||||
"secrets/api_key" = {
|
||||
type = "SecureString"
|
||||
value = "initial-value-update-after-deploy"
|
||||
description = "External API key"
|
||||
}
|
||||
|
||||
# List example
|
||||
"config/allowed_origins" = {
|
||||
type = "StringList"
|
||||
value = "https://example.com,https://app.example.com"
|
||||
description = "CORS allowed origins"
|
||||
}
|
||||
}
|
||||
|
||||
# Parameters with expiration policies (Advanced tier only)
|
||||
expiring_parameters = {
|
||||
# "tokens/temp_token" = {
|
||||
# type = "SecureString"
|
||||
# value = "temp-value"
|
||||
# description = "Temporary token"
|
||||
# expiration = "2024-12-31T23:59:59Z"
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# SSM Parameters
|
||||
################################################################################
|
||||
|
||||
resource "aws_ssm_parameter" "params" {
|
||||
for_each = local.parameters
|
||||
|
||||
name = "${local.prefix}/${each.key}"
|
||||
description = lookup(each.value, "description", "Parameter ${each.key}")
|
||||
type = each.value.type
|
||||
value = each.value.value
|
||||
tier = local.tier
|
||||
|
||||
key_id = each.value.type == "SecureString" ? local.kms_key_arn : null
|
||||
|
||||
tags = {
|
||||
Name = "${local.prefix}/${each.key}"
|
||||
Type = each.value.type
|
||||
}
|
||||
|
||||
# Uncomment to prevent Terraform from updating SecureString values
|
||||
# (useful when managing secrets externally via CLI/console)
|
||||
# lifecycle {
|
||||
# ignore_changes = [value]
|
||||
# }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Parameters with Expiration (Advanced Tier)
|
||||
################################################################################
|
||||
|
||||
resource "aws_ssm_parameter" "expiring" {
|
||||
for_each = local.expiring_parameters
|
||||
|
||||
name = "${local.prefix}/${each.key}"
|
||||
description = lookup(each.value, "description", "Parameter ${each.key}")
|
||||
type = each.value.type
|
||||
value = each.value.value
|
||||
tier = "Advanced" # Required for policies
|
||||
overwrite = true # Allow updates to existing parameters
|
||||
|
||||
key_id = each.value.type == "SecureString" ? local.kms_key_arn : null
|
||||
|
||||
# Note: Parameter policies (expiration, notification) require AWS SDK/CLI
|
||||
# Use aws ssm put-parameter with --policies flag for expiration:
|
||||
# aws ssm put-parameter --name "/path/param" --policies '[{"Type":"Expiration","Version":"1.0","Attributes":{"Timestamp":"2024-12-31T23:59:59.000Z"}}]'
|
||||
|
||||
tags = {
|
||||
Name = "${local.prefix}/${each.key}"
|
||||
Type = each.value.type
|
||||
Expiration = lookup(each.value, "expiration", "none")
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Policy for Reading Parameters
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_policy" "read" {
|
||||
name = "${local.tenant}-${local.name}-ssm-read"
|
||||
description = "Read access to ${local.prefix} parameters"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "DescribeParameters"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ssm:DescribeParameters"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "GetParameters"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters",
|
||||
"ssm:GetParametersByPath"
|
||||
]
|
||||
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${local.prefix}/*"
|
||||
},
|
||||
{
|
||||
Sid = "DecryptSecureStrings"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"kms:Decrypt"
|
||||
]
|
||||
Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}-ssm-read" }
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "write" {
|
||||
name = "${local.tenant}-${local.name}-ssm-write"
|
||||
description = "Write access to ${local.prefix} parameters"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "ManageParameters"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ssm:PutParameter",
|
||||
"ssm:DeleteParameter",
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters",
|
||||
"ssm:GetParametersByPath",
|
||||
"ssm:DescribeParameters"
|
||||
]
|
||||
Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${local.prefix}/*"
|
||||
},
|
||||
{
|
||||
Sid = "EncryptDecrypt"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey"
|
||||
]
|
||||
Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}-ssm-write" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "parameter_arns" {
|
||||
value = { for k, v in aws_ssm_parameter.params : k => v.arn }
|
||||
description = "Parameter ARNs"
|
||||
}
|
||||
|
||||
output "parameter_names" {
|
||||
value = { for k, v in aws_ssm_parameter.params : k => v.name }
|
||||
description = "Full parameter names (paths)"
|
||||
}
|
||||
|
||||
output "prefix" {
|
||||
value = local.prefix
|
||||
description = "Parameter path prefix"
|
||||
}
|
||||
|
||||
output "read_policy_arn" {
|
||||
value = aws_iam_policy.read.arn
|
||||
description = "IAM policy ARN for reading parameters"
|
||||
}
|
||||
|
||||
output "write_policy_arn" {
|
||||
value = aws_iam_policy.write.arn
|
||||
description = "IAM policy ARN for writing parameters"
|
||||
}
|
||||
|
||||
output "sdk_examples" {
|
||||
value = {
|
||||
get_single = "aws ssm get-parameter --name '${local.prefix}/config/app_name' --query Parameter.Value --output text"
|
||||
get_secure = "aws ssm get-parameter --name '${local.prefix}/secrets/api_key' --with-decryption --query Parameter.Value --output text"
|
||||
get_path = "aws ssm get-parameters-by-path --path '${local.prefix}/config' --recursive --query 'Parameters[*].[Name,Value]' --output table"
|
||||
put_param = "aws ssm put-parameter --name '${local.prefix}/config/new_param' --value 'my-value' --type String --overwrite"
|
||||
}
|
||||
description = "Example CLI commands"
|
||||
}
|
||||
|
||||
output "cost_estimate" {
|
||||
value = {
|
||||
standard_params = "Free (up to 10,000 parameters)"
|
||||
advanced_params = "$0.05/parameter/month"
|
||||
api_calls = "Free for standard, $0.05 per 10,000 for advanced"
|
||||
note = "SecureString encryption uses KMS (may have additional costs)"
|
||||
}
|
||||
description = "Cost information"
|
||||
}
|
||||
450
terraform/05-workloads/_template/static-site/main.tf
Normal file
450
terraform/05-workloads/_template/static-site/main.tf
Normal file
@@ -0,0 +1,450 @@
|
||||
################################################################################
|
||||
# Workload: Static Site (S3 + CloudFront)
|
||||
#
|
||||
# Deploys a static website:
|
||||
# - S3 bucket for content (private, OAC access only)
|
||||
# - CloudFront distribution with HTTPS
|
||||
# - ACM certificate (DNS validation)
|
||||
# - WAF integration (optional)
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<site-name>/
|
||||
# Update locals and variables
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
# Domain (leave empty for CloudFront default domain)
|
||||
domain_name = "" # e.g., "www.example.com"
|
||||
hosted_zone_id = "" # Route53 hosted zone ID
|
||||
create_certificate = local.domain_name != ""
|
||||
|
||||
# Content settings
|
||||
default_root_object = "index.html"
|
||||
error_page_path = "/error.html"
|
||||
|
||||
# Caching
|
||||
default_ttl = 86400 # 1 day
|
||||
min_ttl = 0
|
||||
max_ttl = 31536000 # 1 year
|
||||
|
||||
# Price class
|
||||
# PriceClass_100 = US, Canada, Europe (cheapest)
|
||||
# PriceClass_200 = Above + Asia, Africa, Middle East
|
||||
# PriceClass_All = All edge locations
|
||||
price_class = "PriceClass_100"
|
||||
|
||||
# WAF (set to WAF web ACL ARN to enable)
|
||||
waf_web_acl_arn = ""
|
||||
|
||||
# Logging
|
||||
enable_logging = true
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ACM certificates must be in us-east-1 for CloudFront
|
||||
provider "aws" {
|
||||
alias = "us_east_1"
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "terraform_remote_state" "bootstrap" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = var.state_bucket
|
||||
key = "00-bootstrap/terraform.tfstate"
|
||||
region = var.region
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# S3 Bucket
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "site" {
|
||||
bucket = "${local.tenant}-${local.name}-${local.env}-${data.aws_caller_identity.current.account_id}"
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}" }
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = "AES256"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_public_access_block" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
}
|
||||
|
||||
# Bucket policy for CloudFront OAC
|
||||
resource "aws_s3_bucket_policy" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowCloudFrontOAC"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "cloudfront.amazonaws.com"
|
||||
}
|
||||
Action = "s3:GetObject"
|
||||
Resource = "${aws_s3_bucket.site.arn}/*"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"AWS:SourceArn" = aws_cloudfront_distribution.site.arn
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudFront Origin Access Control
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudfront_origin_access_control" "site" {
|
||||
name = "${local.tenant}-${local.name}"
|
||||
description = "OAC for ${local.tenant}-${local.name}"
|
||||
origin_access_control_origin_type = "s3"
|
||||
signing_behavior = "always"
|
||||
signing_protocol = "sigv4"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# ACM Certificate (if custom domain)
|
||||
################################################################################
|
||||
|
||||
resource "aws_acm_certificate" "site" {
|
||||
count = local.create_certificate ? 1 : 0
|
||||
provider = aws.us_east_1
|
||||
|
||||
domain_name = local.domain_name
|
||||
validation_method = "DNS"
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}" }
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "cert_validation" {
|
||||
for_each = local.create_certificate ? {
|
||||
for dvo in aws_acm_certificate.site[0].domain_validation_options : dvo.domain_name => {
|
||||
name = dvo.resource_record_name
|
||||
record = dvo.resource_record_value
|
||||
type = dvo.resource_record_type
|
||||
}
|
||||
} : {}
|
||||
|
||||
allow_overwrite = true
|
||||
name = each.value.name
|
||||
records = [each.value.record]
|
||||
ttl = 60
|
||||
type = each.value.type
|
||||
zone_id = local.hosted_zone_id
|
||||
}
|
||||
|
||||
resource "aws_acm_certificate_validation" "site" {
|
||||
count = local.create_certificate ? 1 : 0
|
||||
provider = aws.us_east_1
|
||||
|
||||
certificate_arn = aws_acm_certificate.site[0].arn
|
||||
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudFront Logging Bucket
|
||||
################################################################################
|
||||
|
||||
resource "aws_s3_bucket" "logs" {
|
||||
count = local.enable_logging ? 1 : 0
|
||||
bucket = "${local.tenant}-${local.name}-logs-${data.aws_caller_identity.current.account_id}"
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}-logs" }
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_ownership_controls" "logs" {
|
||||
count = local.enable_logging ? 1 : 0
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
|
||||
rule {
|
||||
object_ownership = "BucketOwnerPreferred"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_acl" "logs" {
|
||||
count = local.enable_logging ? 1 : 0
|
||||
depends_on = [aws_s3_bucket_ownership_controls.logs]
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
acl = "private"
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
|
||||
count = local.enable_logging ? 1 : 0
|
||||
bucket = aws_s3_bucket.logs[0].id
|
||||
|
||||
rule {
|
||||
id = "cleanup"
|
||||
status = "Enabled"
|
||||
|
||||
expiration {
|
||||
days = 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudFront Distribution
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudfront_distribution" "site" {
|
||||
enabled = true
|
||||
is_ipv6_enabled = true
|
||||
default_root_object = local.default_root_object
|
||||
price_class = local.price_class
|
||||
comment = "${local.tenant} ${local.name} static site"
|
||||
|
||||
aliases = local.create_certificate ? [local.domain_name] : []
|
||||
|
||||
origin {
|
||||
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||
origin_id = "S3-${aws_s3_bucket.site.id}"
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||
}
|
||||
|
||||
default_cache_behavior {
|
||||
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
||||
cached_methods = ["GET", "HEAD"]
|
||||
target_origin_id = "S3-${aws_s3_bucket.site.id}"
|
||||
viewer_protocol_policy = "redirect-to-https"
|
||||
compress = true
|
||||
|
||||
min_ttl = local.min_ttl
|
||||
default_ttl = local.default_ttl
|
||||
max_ttl = local.max_ttl
|
||||
|
||||
forwarded_values {
|
||||
query_string = false
|
||||
cookies {
|
||||
forward = "none"
|
||||
}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id
|
||||
}
|
||||
|
||||
# Custom error pages
|
||||
custom_error_response {
|
||||
error_code = 404
|
||||
response_code = 404
|
||||
response_page_path = local.error_page_path
|
||||
error_caching_min_ttl = 60
|
||||
}
|
||||
|
||||
custom_error_response {
|
||||
error_code = 403
|
||||
response_code = 404
|
||||
response_page_path = local.error_page_path
|
||||
error_caching_min_ttl = 60
|
||||
}
|
||||
|
||||
restrictions {
|
||||
geo_restriction {
|
||||
restriction_type = "none"
|
||||
}
|
||||
}
|
||||
|
||||
viewer_certificate {
|
||||
acm_certificate_arn = local.create_certificate ? aws_acm_certificate.site[0].arn : null
|
||||
ssl_support_method = local.create_certificate ? "sni-only" : null
|
||||
minimum_protocol_version = local.create_certificate ? "TLSv1.2_2021" : null
|
||||
cloudfront_default_certificate = !local.create_certificate
|
||||
}
|
||||
|
||||
dynamic "logging_config" {
|
||||
for_each = local.enable_logging ? [1] : []
|
||||
content {
|
||||
bucket = aws_s3_bucket.logs[0].bucket_domain_name
|
||||
include_cookies = false
|
||||
prefix = "cloudfront/"
|
||||
}
|
||||
}
|
||||
|
||||
web_acl_id = local.waf_web_acl_arn != "" ? local.waf_web_acl_arn : null
|
||||
|
||||
tags = { Name = "${local.tenant}-${local.name}" }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Security Headers Policy
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudfront_response_headers_policy" "security" {
|
||||
name = "${local.tenant}-${local.name}-security"
|
||||
comment = "Security headers for ${local.tenant}-${local.name}"
|
||||
|
||||
security_headers_config {
|
||||
content_type_options {
|
||||
override = true
|
||||
}
|
||||
|
||||
frame_options {
|
||||
frame_option = "DENY"
|
||||
override = true
|
||||
}
|
||||
|
||||
referrer_policy {
|
||||
referrer_policy = "strict-origin-when-cross-origin"
|
||||
override = true
|
||||
}
|
||||
|
||||
strict_transport_security {
|
||||
access_control_max_age_sec = 31536000 # 1 year
|
||||
include_subdomains = true
|
||||
preload = true
|
||||
override = true
|
||||
}
|
||||
|
||||
xss_protection {
|
||||
mode_block = true
|
||||
protection = true
|
||||
override = true
|
||||
}
|
||||
|
||||
content_security_policy {
|
||||
content_security_policy = "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
override = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route53 Record (if custom domain)
|
||||
################################################################################
|
||||
|
||||
resource "aws_route53_record" "site" {
|
||||
count = local.create_certificate ? 1 : 0
|
||||
|
||||
zone_id = local.hosted_zone_id
|
||||
name = local.domain_name
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = aws_cloudfront_distribution.site.domain_name
|
||||
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||
evaluate_target_health = false
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "bucket_name" {
|
||||
value = aws_s3_bucket.site.id
|
||||
}
|
||||
|
||||
output "bucket_arn" {
|
||||
value = aws_s3_bucket.site.arn
|
||||
}
|
||||
|
||||
output "distribution_id" {
|
||||
value = aws_cloudfront_distribution.site.id
|
||||
}
|
||||
|
||||
output "distribution_domain" {
|
||||
value = aws_cloudfront_distribution.site.domain_name
|
||||
}
|
||||
|
||||
output "site_url" {
|
||||
value = local.create_certificate ? "https://${local.domain_name}" : "https://${aws_cloudfront_distribution.site.domain_name}"
|
||||
}
|
||||
|
||||
output "deploy_command" {
|
||||
value = "aws s3 sync ./dist s3://${aws_s3_bucket.site.id} --delete && aws cloudfront create-invalidation --distribution-id ${aws_cloudfront_distribution.site.id} --paths '/*'"
|
||||
description = "Command to deploy content"
|
||||
}
|
||||
499
terraform/05-workloads/_template/step-function/main.tf
Normal file
499
terraform/05-workloads/_template/step-function/main.tf
Normal file
@@ -0,0 +1,499 @@
|
||||
################################################################################
|
||||
# Workload: Step Functions State Machine
|
||||
#
|
||||
# Deploys a serverless workflow:
|
||||
# - Step Functions state machine
|
||||
# - IAM role with least-privilege
|
||||
# - CloudWatch logging
|
||||
# - X-Ray tracing
|
||||
# - EventBridge trigger (optional)
|
||||
# - API Gateway trigger (optional)
|
||||
#
|
||||
# Usage:
|
||||
# Copy this folder to 05-workloads/<tenant>-<workflow-name>/
|
||||
# Update the state machine definition in definition.json
|
||||
# terraform init -backend-config=../../00-bootstrap/backend.hcl
|
||||
# terraform apply
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
key = "05-workloads/<TENANT>-<NAME>-workflow/terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Configuration - UPDATE THESE
|
||||
################################################################################
|
||||
|
||||
locals {
|
||||
# Naming
|
||||
tenant = "<TENANT>"
|
||||
name = "<NAME>"
|
||||
env = "prod"
|
||||
|
||||
state_machine_name = "${local.tenant}-${local.name}-${local.env}"
|
||||
|
||||
# State machine type: STANDARD or EXPRESS
|
||||
# STANDARD: Long-running (up to 1 year), exactly-once execution
|
||||
# EXPRESS: Short-duration (up to 5 min), at-least-once, cheaper
|
||||
type = "STANDARD"
|
||||
|
||||
# Logging level: OFF, ALL, ERROR, FATAL
|
||||
logging_level = "ERROR"
|
||||
|
||||
# X-Ray tracing
|
||||
tracing_enabled = true
|
||||
|
||||
# EventBridge trigger (set to null to disable)
|
||||
schedule_expression = null # e.g., "rate(1 hour)" or "cron(0 12 * * ? *)"
|
||||
|
||||
# API Gateway trigger
|
||||
enable_api_trigger = false
|
||||
|
||||
# Lambda functions this workflow can invoke (ARNs)
|
||||
lambda_arns = [
|
||||
# "arn:aws:lambda:us-east-1:123456789012:function:my-function",
|
||||
]
|
||||
|
||||
# DynamoDB tables this workflow can access (ARNs)
|
||||
dynamodb_arns = [
|
||||
# "arn:aws:dynamodb:us-east-1:123456789012:table/my-table",
|
||||
]
|
||||
|
||||
# SQS queues this workflow can send to (ARNs)
|
||||
sqs_arns = [
|
||||
# "arn:aws:sqs:us-east-1:123456789012:my-queue",
|
||||
]
|
||||
|
||||
# SNS topics this workflow can publish to (ARNs)
|
||||
sns_arns = [
|
||||
# "arn:aws:sns:us-east-1:123456789012:my-topic",
|
||||
]
|
||||
|
||||
# S3 buckets this workflow can access (ARNs)
|
||||
s3_arns = [
|
||||
# "arn:aws:s3:::my-bucket/*",
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Variables
|
||||
################################################################################
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "state_bucket" {
|
||||
type = string
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provider
|
||||
################################################################################
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Tenant = local.tenant
|
||||
App = local.name
|
||||
Environment = local.env
|
||||
ManagedBy = "terraform"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Log Group
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_log_group" "main" {
|
||||
name = "/aws/states/${local.state_machine_name}"
|
||||
retention_in_days = 30
|
||||
|
||||
tags = { Name = local.state_machine_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# IAM Role
|
||||
################################################################################
|
||||
|
||||
resource "aws_iam_role" "state_machine" {
|
||||
name = "${local.state_machine_name}-role"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "states.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.state_machine_name}-role" }
|
||||
}
|
||||
|
||||
# CloudWatch Logs permissions
|
||||
resource "aws_iam_role_policy" "logs" {
|
||||
name = "cloudwatch-logs"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"logs:CreateLogDelivery",
|
||||
"logs:CreateLogStream",
|
||||
"logs:GetLogDelivery",
|
||||
"logs:UpdateLogDelivery",
|
||||
"logs:DeleteLogDelivery",
|
||||
"logs:ListLogDeliveries",
|
||||
"logs:PutLogEvents",
|
||||
"logs:PutResourcePolicy",
|
||||
"logs:DescribeResourcePolicies",
|
||||
"logs:DescribeLogGroups"
|
||||
]
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# X-Ray permissions
|
||||
resource "aws_iam_role_policy" "xray" {
|
||||
count = local.tracing_enabled ? 1 : 0
|
||||
name = "xray"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"xray:PutTraceSegments",
|
||||
"xray:PutTelemetryRecords",
|
||||
"xray:GetSamplingRules",
|
||||
"xray:GetSamplingTargets"
|
||||
]
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# Lambda invocation permissions
|
||||
resource "aws_iam_role_policy" "lambda" {
|
||||
count = length(local.lambda_arns) > 0 ? 1 : 0
|
||||
name = "lambda"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "lambda:InvokeFunction"
|
||||
Resource = local.lambda_arns
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# DynamoDB permissions
|
||||
resource "aws_iam_role_policy" "dynamodb" {
|
||||
count = length(local.dynamodb_arns) > 0 ? 1 : 0
|
||||
name = "dynamodb"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:DeleteItem",
|
||||
"dynamodb:Query",
|
||||
"dynamodb:Scan"
|
||||
]
|
||||
Resource = local.dynamodb_arns
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# SQS permissions
|
||||
resource "aws_iam_role_policy" "sqs" {
|
||||
count = length(local.sqs_arns) > 0 ? 1 : 0
|
||||
name = "sqs"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"sqs:SendMessage",
|
||||
"sqs:GetQueueUrl"
|
||||
]
|
||||
Resource = local.sqs_arns
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# SNS permissions
|
||||
resource "aws_iam_role_policy" "sns" {
|
||||
count = length(local.sns_arns) > 0 ? 1 : 0
|
||||
name = "sns"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sns:Publish"
|
||||
Resource = local.sns_arns
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# S3 permissions
|
||||
resource "aws_iam_role_policy" "s3" {
|
||||
count = length(local.s3_arns) > 0 ? 1 : 0
|
||||
name = "s3"
|
||||
role = aws_iam_role.state_machine.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject"
|
||||
]
|
||||
Resource = local.s3_arns
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# State Machine Definition
|
||||
################################################################################
|
||||
|
||||
# Simple example - replace with your actual workflow
|
||||
locals {
|
||||
state_machine_definition = jsonencode({
|
||||
Comment = "Example workflow for ${local.tenant} ${local.name}"
|
||||
StartAt = "ProcessInput"
|
||||
States = {
|
||||
ProcessInput = {
|
||||
Type = "Pass"
|
||||
Parameters = {
|
||||
"input.$" = "$"
|
||||
"timestamp" = "$$.State.EnteredTime"
|
||||
}
|
||||
Next = "Success"
|
||||
}
|
||||
Success = {
|
||||
Type = "Succeed"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Step Functions State Machine
|
||||
################################################################################
|
||||
|
||||
resource "aws_sfn_state_machine" "main" {
|
||||
name = local.state_machine_name
|
||||
role_arn = aws_iam_role.state_machine.arn
|
||||
type = local.type
|
||||
|
||||
definition = local.state_machine_definition
|
||||
|
||||
logging_configuration {
|
||||
log_destination = "${aws_cloudwatch_log_group.main.arn}:*"
|
||||
include_execution_data = true
|
||||
level = local.logging_level
|
||||
}
|
||||
|
||||
tracing_configuration {
|
||||
enabled = local.tracing_enabled
|
||||
}
|
||||
|
||||
tags = { Name = local.state_machine_name }
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EventBridge Schedule Trigger
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "schedule" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
name = "${local.state_machine_name}-schedule"
|
||||
description = "Trigger ${local.state_machine_name} on schedule"
|
||||
schedule_expression = local.schedule_expression
|
||||
|
||||
tags = { Name = "${local.state_machine_name}-schedule" }
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "schedule" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
rule = aws_cloudwatch_event_rule.schedule[0].name
|
||||
target_id = "StepFunctions"
|
||||
arn = aws_sfn_state_machine.main.arn
|
||||
role_arn = aws_iam_role.eventbridge[0].arn
|
||||
|
||||
input = jsonencode({
|
||||
source = "scheduled"
|
||||
timestamp = "$.time"
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "eventbridge" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
name = "${local.state_machine_name}-eventbridge"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "events.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.state_machine_name}-eventbridge" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "eventbridge" {
|
||||
count = local.schedule_expression != null ? 1 : 0
|
||||
name = "start-execution"
|
||||
role = aws_iam_role.eventbridge[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "states:StartExecution"
|
||||
Resource = aws_sfn_state_machine.main.arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# API Gateway Trigger
|
||||
################################################################################
|
||||
|
||||
resource "aws_apigatewayv2_api" "main" {
|
||||
count = local.enable_api_trigger ? 1 : 0
|
||||
name = local.state_machine_name
|
||||
protocol_type = "HTTP"
|
||||
|
||||
tags = { Name = local.state_machine_name }
|
||||
}
|
||||
|
||||
resource "aws_apigatewayv2_stage" "main" {
|
||||
count = local.enable_api_trigger ? 1 : 0
|
||||
api_id = aws_apigatewayv2_api.main[0].id
|
||||
name = "$default"
|
||||
auto_deploy = true
|
||||
}
|
||||
|
||||
resource "aws_apigatewayv2_integration" "main" {
|
||||
count = local.enable_api_trigger ? 1 : 0
|
||||
api_id = aws_apigatewayv2_api.main[0].id
|
||||
integration_type = "AWS_PROXY"
|
||||
integration_subtype = "StepFunctions-StartExecution"
|
||||
credentials_arn = aws_iam_role.api[0].arn
|
||||
|
||||
request_parameters = {
|
||||
StateMachineArn = aws_sfn_state_machine.main.arn
|
||||
Input = "$request.body"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_apigatewayv2_route" "main" {
|
||||
count = local.enable_api_trigger ? 1 : 0
|
||||
api_id = aws_apigatewayv2_api.main[0].id
|
||||
route_key = "POST /execute"
|
||||
target = "integrations/${aws_apigatewayv2_integration.main[0].id}"
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "api" {
|
||||
count = local.enable_api_trigger ? 1 : 0
|
||||
name = "${local.state_machine_name}-api"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "sts:AssumeRole"
|
||||
Principal = { Service = "apigateway.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
|
||||
tags = { Name = "${local.state_machine_name}-api" }
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "api" {
|
||||
count = local.enable_api_trigger ? 1 : 0
|
||||
name = "start-execution"
|
||||
role = aws_iam_role.api[0].id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = "states:StartExecution"
|
||||
Resource = aws_sfn_state_machine.main.arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "state_machine_arn" {
|
||||
value = aws_sfn_state_machine.main.arn
|
||||
}
|
||||
|
||||
output "state_machine_name" {
|
||||
value = aws_sfn_state_machine.main.name
|
||||
}
|
||||
|
||||
output "role_arn" {
|
||||
value = aws_iam_role.state_machine.arn
|
||||
}
|
||||
|
||||
output "log_group" {
|
||||
value = aws_cloudwatch_log_group.main.name
|
||||
}
|
||||
|
||||
output "api_endpoint" {
|
||||
value = local.enable_api_trigger ? "${aws_apigatewayv2_api.main[0].api_endpoint}/execute" : null
|
||||
}
|
||||
|
||||
output "execution_command" {
|
||||
value = "aws stepfunctions start-execution --state-machine-arn ${aws_sfn_state_machine.main.arn} --input '{\"key\": \"value\"}'"
|
||||
}
|
||||
Reference in New Issue
Block a user