diff --git a/.gitignore b/.gitignore index 0a9e7f2..8479ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ **/.terraform/ .terraform.lock.hcl.bak +# Lock files belong to root modules only - bootstrap/ and envs/*. The ones under +# modules/ are a side effect of validate.sh initialising each module as a root +# to check it, and Terraform ignores them anyway. +modules/*/.terraform.lock.hcl + # State never belongs in git. Environment state lives in S3; bootstrap/ keeps # its own state locally, and losing it costs a `terraform import` rather than # the bucket - see bootstrap/README.md. diff --git a/bootstrap/.terraform.lock.hcl b/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..3347a75 --- /dev/null +++ b/bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.57.1" + constraints = "~> 6.0" + hashes = [ + "h1:WXndu9uKvbnmspexcbki89ZuGLt2SUyAfZ5GgQUm+QU=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} diff --git a/bootstrap/README.md b/bootstrap/README.md new file mode 100644 index 0000000..0825003 --- /dev/null +++ b/bootstrap/README.md @@ -0,0 +1,36 @@ +# bootstrap + +Creates the S3 bucket that holds every environment's Terraform state. Run once +per AWS account, ever. + + terraform init + terraform apply -var 'region=us-east-1' + +It prints the bucket name and the `init` line the prod environment needs. + +## Why this is a separate root + +A backend cannot store the state of its own creation, so something has to make +the bucket first. That something keeps its state here, on local disk, in +`terraform.tfstate` — which is gitignored. + +**Losing that file is not a problem.** The bucket has `prevent_destroy`, so +nothing is going to remove it by accident, and re-adopting it is one command: + + terraform import aws_s3_bucket.state lance-blue-tfstate- + +followed by `terraform plan` to confirm the rest of the configuration matches +what is actually there. + +## What it makes + +One bucket, with versioning (the recovery path for a corrupted state file), +SSE-S3 (KMS charges per request and buys nothing here), public access blocked, +a TLS-only bucket policy, and lifecycle rules that expire old versions after 90 +days and abort abandoned multipart uploads. + +**No DynamoDB table.** Terraform 1.10 added S3-native state locking via a lock +file object, and `dynamodb_table` is deprecated as of 1.11. Environments set +`use_lockfile = true` in their backend block instead — one fewer resource, one +fewer thing to pay for, and no chance of the table and the bucket drifting +apart. diff --git a/bootstrap/main.tf b/bootstrap/main.tf new file mode 100644 index 0000000..81b5c06 --- /dev/null +++ b/bootstrap/main.tf @@ -0,0 +1,146 @@ +# The S3 bucket that holds every environment's state. +# +# This root has no backend of its own: a bucket cannot store the state of its +# own creation. Its state file stays on local disk, gitignored, and losing it +# costs a `terraform import` rather than the bucket - see README.md. +# +# Run once per AWS account, ever. + +terraform { + required_version = ">= 1.11" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Project = "lance.blue" + ManagedBy = "terraform" + Repo = "infra" + Component = "tfstate" + } + } +} + +data "aws_caller_identity" "current" {} + +# The account id is in the name because S3 bucket names are globally unique and +# "lance-blue-tfstate" is exactly the kind of name someone else has already +# taken. It also makes a wrong-account apply obvious in the output. +resource "aws_s3_bucket" "state" { + bucket = "${var.name_prefix}-tfstate-${data.aws_caller_identity.current.account_id}" + + # Deleting this deletes every environment's state. Terraform will refuse + # rather than let a bad plan through; that refusal is the point. + lifecycle { + prevent_destroy = true + } +} + +# The actual safety net. State files are kilobytes, so keeping every version is +# effectively free, and a corrupt apply is recoverable by restoring an object +# version rather than by rebuilding the account. +resource "aws_s3_bucket_versioning" "state" { + bucket = aws_s3_bucket.state.id + + versioning_configuration { + status = "Enabled" + } +} + +# SSE-S3 rather than KMS: KMS charges per request and per key, and buys nothing +# here. State is sensitive because of what Terraform puts in it, not because of +# who can reach the bucket - which is nobody, per the public access block. +resource "aws_s3_bucket_server_side_encryption_configuration" "state" { + bucket = aws_s3_bucket.state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "state" { + bucket = aws_s3_bucket.state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Versioning without expiry grows forever. 90 days is long enough that any +# recovery worth attempting has already been attempted. +resource "aws_s3_bucket_lifecycle_configuration" "state" { + bucket = aws_s3_bucket.state.id + + rule { + id = "expire-noncurrent-state" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = 90 + } + } + + # State locking with `use_lockfile` writes and deletes small objects. A failed + # multipart upload is unlikely at that size, but an abandoned one bills until + # something removes it. + rule { + id = "abort-incomplete-uploads" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + depends_on = [aws_s3_bucket_versioning.state] +} + +# TLS-only. Not because anything here talks plain HTTP - the AWS SDKs do not - +# but because a bucket policy is the only place this can be stated as a rule +# rather than as an assumption. +resource "aws_s3_bucket_policy" "state" { + bucket = aws_s3_bucket.state.id + policy = data.aws_iam_policy_document.state.json +} + +data "aws_iam_policy_document" "state" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + + resources = [ + aws_s3_bucket.state.arn, + "${aws_s3_bucket.state.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} diff --git a/bootstrap/outputs.tf b/bootstrap/outputs.tf new file mode 100644 index 0000000..4d5527d --- /dev/null +++ b/bootstrap/outputs.tf @@ -0,0 +1,9 @@ +output "state_bucket" { + description = "Name of the state bucket. This is the value envs/*/backend.tf needs." + value = aws_s3_bucket.state.id +} + +output "init_command" { + description = "Ready-made init for the prod environment, since a backend block cannot take variables." + value = "terraform -chdir=envs/prod init -backend-config=\"bucket=${aws_s3_bucket.state.id}\" -backend-config=\"region=${var.region}\"" +} diff --git a/bootstrap/variables.tf b/bootstrap/variables.tf new file mode 100644 index 0000000..c1883d1 --- /dev/null +++ b/bootstrap/variables.tf @@ -0,0 +1,11 @@ +variable "region" { + description = "AWS region for the state bucket. Should match the region the environments run in; the backend config in envs/*/backend.tf must agree with it." + type = string + default = "us-east-1" +} + +variable "name_prefix" { + description = "Prefix for resource names." + type = string + default = "lance-blue" +} diff --git a/envs/prod/.terraform.lock.hcl b/envs/prod/.terraform.lock.hcl new file mode 100644 index 0000000..3347a75 --- /dev/null +++ b/envs/prod/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.57.1" + constraints = "~> 6.0" + hashes = [ + "h1:WXndu9uKvbnmspexcbki89ZuGLt2SUyAfZ5GgQUm+QU=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} diff --git a/envs/prod/backend.tf b/envs/prod/backend.tf new file mode 100644 index 0000000..0f1ca25 --- /dev/null +++ b/envs/prod/backend.tf @@ -0,0 +1,23 @@ +# State lives in the bucket bootstrap/ creates. +# +# A backend block cannot use variables, so the bucket name is a placeholder that +# has to be either edited here or supplied at init: +# +# terraform -chdir=envs/prod init \ +# -backend-config="bucket=lance-blue-tfstate-" +# +# `bootstrap` prints the exact line as an output. +# +# No `dynamodb_table`: locking is the S3 lock file, which needs no second +# resource and is the supported mechanism from Terraform 1.11 on. + +terraform { + backend "s3" { + bucket = "REPLACE-ME-lance-blue-tfstate-" + key = "prod/terraform.tfstate" + region = "us-east-1" + + encrypt = true + use_lockfile = true + } +} diff --git a/envs/prod/main.tf b/envs/prod/main.tf new file mode 100644 index 0000000..7053ccb --- /dev/null +++ b/envs/prod/main.tf @@ -0,0 +1,23 @@ +# Production. The only environment - see docs/decisions.md#one-environment. +# +# This file composes modules and nothing else: no resources are declared here, +# so anything that turns out to be worth reusing already lives somewhere it can +# be reused from. + +locals { + # Prod carries the suffix like any other environment. The exception is + # anything in a global namespace that would be awkward to rename later, which + # names itself. + name_prefix = "lance-blue-${var.environment}" +} + +module "network" { + source = "../../modules/network" + + name_prefix = local.name_prefix + region = var.region + cidr_block = var.vpc_cidr + az_count = var.az_count + + tags = { Component = "network" } +} diff --git a/envs/prod/outputs.tf b/envs/prod/outputs.tf new file mode 100644 index 0000000..ce56c02 --- /dev/null +++ b/envs/prod/outputs.tf @@ -0,0 +1,9 @@ +output "vpc_id" { + description = "VPC id." + value = module.network.vpc_id +} + +output "public_subnet_ids" { + description = "Public subnets match tasks run in." + value = module.network.public_subnet_ids +} diff --git a/envs/prod/providers.tf b/envs/prod/providers.tf new file mode 100644 index 0000000..2d34b44 --- /dev/null +++ b/envs/prod/providers.tf @@ -0,0 +1,16 @@ +provider "aws" { + region = var.region + + # The cheapest possible defence against applying to the wrong account. + # Terraform refuses before it plans if the caller's account is not this one. + allowed_account_ids = [var.account_id] + + default_tags { + tags = { + Project = "lance.blue" + Environment = var.environment + ManagedBy = "terraform" + Repo = "infra" + } + } +} diff --git a/envs/prod/terraform.tfvars.example b/envs/prod/terraform.tfvars.example new file mode 100644 index 0000000..45afeb0 --- /dev/null +++ b/envs/prod/terraform.tfvars.example @@ -0,0 +1,8 @@ +# Copy to terraform.tfvars and fill in. That file is gitignored - the +# .gitignore treats every *.tfvars as suspect on principle, even when, as here, +# it holds nothing secret. + +account_id = "000000000000" +region = "us-east-1" + +domain_name = "lance.blue" diff --git a/envs/prod/variables.tf b/envs/prod/variables.tf new file mode 100644 index 0000000..f9dedda --- /dev/null +++ b/envs/prod/variables.tf @@ -0,0 +1,48 @@ +# Everything environment-specific lives here. Modules take inputs and know +# nothing about which account or region they are in, so a second environment is +# a directory copy rather than a refactor. +# +# Nothing in this file is secret. If a sensitive value ever becomes necessary, +# it is passed as -var or TF_VAR_* at the command line and never written to +# disk - and that is the moment to reconsider and reach for Secrets Manager +# instead. See docs/runbook.md. + +variable "account_id" { + description = "AWS account id. Checked by the provider before anything is planned, so a wrong-profile apply fails immediately." + type = string + + validation { + condition = can(regex("^[0-9]{12}$", var.account_id)) + error_message = "account_id must be 12 digits." + } +} + +variable "region" { + description = "AWS region. us-east-1 is cheapest and needs no aliased provider for CloudFront-facing certificates; it is a poor default for European players. See docs/decisions.md#region-us-east-1." + type = string + default = "us-east-1" +} + +variable "environment" { + description = "Environment name. Used in tags and in resource names." + type = string + default = "prod" +} + +variable "domain_name" { + description = "The apex domain. Player handles are subdomains of it." + type = string + default = "lance.blue" +} + +variable "vpc_cidr" { + description = "VPC CIDR." + type = string + default = "10.20.0.0/16" +} + +variable "az_count" { + description = "Availability zones to place public subnets in." + type = number + default = 3 +} diff --git a/envs/prod/versions.tf b/envs/prod/versions.tf new file mode 100644 index 0000000..d6fc022 --- /dev/null +++ b/envs/prod/versions.tf @@ -0,0 +1,13 @@ +terraform { + # 1.11 is the floor: S3-native state locking (`use_lockfile`) landed in 1.10 + # and `dynamodb_table` was deprecated in 1.11, so this is the first release + # where the backend below is the supported shape rather than a new option. + required_version = ">= 1.11" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} diff --git a/modules/network/main.tf b/modules/network/main.tf new file mode 100644 index 0000000..755135d --- /dev/null +++ b/modules/network/main.tf @@ -0,0 +1,123 @@ +# The VPC match tasks run in. +# +# Public subnets only, no NAT gateway, no interface endpoints. A NAT gateway is +# ~$33/month before a byte moves and the three interface endpoints that would +# replace it are ~$21/month; a public IP on a task is $0.005/hour and only for +# the length of the match. See docs/decisions.md#no-nat-gateway. +# +# Public IP does not mean reachable. Nothing in this module opens ingress - +# security groups are owned by the modules that need them, and the match task's +# group allows none at all. The WebSocket proxy is the only thing that talks to +# a task. +# +# Private subnets are deliberately not created rather than created and left +# empty: an empty private subnet invites someone to put something in it and +# discover the egress problem later. + +terraform { + required_version = ">= 1.11" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +data "aws_availability_zones" "available" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +locals { + # Fargate capacity is per-AZ and occasionally short in one of them, so spread + # across a few. Subnets cost nothing; only what runs in them does. + azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) + + # /20 per subnet out of a /16. Far more addresses than a match task needs, but + # the alternative is re-cutting the range the first time something else wants + # a subnet, and unused address space is free. + subnet_cidrs = [for i in range(var.az_count) : cidrsubnet(var.cidr_block, 4, i)] +} + +resource "aws_vpc" "this" { + cidr_block = var.cidr_block + + # DNS hostnames are required for the S3 gateway endpoint to be usable by name, + # and for anything that resolves an AWS service endpoint from inside the VPC. + enable_dns_support = true + enable_dns_hostnames = true + + tags = merge(var.tags, { Name = var.name_prefix }) +} + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { Name = var.name_prefix }) +} + +resource "aws_subnet" "public" { + for_each = { for i, az in local.azs : az => local.subnet_cidrs[i] } + + vpc_id = aws_vpc.this.id + availability_zone = each.key + cidr_block = each.value + + # Tasks get a public IP because there is no NAT to reach the internet through. + # ECS also sets this per-task; both have to agree. + map_public_ip_on_launch = true + + tags = merge(var.tags, { Name = "${var.name_prefix}-public-${each.key}" }) +} + +# One route table for all public subnets - they are identical, and per-subnet +# tables would only be useful if their routes ever differed. +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { Name = "${var.name_prefix}-public" }) +} + +resource "aws_route" "default" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id +} + +resource "aws_route_table_association" "public" { + for_each = aws_subnet.public + + subnet_id = each.value.id + route_table_id = aws_route_table.public.id +} + +# Gateway endpoints are free, unlike interface endpoints. This keeps S3 traffic +# - scenario downloads, camo, result uploads, and the layer pulls behind ECR - +# off the internet gateway path entirely. +# +# It is also the migration path if the assumption that in-region transfer over +# the IGW is free turns out to be wrong: S3 is already covered, and only ECR +# would need interface endpoints. +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.public.id] + + tags = merge(var.tags, { Name = "${var.name_prefix}-s3" }) +} + +# A VPC's default security group allows all traffic between anything that uses +# it. Nothing here should, so it is emptied - a default-open group that nothing +# references is still a default-open group waiting for someone to reference it. +resource "aws_default_security_group" "this" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { Name = "${var.name_prefix}-default-do-not-use" }) +} diff --git a/modules/network/outputs.tf b/modules/network/outputs.tf new file mode 100644 index 0000000..7d76f3a --- /dev/null +++ b/modules/network/outputs.tf @@ -0,0 +1,19 @@ +output "vpc_id" { + description = "VPC id." + value = aws_vpc.this.id +} + +output "vpc_cidr_block" { + description = "VPC CIDR, for security group rules that need to name the VPC's own range." + value = aws_vpc.this.cidr_block +} + +output "public_subnet_ids" { + description = "Public subnet ids, in availability-zone order. What an ECS network configuration wants." + value = [for az in local.azs : aws_subnet.public[az].id] +} + +output "availability_zones" { + description = "The availability zones subnets were placed in." + value = local.azs +} diff --git a/modules/network/variables.tf b/modules/network/variables.tf new file mode 100644 index 0000000..2a58921 --- /dev/null +++ b/modules/network/variables.tf @@ -0,0 +1,37 @@ +variable "name_prefix" { + description = "Prefix for resource names, e.g. lance-blue-prod." + type = string +} + +variable "region" { + description = "AWS region. Needed to build the S3 gateway endpoint service name; the provider's region is not readable from inside a module." + type = string +} + +variable "cidr_block" { + description = "VPC CIDR. Nothing peers with this VPC, so the range only has to avoid colliding with anything it might peer with later." + type = string + default = "10.20.0.0/16" + + validation { + condition = can(cidrnetmask(var.cidr_block)) + error_message = "cidr_block must be a valid IPv4 CIDR." + } +} + +variable "az_count" { + description = "How many availability zones to place public subnets in. Subnets are free; this only affects where Fargate can find capacity." + type = number + default = 3 + + validation { + condition = var.az_count >= 2 && var.az_count <= 4 + error_message = "az_count must be between 2 and 4." + } +} + +variable "tags" { + description = "Tags to merge into every resource, on top of the provider's default_tags." + type = map(string) + default = {} +}