terraform { required_version = ">= 1.11, < 2.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } data "aws_region" "current" {} data "aws_availability_zones" "available" { state = "available" filter { name = "opt-in-status" values = ["opt-in-not-required"] } } data "aws_ami" "debian_arm64" { most_recent = true owners = ["136693071363"] filter { name = "name" values = ["debian-13-arm64-*"] } filter { name = "virtualization-type" values = ["hvm"] } filter { name = "root-device-type" values = ["ebs"] } filter { name = "architecture" values = ["arm64"] } } data "aws_ec2_instance_type_offerings" "hosts" { location_type = "availability-zone" filter { name = "instance-type" values = [var.instance_type] } } locals { available_zones = sort(tolist(setintersection(toset(data.aws_availability_zones.available.names), toset(data.aws_ec2_instance_type_offerings.hosts.locations)))) host_keys = [for i in range(var.host_count) : "host-${i}"] } resource "aws_vpc" "this" { lifecycle { precondition { condition = length(local.available_zones) >= min(var.host_count, 2) error_message = "The chosen instance type needs one available zone for one host, or at least two zones for multiple hosts." } } cidr_block = var.vpc_cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = var.name } } resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = { Name = var.name } } resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this.id } tags = { Name = "${var.name}-public" } } resource "aws_subnet" "public" { lifecycle { ignore_changes = [availability_zone] } count = var.host_count vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) availability_zone = try(local.available_zones[count.index % max(1, length(local.available_zones))], null) map_public_ip_on_launch = true tags = { Name = "${var.name}-public-${count.index}" } } resource "aws_route_table_association" "public" { count = var.host_count subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id } resource "aws_security_group" "host" { name = "${var.name}-host" description = "HTTP and HTTPS ingress, all egress" vpc_id = aws_vpc.this.id ingress { description = "HTTP" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { description = "HTTPS" from_port = 443 to_port = 443 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 = "${var.name}-host" } } resource "aws_iam_role" "host" { name = "${var.name}-host" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } Action = "sts:AssumeRole" }] }) } resource "aws_iam_role_policy" "buckets" { name = "buckets" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "ObjectAccess" Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = [ "${var.deployment_bucket_arn}/*", "${var.data_bucket_arn}/*", ] }, { Sid = "BucketAccess" Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] Resource = [var.deployment_bucket_arn, var.data_bucket_arn] }, ] }) } resource "aws_iam_role_policy" "metrics" { name = "metrics" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "PutHostMetrics" Effect = "Allow" Action = "cloudwatch:PutMetricData" Resource = "*" Condition = { StringEquals = { "cloudwatch:namespace" = "Carlos/Host" } } }] }) } resource "aws_iam_role_policy" "deny_parameters" { name = "deny-ssm-parameters" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "DenyParameterReads" Effect = "Deny" Action = [ "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath", ] Resource = "*" }] }) } resource "aws_iam_role_policy_attachment" "ssm_core" { role = aws_iam_role.host.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } resource "aws_iam_instance_profile" "host" { name = "${var.name}-host" role = aws_iam_role.host.name } resource "aws_instance" "host" { for_each = toset(local.host_keys) ami = data.aws_ami.debian_arm64.id instance_type = var.instance_type subnet_id = aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].id vpc_security_group_ids = [aws_security_group.host.id] iam_instance_profile = aws_iam_instance_profile.host.name associate_public_ip_address = true disable_api_termination = true metadata_options { http_endpoint = "enabled" http_tokens = "required" http_put_response_hop_limit = 1 } root_block_device { volume_type = "gp3" volume_size = var.root_volume_gib encrypted = true delete_on_termination = false } user_data = templatefile("${path.module}/cloud-init.sh.tftpl", { local_region = data.aws_region.current.region deployment_bucket_name = var.deployment_bucket_name deployment_region = var.deployment_region data_bucket_name = var.data_bucket_name placement_region = var.placement_region release_public_key = var.release_public_key apps_domain = var.apps_domain }) tags = { Name = "${var.name}-${each.key}" } depends_on = [ aws_route_table_association.public, aws_iam_role_policy.buckets, aws_iam_role_policy.metrics, aws_iam_role_policy.deny_parameters, aws_iam_role_policy_attachment.ssm_core, ] lifecycle { prevent_destroy = true ignore_changes = [ami, user_data] precondition { condition = contains(data.aws_ec2_instance_type_offerings.hosts.locations, aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].availability_zone) error_message = "The chosen instance type is unavailable in this host's existing availability zone." } } } resource "aws_eip" "host" { for_each = aws_instance.host domain = "vpc" instance = each.value.id tags = { Name = "${var.name}-${each.key}" } } resource "aws_cloudwatch_metric_alarm" "recover" { for_each = aws_instance.host alarm_name = "${var.name}-${each.key}-recover" namespace = "AWS/EC2" metric_name = "StatusCheckFailed_System" statistic = "Maximum" period = 60 evaluation_periods = 2 threshold = 1 comparison_operator = "GreaterThanOrEqualToThreshold" alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"] dimensions = { InstanceId = each.value.id } }