From 2f90c3c6ce9724890239d0424f2f37d044011a74 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 1 Aug 2026 01:36:11 -0400 Subject: [PATCH] s3+cloudfront site, env-suffix names, commit tags; applied to prod --- README.md | 33 +-- TODO.md | 28 +-- bootstrap/README.md | 2 +- bootstrap/iam.tf | 5 +- bootstrap/main.tf | 15 +- bootstrap/outputs.tf | 4 +- bootstrap/terraform.tfstate | 11 +- bootstrap/variables.tf | 6 + docs/architecture.md | 6 +- docs/cost.md | 2 +- docs/decisions.md | 58 ++--- docs/runbook.md | 70 +++--- envs/{prod => lance.blue}/.terraform.lock.hcl | 0 envs/{prod => lance.blue}/backend.tf | 2 +- envs/lance.blue/main.tf | 105 ++++++++ envs/{prod => lance.blue}/outputs.tf | 22 +- envs/lance.blue/providers.tf | 38 +++ .../terraform.tfvars.example | 0 envs/{prod => lance.blue}/variables.tf | 14 +- envs/{prod => lance.blue}/versions.tf | 0 envs/prod/main.tf | 92 ------- envs/prod/providers.tf | 16 -- modules/artifacts/main.tf | 2 +- modules/artifacts/variables.tf | 5 + modules/dns/main.tf | 8 +- modules/match-cluster/control-plane.tf | 2 +- modules/match-cluster/main.tf | 18 +- modules/match-cluster/variables.tf | 7 +- modules/network/main.tf | 12 +- modules/network/variables.tf | 7 +- modules/static-site/main.tf | 238 +++++++++++++----- modules/static-site/outputs.tf | 23 +- modules/static-site/variables.tf | 27 +- prek.toml | 4 +- scripts/apply.sh | 2 +- scripts/common.sh | 5 + scripts/plan.sh | 6 +- 37 files changed, 527 insertions(+), 368 deletions(-) rename envs/{prod => lance.blue}/.terraform.lock.hcl (100%) rename envs/{prod => lance.blue}/backend.tf (92%) create mode 100644 envs/lance.blue/main.tf rename envs/{prod => lance.blue}/outputs.tf (76%) create mode 100644 envs/lance.blue/providers.tf rename envs/{prod => lance.blue}/terraform.tfvars.example (100%) rename envs/{prod => lance.blue}/variables.tf (83%) rename envs/{prod => lance.blue}/versions.tf (100%) delete mode 100644 envs/prod/main.tf delete mode 100644 envs/prod/providers.tf diff --git a/README.md b/README.md index ad20f2f..5f2fbc2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ AWS infrastructure for [lance.blue](https://lance.blue), as Terraform. Separate from the code repos on purpose: it is the only place that knows account ids, region and DNS, so nothing else has to. -The AWS account is `179302349187`. It contains only lance.blue. Bootstrap -(state bucket, apply role) is applied; the prod environment is not. +The AWS account is `179302349187`. It contains only lance.blue. Everything +written here is applied. | repo | what it is | |---|---| @@ -23,8 +23,8 @@ and assumed through the `lance-blue` profile. Anything sensitive is passed as `-var` on the command line. export AWS_PROFILE=lance-blue AWS_REGION=us-east-1 - ./scripts/plan.sh prod - ./scripts/apply.sh prod + ./scripts/plan.sh lance.blue + ./scripts/apply.sh lance.blue The scripts are thin wrappers around `terraform -chdir=envs/`; running Terraform directly works exactly the same. See @@ -45,28 +45,33 @@ They then run on every commit; `prek run --all-files` runs them by hand. | | | |---|---| -| `envs/prod/` | the only environment. Composes modules; holds the backend config and all real values. | +| `envs/lance.blue/` | the AWS account. Composes modules; holds the backend config and all real values. | | `modules/network/` | VPC, public subnets, no NAT gateway | | `modules/ecr/` | image repositories, one per name in a list, with a lifecycle policy | | `modules/artifacts/` | S3 for scenarios, camo and match results | | `modules/match-cluster/` | ECS cluster and the arena task definition | -| `modules/static-site/` | Amplify hosting for the headquarters frontend | +| `modules/static-site/` | S3 + CloudFront for the headquarters frontend | | `modules/dns/` | the `lance.blue` hosted zone | | `bootstrap/` | one-shot: the state bucket and the apply role | | `scripts/` | `plan.sh`, `apply.sh`, `fmt.sh`, `validate.sh` | | `docs/` | architecture, decisions, cost model, runbook, licensing | Modules take inputs and return outputs; they never read remote state or call -`aws` themselves. Everything environment-specific — account id, region, domain, -sizes — lives in `envs/prod/`, so a second environment is a directory copy -rather than a refactor. +`aws` themselves. Each directory under `envs/` is one AWS account. Dev and +prod resources share the account and the root, told apart by name suffix; +some resources (the hosted zone, ECR) are shared between environments. A +second AWS account would be a directory copy. ## Conventions -- **Names** are `lance-blue-[-]`. Prod is unsuffixed for - anything with a global namespace it would be awkward to rename. +- **Names** say what the thing is and end with the environment: + `lance-blue-match-task-sg-prod`, `lance-blue-web-bucket-prod-`. + Shared resources — the hosted zone, the ECR repositories — carry no + environment. Only prod exists today. - **Tags** come from `default_tags` on the provider: `Project=lance.blue`, - `Environment`, `ManagedBy=terraform`, `Repo=infra`. Modules add `Component`. + `ManagedBy=terraform`, `Repo=infra`, and `Commit` (the git sha the plan was + made from, set by `scripts/plan.sh`). Modules add `Component`, and + per-environment resources add `Environment`. - **Regions**: one, from `var.region`. Anything that must live in `us-east-1` regardless (CloudFront-facing certificates) uses an aliased provider and says so. @@ -100,5 +105,5 @@ See [docs/licensing.md](docs/licensing.md); arena's `LICENSING.md` is canonical. ## Status -See [TODO.md](TODO.md). Bootstrap is applied. The prod environment is written -but has never been planned or applied against the real account. +See [TODO.md](TODO.md). Everything written here is applied. The largest gap +is compute for the control plane, which is not written yet. diff --git a/TODO.md b/TODO.md index 478bfa4..6bd3530 100644 --- a/TODO.md +++ b/TODO.md @@ -1,18 +1,8 @@ # TODO -State: bootstrap is applied — the account (`179302349187`) has its state -bucket and apply role. The prod environment has never been planned or -applied. - -## First applies - -- [ ] **Run `plan` once.** Expect the first pass to fail on - things a plan catches and review does not: argument names that moved - between provider majors, arguments that are only valid in some - combinations. Nothing here has been executed. -- [ ] **Delegate `lance.blue` to Route 53** at the registrar — four NS records, - by hand. Until that happens nothing under the domain resolves and the - site certificate's DNS validation cannot complete. +State: everything written here is applied to the account (`179302349187`) — +network, registry, artifacts, match cluster, DNS (delegated), site. The big +gap is compute for the control plane. ## Not written yet @@ -23,12 +13,6 @@ applied. drain-on-deploy story (headquarters milestone M5) is what decides where this compute lives. Options and prices are in [docs/decisions.md](docs/decisions.md#where-the-control-plane-runs). -- [ ] **Replace `modules/static-site` with S3 + CloudFront.** Decided, - reversing the recorded Amplify choice: bucket + OAC + distribution + - ACM certificate (us-east-1) + SPA rewrite, and a deploy script that is - `aws s3 sync web/dist` plus an invalidation — runnable from any laptop, - no git integration needed, which was Amplify's missing feature anyway. - Record the reversal in [docs/decisions.md](docs/decisions.md). - [ ] **Attach the control-plane policies to hq's role when it exists.** `control_plane_policy_arns` (artifacts + run_match) are written and exported but attach to nothing. Headquarters' M4 milestone @@ -60,9 +44,9 @@ Findings from building the infrastructure that land as work somewhere else. Most are now raised in the repo that owns them; what stays here is the local follow-up once each lands. -- [ ] **An image to push.** arena builds and runs now; the ECR repository this - repo creates just has to exist for `push.sh` to have a target. Blocked - only on the first apply. +- [ ] **An image to push.** The `lance-blue/arena` repository now exists; + pushing the first image is arena's `push.sh` against the + `ecr_push_registry` output. - [ ] **arena's image tag is not unique per build.** Raised — now tracked in arena's TODO (fold a build ref into `IMAGE_TAG`). The follow-up here: set `image_tag_mutability = "IMMUTABLE"` once it lands. diff --git a/bootstrap/README.md b/bootstrap/README.md index 890538a..48aef71 100644 --- a/bootstrap/README.md +++ b/bootstrap/README.md @@ -14,7 +14,7 @@ Run it: terraform init terraform apply -var 'region=us-east-1' -It prints the bucket name, the `init` line the prod environment needs, and a +It prints the bucket name, the `init` line the environment needs, and a ready-made `~/.aws/config` block. ## After the apply diff --git a/bootstrap/iam.tf b/bootstrap/iam.tf index 7a3a18c..4659a50 100644 --- a/bootstrap/iam.tf +++ b/bootstrap/iam.tf @@ -69,9 +69,8 @@ data "aws_iam_policy_document" "apply" { "s3:*", # artifacts, the state bucket, the future site bucket "route53:*", # the hosted zone "logs:*", # the match log group - "cloudfront:*", # static site, once the S3 + CloudFront swap lands - "acm:*", # its us-east-1 certificate - "amplify:*", # static site as written today; drop with the module + "cloudfront:*", # static site + "acm:*", # the site certificate ] resources = ["*"] } diff --git a/bootstrap/main.tf b/bootstrap/main.tf index db230fb..b7148bb 100644 --- a/bootstrap/main.tf +++ b/bootstrap/main.tf @@ -25,12 +25,15 @@ provider "aws" { allowed_account_ids = [var.account_id] default_tags { - tags = { - Project = "lance.blue" - ManagedBy = "terraform" - Repo = "infra" - Component = "tfstate" - } + tags = merge( + { + Project = "lance.blue" + ManagedBy = "terraform" + Repo = "infra" + Component = "tfstate" + }, + var.git_commit == "" ? {} : { Commit = var.git_commit } + ) } } diff --git a/bootstrap/outputs.tf b/bootstrap/outputs.tf index 79c30a4..fbef483 100644 --- a/bootstrap/outputs.tf +++ b/bootstrap/outputs.tf @@ -4,8 +4,8 @@ output "state_bucket" { } 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}\"" + description = "Ready-made init for the environment, since a backend block cannot take variables." + value = "terraform -chdir=envs/lance.blue init -backend-config=\"bucket=${aws_s3_bucket.state.id}\" -backend-config=\"region=${var.region}\"" } output "apply_role_arn" { diff --git a/bootstrap/terraform.tfstate b/bootstrap/terraform.tfstate index 1a8530e..aa5ccd0 100644 --- a/bootstrap/terraform.tfstate +++ b/bootstrap/terraform.tfstate @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.12.5", - "serial": 5, + "serial": 6, "lineage": "df7f0e77-2b15-7eda-d5cb-2981f76d98e4", "outputs": { "apply_role_arn": { @@ -31,9 +31,9 @@ { "schema_version": 0, "attributes": { - "id": "1494155020", - "json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"ServiceAdmin\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:*\",\n \"route53:*\",\n \"logs:*\",\n \"ecs:*\",\n \"ecr:*\",\n \"ec2:*\",\n \"cloudfront:*\",\n \"amplify:*\",\n \"acm:*\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"IamRead\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:List*\",\n \"iam:Get*\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"IamPrefixed\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:*\",\n \"Resource\": [\n \"arn:aws:iam::179302349187:role/lance-blue-*\",\n \"arn:aws:iam::179302349187:policy/lance-blue-*\"\n ]\n },\n {\n \"Sid\": \"ServiceLinkedRoles\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:CreateServiceLinkedRole\",\n \"Resource\": \"*\",\n \"Condition\": {\n \"StringEquals\": {\n \"iam:AWSServiceName\": \"ecs.amazonaws.com\"\n }\n }\n },\n {\n \"Sid\": \"DenySelfMutation\",\n \"Effect\": \"Deny\",\n \"Action\": \"iam:*\",\n \"Resource\": [\n \"arn:aws:iam::179302349187:role/lance-blue-apply\",\n \"arn:aws:iam::179302349187:policy/lance-blue-apply\"\n ]\n }\n ]\n}", - "minified_json": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"ServiceAdmin\",\"Effect\":\"Allow\",\"Action\":[\"s3:*\",\"route53:*\",\"logs:*\",\"ecs:*\",\"ecr:*\",\"ec2:*\",\"cloudfront:*\",\"amplify:*\",\"acm:*\"],\"Resource\":\"*\"},{\"Sid\":\"IamRead\",\"Effect\":\"Allow\",\"Action\":[\"iam:List*\",\"iam:Get*\"],\"Resource\":\"*\"},{\"Sid\":\"IamPrefixed\",\"Effect\":\"Allow\",\"Action\":\"iam:*\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-*\",\"arn:aws:iam::179302349187:policy/lance-blue-*\"]},{\"Sid\":\"ServiceLinkedRoles\",\"Effect\":\"Allow\",\"Action\":\"iam:CreateServiceLinkedRole\",\"Resource\":\"*\",\"Condition\":{\"StringEquals\":{\"iam:AWSServiceName\":\"ecs.amazonaws.com\"}}},{\"Sid\":\"DenySelfMutation\",\"Effect\":\"Deny\",\"Action\":\"iam:*\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-apply\",\"arn:aws:iam::179302349187:policy/lance-blue-apply\"]}]}", + "id": "3066737992", + "json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"ServiceAdmin\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:*\",\n \"route53:*\",\n \"logs:*\",\n \"ecs:*\",\n \"ecr:*\",\n \"ec2:*\",\n \"cloudfront:*\",\n \"acm:*\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"IamRead\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:List*\",\n \"iam:Get*\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"IamPrefixed\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:*\",\n \"Resource\": [\n \"arn:aws:iam::179302349187:role/lance-blue-*\",\n \"arn:aws:iam::179302349187:policy/lance-blue-*\"\n ]\n },\n {\n \"Sid\": \"ServiceLinkedRoles\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:CreateServiceLinkedRole\",\n \"Resource\": \"*\",\n \"Condition\": {\n \"StringEquals\": {\n \"iam:AWSServiceName\": \"ecs.amazonaws.com\"\n }\n }\n },\n {\n \"Sid\": \"DenySelfMutation\",\n \"Effect\": \"Deny\",\n \"Action\": \"iam:*\",\n \"Resource\": [\n \"arn:aws:iam::179302349187:role/lance-blue-apply\",\n \"arn:aws:iam::179302349187:policy/lance-blue-apply\"\n ]\n }\n ]\n}", + "minified_json": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"ServiceAdmin\",\"Effect\":\"Allow\",\"Action\":[\"s3:*\",\"route53:*\",\"logs:*\",\"ecs:*\",\"ecr:*\",\"ec2:*\",\"cloudfront:*\",\"acm:*\"],\"Resource\":\"*\"},{\"Sid\":\"IamRead\",\"Effect\":\"Allow\",\"Action\":[\"iam:List*\",\"iam:Get*\"],\"Resource\":\"*\"},{\"Sid\":\"IamPrefixed\",\"Effect\":\"Allow\",\"Action\":\"iam:*\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-*\",\"arn:aws:iam::179302349187:policy/lance-blue-*\"]},{\"Sid\":\"ServiceLinkedRoles\",\"Effect\":\"Allow\",\"Action\":\"iam:CreateServiceLinkedRole\",\"Resource\":\"*\",\"Condition\":{\"StringEquals\":{\"iam:AWSServiceName\":\"ecs.amazonaws.com\"}}},{\"Sid\":\"DenySelfMutation\",\"Effect\":\"Deny\",\"Action\":\"iam:*\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-apply\",\"arn:aws:iam::179302349187:policy/lance-blue-apply\"]}]}", "override_json": null, "override_policy_documents": null, "policy_id": null, @@ -43,7 +43,6 @@ { "actions": [ "acm:*", - "amplify:*", "cloudfront:*", "ec2:*", "ecr:*", @@ -267,7 +266,7 @@ "name": "lance-blue-apply", "name_prefix": "", "path": "/", - "policy": "{\"Statement\":[{\"Action\":[\"s3:*\",\"route53:*\",\"logs:*\",\"ecs:*\",\"ecr:*\",\"ec2:*\",\"cloudfront:*\",\"amplify:*\",\"acm:*\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"ServiceAdmin\"},{\"Action\":[\"iam:List*\",\"iam:Get*\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"IamRead\"},{\"Action\":\"iam:*\",\"Effect\":\"Allow\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-*\",\"arn:aws:iam::179302349187:policy/lance-blue-*\"],\"Sid\":\"IamPrefixed\"},{\"Action\":\"iam:CreateServiceLinkedRole\",\"Condition\":{\"StringEquals\":{\"iam:AWSServiceName\":\"ecs.amazonaws.com\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"ServiceLinkedRoles\"},{\"Action\":\"iam:*\",\"Effect\":\"Deny\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-apply\",\"arn:aws:iam::179302349187:policy/lance-blue-apply\"],\"Sid\":\"DenySelfMutation\"}],\"Version\":\"2012-10-17\"}", + "policy": "{\"Statement\":[{\"Action\":[\"s3:*\",\"route53:*\",\"logs:*\",\"ecs:*\",\"ecr:*\",\"ec2:*\",\"cloudfront:*\",\"acm:*\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"ServiceAdmin\"},{\"Action\":[\"iam:List*\",\"iam:Get*\"],\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"IamRead\"},{\"Action\":\"iam:*\",\"Effect\":\"Allow\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-*\",\"arn:aws:iam::179302349187:policy/lance-blue-*\"],\"Sid\":\"IamPrefixed\"},{\"Action\":\"iam:CreateServiceLinkedRole\",\"Condition\":{\"StringEquals\":{\"iam:AWSServiceName\":\"ecs.amazonaws.com\"}},\"Effect\":\"Allow\",\"Resource\":\"*\",\"Sid\":\"ServiceLinkedRoles\"},{\"Action\":\"iam:*\",\"Effect\":\"Deny\",\"Resource\":[\"arn:aws:iam::179302349187:role/lance-blue-apply\",\"arn:aws:iam::179302349187:policy/lance-blue-apply\"],\"Sid\":\"DenySelfMutation\"}],\"Version\":\"2012-10-17\"}", "policy_id": "ANPASTP2AXGBU3EOURZUH", "tags": { "Component": "deploy" diff --git a/bootstrap/variables.tf b/bootstrap/variables.tf index 8924b5f..bad8fdb 100644 --- a/bootstrap/variables.tf +++ b/bootstrap/variables.tf @@ -10,6 +10,12 @@ variable "name_prefix" { default = "lance-blue" } +variable "git_commit" { + description = "Commit the plan was made from, tagged onto every resource. Empty omits the tag." + type = string + default = "" +} + variable "account_id" { description = "The AWS account this repo owns." type = string diff --git a/docs/architecture.md b/docs/architecture.md index b20c7a2..7f314fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,8 +15,8 @@ Nothing here is deployed. Where a component has no Terraform yet, it says so. www / static hq.lance.blue │ │ ▼ ▼ - Amplify headquarters - (frontend) control plane + proxy + CloudFront headquarters + (frontend, S3) control plane + proxy │ launch a task │ WebSocket proxy ▼ @@ -42,7 +42,7 @@ Nothing here is deployed. Where a component has no Terraform yet, it says so. | match execution | ECS Fargate | `modules/match-cluster` | written | | match artifacts | S3 | `modules/artifacts` | written | | network | VPC, public subnets, IGW | `modules/network` | written | -| frontend hosting | Amplify | `modules/static-site` | written | +| frontend hosting | S3 + CloudFront | `modules/static-site` | written | | DNS | Route 53 | `modules/dns` | written | | control plane + WebSocket proxy | undecided | — | **not written** | diff --git a/docs/cost.md b/docs/cost.md index 4e14164..cc00b0c 100644 --- a/docs/cost.md +++ b/docs/cost.md @@ -17,7 +17,7 @@ were wrong by more than an order of magnitude in the direction that matters. | ECR storage, ~1GB × 10 retained arena images | $0.10/GB-month | $1.00 | | ECR storage, the control-plane image, far smaller | $0.10/GB-month | ~$0.15 | | S3 artifacts, a few GB | $0.023/GB-month | ~$0.10 | -| Amplify hosting, storage | $0.023/GB-month | ~$0.05 | +| S3 site bucket + CloudFront (free tier covers 1TB/month served) | $0.023/GB-month | ~$0.05 | | CloudWatch Logs, 7-day retention | $0.50/GB ingest | ~$0.20 | | VPC, subnets, IGW, S3 gateway endpoint | free | — | | | | **~$2/month** | diff --git a/docs/decisions.md b/docs/decisions.md index dbf1726..21f8c0c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -43,7 +43,7 @@ first. That is on [arena's TODO](https://tangled.org/lance.blue/arena/blob/main/TODO.md), and mirrored on [ours](../TODO.md). -The variable is `match_task_architecture` in `envs/prod`. Flipping it to +The variable is `match_task_architecture` in `envs/lance.blue`. Flipping it to `ARM64` is a one-line change once arena publishes an arm64 image — and it will fail loudly rather than subtly if the image is the wrong architecture. @@ -64,30 +64,14 @@ break. Saving $0.02 on a match by occasionally destroying one is a bad trade. supports natively. Then Spot becomes the obvious default and the two-minute warning is enough to save and hand back. -## Amplify for the static site, deployed manually +## S3 + CloudFront for the static site -**Chosen:** `aws_amplify_app` with **no repository connected**, deployed by -uploading a build artifact. - -The constraint that decides this: **Amplify's git integration supports GitHub, -GitLab, Bitbucket and CodeCommit — not tangled.org.** The repos are on tangled, -so the feature that makes Amplify worth choosing over plain S3 is unavailable. -What is left is manual deployment: `aws amplify create-deployment` returns a -presigned URL, you upload a zip, `start-deployment` publishes it. - -Amplify is still the smaller amount of Terraform. S3 + CloudFront + OAC + -response-headers policy + invalidation-on-deploy is four or five resources and -a cache-busting story; Amplify is one resource that already does atomic -deploys, custom domains, TLS and SPA rewrites. - -Costs are close enough not to decide it: Amplify hosting is $0.023/GB stored -and $0.15/GB served, CloudFront is ~$0.085/GB served with a free tier. At the -traffic a fan project sees, both round to a couple of dollars. - -**Wrong if:** the frontend grows enough traffic for the per-GB difference to -matter, or if wanting one CloudFront distribution in front of both the static -site and the control plane makes the split awkward. Revisit then; the module -boundary is drawn so the swap does not touch anything else. +**Chosen:** a private S3 bucket behind CloudFront, with Origin Access +Control, an ACM certificate in us-east-1, and index.html served for missing +paths so client-side routes work. A deploy is `aws s3 sync` plus an +invalidation, run from a laptop. The distribution can also serve from a +prefix inside the bucket (`site_origin_path`), so headquarters can push +versioned builds and flip between them. ## S3 backend with native locking, no DynamoDB @@ -138,19 +122,25 @@ bootstrap again with administrator credentials. — or the account ever hosts anything that is not this project; the wildcards assume single tenancy. -## One environment +## One account, environments by suffix + +**Chosen:** each directory under `envs/` is one AWS account; `envs/lance.blue` +is the only one. Dev and prod resources share the account and the Terraform +root, told apart by name: `lance-blue--prod` for per-environment +resources, `lance-blue-` for shared ones (the hosted zone, the ECR +repositories). Only prod's resources exist today. -**Chosen:** `envs/prod` and nothing else. +Standing dev copies of everything would double the standing cost to test +infrastructure that changes a few times a year. Dev resources are added to +this root when something needs rehearsing and destroyed after; the shared +zone and registry exist once either way. -A staging environment doubles the standing cost — a second hosted zone, a -second Amplify app, a second everything — to test infrastructure that changes -a few times a year. The modules are parameterised so a second environment is a -directory copy, and that is where the value is: the ability to make one, not -the standing cost of having one. +A separate dev AWS account only happens if there is extensive dev work or +other contributors. Then it is a directory copy with its own backend key. -**Wrong if:** a change ever needs to be rehearsed against real AWS before it -touches players' matches. Then create `envs/dev`, apply it, destroy it, and -delete the directory again. +**Wrong if:** a dev experiment can break prod — they share an account, the +zone and the registry. Anything that risky is the trigger for the separate +account. ## Region: us-east-1 diff --git a/docs/runbook.md b/docs/runbook.md index ac531fa..2a33635 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -34,8 +34,8 @@ Terraform and OpenTofu read AWS profiles natively, including SSO ones. Anything sensitive that Terraform itself needs is passed at the command line: - ./scripts/apply.sh prod -var 'some_token=…' - TF_VAR_some_token=… ./scripts/apply.sh prod + ./scripts/apply.sh lance.blue -var 'some_token=…' + TF_VAR_some_token=… ./scripts/apply.sh lance.blue Two things to know about that. Values passed with `-var` **end up in state** — that is a Terraform property, not a choice this repo makes — so the state @@ -61,8 +61,8 @@ role. terraform init terraform apply -var 'region=us-east-1' -It prints the bucket name, the prod `init` line and the `~/.aws/config` block -for the next step. Bootstrap keeps its state in `bootstrap/`, on local disk, +It prints the bucket name, the environment's `init` line and the +`~/.aws/config` block for the next step. Bootstrap keeps its state in `bootstrap/`, on local disk, because the bucket cannot hold the state of its own creation. That file is gitignored and losing it is recoverable — the bucket has `prevent_destroy` and re-importing is one command: @@ -82,49 +82,53 @@ automatically while your SSO login lasts. The administrator credentials are only needed again to change the apply role itself: the role cannot change its own permissions. -**4. Initialise the environment.** `envs/prod/backend.tf` already contains +**4. Initialise the environment.** `envs/lance.blue/backend.tf` already contains the bucket name. (A backend block cannot use variables, so it is written out.) - terraform -chdir=envs/prod init + terraform -chdir=envs/lance.blue init **5. Set the environment's values.** - cp envs/prod/terraform.tfvars.example envs/prod/terraform.tfvars + cp envs/lance.blue/terraform.tfvars.example envs/lance.blue/terraform.tfvars Fill in the account id and the domain. `terraform.tfvars` is gitignored — the `.gitignore` treats every `*.tfvars` as suspect on principle, even when, as here, it holds nothing secret. -**6. Plan, read the plan, apply.** +**6. Create the hosted zone and delegate the domain.** The site certificate +validates through DNS, so the zone must exist and the domain must be +delegated before the full apply can finish. - ./scripts/plan.sh prod - ./scripts/apply.sh prod + terraform -chdir=envs/lance.blue apply -target=module.dns + terraform -chdir=envs/lance.blue output nameservers + +Set the four nameservers at the registrar, by hand. `dig NS lance.blue` +shows when the change is live. + +**7. Plan, read the plan, apply.** + + ./scripts/plan.sh lance.blue + ./scripts/apply.sh lance.blue ## Day to day ./scripts/fmt.sh # terraform fmt -recursive ./scripts/validate.sh # init -backend=false + validate, every module - ./scripts/plan.sh prod # plan, saved to .work/prod.tfplan - ./scripts/apply.sh prod # apply that saved plan + ./scripts/plan.sh lance.blue # plan, saved to .work/lance.blue.tfplan + ./scripts/apply.sh lance.blue # apply that saved plan `apply.sh` applies the **saved plan** rather than re-planning, so what you read is what runs. ## After the first apply -**Delegate the domain.** Route 53 issues four nameservers for the hosted zone; -they have to be set at the registrar by hand. `terraform output nameservers` -prints them. Until that is done nothing under the domain resolves, and -Amplify's domain association sits unverified — which is why the association -does not wait for verification and the apply does not hang on it. - **Tell a repo where to push.** Two outputs, because `docker login` wants the registry host and a push script wants the host plus namespace — each appends the basename of its own `IMAGE_NAME`: cd ../arena # from an infra checkout - INFRA=../infra/envs/prod + INFRA=../infra/envs/lance.blue aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin \ @@ -134,20 +138,18 @@ the basename of its own `IMAGE_NAME`: REGISTRY=$(terraform -chdir=$INFRA output -raw ecr_push_registry) ./push.sh The same `REGISTRY` works for every repository in the namespace. To see what -exists: `terraform -chdir=envs/prod output -json ecr_repositories`. - -**Deploy the frontend.** Amplify is not connected to a git repository — -tangled.org is not one of the providers it supports — so deploys are manual: +exists: `terraform -chdir=envs/lance.blue output -json ecr_repositories`. - APP_ID=$(terraform -chdir=envs/prod output -raw amplify_app_id) +**Deploy the frontend.** Headquarters builds the site; deploying it is a sync +plus an invalidation: - aws amplify create-deployment --app-id "$APP_ID" --branch-name main - # PUT the built site as a zip to the zipUploadUrl it returns, then: - aws amplify start-deployment --app-id "$APP_ID" --branch-name main --job-id "$JOB_ID" + aws s3 sync dist/ "s3://$(terraform -chdir=envs/lance.blue output -raw site_bucket)/" --delete + aws cloudfront create-invalidation \ + --distribution-id "$(terraform -chdir=envs/lance.blue output -raw site_distribution_id)" \ + --paths '/*' -There is no script for this yet because there is no frontend to deploy. -`amplify_branch_url` is the amplifyapp.com address, which works before the -custom domain is delegated. +`site_cloudfront_url` is the cloudfront.net address, which works before the +domain is delegated. ## Starting a match by hand @@ -156,12 +158,12 @@ task for testing. Everything per-match is a container override — the manifest URL is a bearer credential and does not belong in a task definition revision. aws ecs run-task \ - --cluster "$(terraform -chdir=envs/prod output -raw match_cluster_name)" \ - --task-definition "$(terraform -chdir=envs/prod output -raw match_task_definition)" \ + --cluster "$(terraform -chdir=envs/lance.blue output -raw match_cluster_name)" \ + --task-definition "$(terraform -chdir=envs/lance.blue output -raw match_task_definition)" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={ - subnets=[$(terraform -chdir=envs/prod output -json public_subnet_ids | jq -r 'join(",")')], - securityGroups=[$(terraform -chdir=envs/prod output -raw match_task_security_group_id)], + subnets=[$(terraform -chdir=envs/lance.blue output -json public_subnet_ids | jq -r 'join(",")')], + securityGroups=[$(terraform -chdir=envs/lance.blue output -raw match_task_security_group_id)], assignPublicIp=ENABLED}" \ --overrides '{"containerOverrides":[{"name":"arena","environment":[ {"name":"ARENA_MANIFEST_URL","value":"https://…presigned…"}]}]}' diff --git a/envs/prod/.terraform.lock.hcl b/envs/lance.blue/.terraform.lock.hcl similarity index 100% rename from envs/prod/.terraform.lock.hcl rename to envs/lance.blue/.terraform.lock.hcl diff --git a/envs/prod/backend.tf b/envs/lance.blue/backend.tf similarity index 92% rename from envs/prod/backend.tf rename to envs/lance.blue/backend.tf index 29243f5..b23f14c 100644 --- a/envs/prod/backend.tf +++ b/envs/lance.blue/backend.tf @@ -10,7 +10,7 @@ terraform { backend "s3" { bucket = "lance-blue-tfstate-179302349187" - key = "prod/terraform.tfstate" + key = "lance.blue/terraform.tfstate" region = "us-east-1" encrypt = true diff --git a/envs/lance.blue/main.tf b/envs/lance.blue/main.tf new file mode 100644 index 0000000..6842f38 --- /dev/null +++ b/envs/lance.blue/main.tf @@ -0,0 +1,105 @@ +# The lance.blue AWS account. Each directory under envs/ is one account. +# +# Dev and prod resources share the account and this root, told apart by an +# environment suffix on the name: lance-blue--prod. Shared +# resources - the hosted zone, the ECR repositories - carry no environment. +# Only prod's resources exist today. See +# docs/decisions.md#one-account-environments-by-suffix. +# +# 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 { + project = "lance-blue" + env = "prod" +} + +data "aws_caller_identity" "current" {} + +module "network" { + source = "../../modules/network" + + name_prefix = local.project + environment = local.env + region = var.region + cidr_block = var.vpc_cidr + az_count = var.az_count + + tags = { Component = "network", Environment = local.env } +} + +# Shared between environments: repository names come from each repo's own +# IMAGE_NAME, which is a cross-repo constant. Environments share the registry +# and differ by image tag. +module "ecr" { + source = "../../modules/ecr" + + repositories = var.ecr_repositories + retained_image_count = var.ecr_retained_image_count + + tags = { Component = "registry" } +} + +module "artifacts" { + source = "../../modules/artifacts" + + # Bucket names are globally unique; the account id is the least surprising way + # to get there and makes a wrong-account apply visible in the plan. + bucket_name = "${local.project}-artifacts-bucket-${local.env}-${data.aws_caller_identity.current.account_id}" + name_prefix = local.project + environment = local.env + + tags = { Component = "artifacts", Environment = local.env } +} + +module "match_cluster" { + source = "../../modules/match-cluster" + + name_prefix = local.project + environment = local.env + vpc_id = module.network.vpc_id + + image_repository_url = module.ecr.repositories["arena"].url + image_repository_arn = module.ecr.repositories["arena"].arn + image_tag = var.match_image_tag + + task_cpu = var.match_task_cpu + task_memory = var.match_task_memory + task_architecture = var.match_task_architecture + + # No proxy exists yet, so a match task accepts no ingress from anything. + proxy_security_group_ids = [] + + tags = { Component = "match", Environment = local.env } +} + +# Shared between environments. +module "dns" { + source = "../../modules/dns" + + domain_name = var.domain_name + + tags = { Component = "dns" } +} + +# The frontend. Headquarters builds the site and pushes it to the bucket; +# this repo provides the bucket and the CloudFront distribution. The +# certificate cannot validate until the domain is delegated at the registrar +# - see the runbook for the first-apply order. +module "static_site" { + source = "../../modules/static-site" + + name = "${local.project}-web-cdn-${local.env}" + bucket_name = "${local.project}-web-bucket-${local.env}-${data.aws_caller_identity.current.account_id}" + domain_name = var.domain_name + zone_id = module.dns.zone_id + origin_path = var.site_origin_path + + providers = { + aws = aws + aws.us_east_1 = aws.us_east_1 + } + + tags = { Component = "web", Environment = local.env } +} diff --git a/envs/prod/outputs.tf b/envs/lance.blue/outputs.tf similarity index 76% rename from envs/prod/outputs.tf rename to envs/lance.blue/outputs.tf index 9813499..456fe87 100644 --- a/envs/prod/outputs.tf +++ b/envs/lance.blue/outputs.tf @@ -58,14 +58,24 @@ output "nameservers" { value = module.dns.name_servers } -output "amplify_app_id" { - description = "Amplify app id, for create-deployment and start-deployment." - value = module.static_site.app_id +# What a frontend deploy wants: +# +# aws s3 sync dist/ "s3://$(terraform output -raw site_bucket)/" --delete +# aws cloudfront create-invalidation \ +# --distribution-id "$(terraform output -raw site_distribution_id)" --paths '/*' +output "site_bucket" { + description = "Bucket headquarters pushes the built site to." + value = module.static_site.bucket_name +} + +output "site_distribution_id" { + description = "CloudFront distribution id, for invalidations." + value = module.static_site.distribution_id } -output "amplify_branch_url" { - description = "The site on its amplifyapp.com domain, which works before the custom domain is delegated." - value = module.static_site.branch_url +output "site_cloudfront_url" { + description = "The site's cloudfront.net address, which works before the domain is delegated." + value = module.static_site.distribution_domain_name } # Both are unattached. There is no control plane role to attach them to; when diff --git a/envs/lance.blue/providers.tf b/envs/lance.blue/providers.tf new file mode 100644 index 0000000..bf486fd --- /dev/null +++ b/envs/lance.blue/providers.tf @@ -0,0 +1,38 @@ +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 = merge( + { + Project = "lance.blue" + ManagedBy = "terraform" + Repo = "infra" + }, + var.git_commit == "" ? {} : { Commit = var.git_commit } + ) + } +} + +# CloudFront only accepts certificates from us-east-1, whatever var.region +# says. Today they are the same region; the alias keeps that explicit. +provider "aws" { + alias = "us_east_1" + region = "us-east-1" + + allowed_account_ids = [var.account_id] + + default_tags { + tags = merge( + { + Project = "lance.blue" + ManagedBy = "terraform" + Repo = "infra" + }, + var.git_commit == "" ? {} : { Commit = var.git_commit } + ) + } +} diff --git a/envs/prod/terraform.tfvars.example b/envs/lance.blue/terraform.tfvars.example similarity index 100% rename from envs/prod/terraform.tfvars.example rename to envs/lance.blue/terraform.tfvars.example diff --git a/envs/prod/variables.tf b/envs/lance.blue/variables.tf similarity index 83% rename from envs/prod/variables.tf rename to envs/lance.blue/variables.tf index 710ed34..d884c04 100644 --- a/envs/prod/variables.tf +++ b/envs/lance.blue/variables.tf @@ -18,15 +18,15 @@ variable "account_id" { } 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." + description = "AWS region. us-east-1 is cheapest; 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." +variable "git_commit" { + description = "Commit the plan was made from, tagged onto every resource. scripts/plan.sh sets it; empty omits the tag." type = string - default = "prod" + default = "" } variable "domain_name" { @@ -35,6 +35,12 @@ variable "domain_name" { default = "lance.blue" } +variable "site_origin_path" { + description = "Prefix inside the site bucket that CloudFront serves from, e.g. /releases/v12. Empty serves the bucket root. Lets headquarters push versioned builds and flip between them with an apply." + type = string + default = "" +} + variable "vpc_cidr" { description = "VPC CIDR." type = string diff --git a/envs/prod/versions.tf b/envs/lance.blue/versions.tf similarity index 100% rename from envs/prod/versions.tf rename to envs/lance.blue/versions.tf diff --git a/envs/prod/main.tf b/envs/prod/main.tf deleted file mode 100644 index 1030e19..0000000 --- a/envs/prod/main.tf +++ /dev/null @@ -1,92 +0,0 @@ -# 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}" -} - -data "aws_caller_identity" "current" {} - -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" } -} - -# Repositories are not environment-suffixed: the names come from each repo's -# own IMAGE_NAME, which is a cross-repo constant. A second environment would -# share the registry and differ by tag. -module "ecr" { - source = "../../modules/ecr" - - repositories = var.ecr_repositories - retained_image_count = var.ecr_retained_image_count - - tags = { Component = "registry" } -} - -module "artifacts" { - source = "../../modules/artifacts" - - # Bucket names are globally unique; the account id is the least surprising way - # to get there and makes a wrong-account apply visible in the plan. - bucket_name = "${local.name_prefix}-artifacts-${data.aws_caller_identity.current.account_id}" - name_prefix = local.name_prefix - - tags = { Component = "artifacts" } -} - -module "match_cluster" { - source = "../../modules/match-cluster" - - name_prefix = local.name_prefix - vpc_id = module.network.vpc_id - - image_repository_url = module.ecr.repositories["arena"].url - image_repository_arn = module.ecr.repositories["arena"].arn - image_tag = var.match_image_tag - - task_cpu = var.match_task_cpu - task_memory = var.match_task_memory - task_architecture = var.match_task_architecture - - # No proxy exists yet, so a match task accepts no ingress from anything. - proxy_security_group_ids = [] - - tags = { Component = "match" } -} - -module "dns" { - source = "../../modules/dns" - - domain_name = var.domain_name - - tags = { Component = "dns" } -} - -# The frontend does not exist yet. The app is created anyway because it is free -# until something is deployed to it, and because the domain association is the -# slow part - it can be verifying while there is still nothing to serve. -module "static_site" { - source = "../../modules/static-site" - - name = "${local.name_prefix}-web" - domain_name = var.domain_name - - tags = { Component = "web" } - - # Amplify writes records into the hosted zone, so the zone has to exist first. - # Nothing in the module's arguments says so. - depends_on = [module.dns] -} diff --git a/envs/prod/providers.tf b/envs/prod/providers.tf deleted file mode 100644 index 2d34b44..0000000 --- a/envs/prod/providers.tf +++ /dev/null @@ -1,16 +0,0 @@ -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/modules/artifacts/main.tf b/modules/artifacts/main.tf index 6913ccc..1de61e4 100644 --- a/modules/artifacts/main.tf +++ b/modules/artifacts/main.tf @@ -158,7 +158,7 @@ data "aws_iam_policy_document" "bucket" { resource "aws_iam_policy" "control_plane" { count = var.create_control_plane_policy ? 1 : 0 - name = "${var.name_prefix}-artifacts-access" + name = "${var.name_prefix}-artifacts-access-policy-${var.environment}" description = "Read and write match artifacts, and sign URLs granting the same. Intended for the control plane's role; nothing attaches it yet." policy = data.aws_iam_policy_document.control_plane.json diff --git a/modules/artifacts/variables.tf b/modules/artifacts/variables.tf index 6817303..2a65b34 100644 --- a/modules/artifacts/variables.tf +++ b/modules/artifacts/variables.tf @@ -8,6 +8,11 @@ variable "name_prefix" { type = string } +variable "environment" { + description = "Environment these resources belong to, e.g. prod. Appended to every name." + type = string +} + variable "match_artifact_retention_days" { description = "Days to keep everything under matches/. This is a staging area; the durable record of a match belongs in ATProto records." type = number diff --git a/modules/dns/main.tf b/modules/dns/main.tf index 30e5352..c9b67f4 100644 --- a/modules/dns/main.tf +++ b/modules/dns/main.tf @@ -5,10 +5,10 @@ # do not issue handles under this domain, so nothing writes a record here per # player. See docs/architecture.md#we-do-not-host-identity. # -# No certificate either. Amplify provisions its own for the site, and the only -# thing that wanted one from us was per-player TLS, which is not happening. When -# the control plane exists it will need a certificate for its own name; that -# belongs with the control plane, not here. +# No certificate either. The site's certificate lives in the static-site +# module, and the only other thing that wanted one from us was per-player TLS, +# which is not happening. When the control plane exists it will need a +# certificate for its own name; that belongs with the control plane, not here. terraform { required_version = ">= 1.11" diff --git a/modules/match-cluster/control-plane.tf b/modules/match-cluster/control-plane.tf index 4f9dac3..9b78be5 100644 --- a/modules/match-cluster/control-plane.tf +++ b/modules/match-cluster/control-plane.tf @@ -7,7 +7,7 @@ resource "aws_iam_policy" "control_plane" { count = var.create_control_plane_policy ? 1 : 0 - name = "${var.name_prefix}-run-match" + name = "${var.name_prefix}-run-match-policy-${var.environment}" description = "Start, stop and observe match tasks. Intended for the control plane's role; nothing attaches it yet." policy = data.aws_iam_policy_document.control_plane.json diff --git a/modules/match-cluster/main.tf b/modules/match-cluster/main.tf index 7136f89..5aa6314 100644 --- a/modules/match-cluster/main.tf +++ b/modules/match-cluster/main.tf @@ -22,7 +22,7 @@ data "aws_region" "current" {} data "aws_caller_identity" "current" {} resource "aws_ecs_cluster" "this" { - name = var.name_prefix + name = "${var.name_prefix}-match-cluster-${var.environment}" # Container Insights is charged per metric per task and would be billed on # every match. There is no monitoring at all yet (see TODO.md); when there is, @@ -55,10 +55,10 @@ resource "aws_ecs_cluster_capacity_providers" "this" { # caps storage, not ingest - if the bill is a surprise it will be the ingest # side, and the fix is sampling rather than a shorter window. resource "aws_cloudwatch_log_group" "match" { - name = "/lance-blue/${var.name_prefix}/match" + name = "/${var.name_prefix}/match-${var.environment}" retention_in_days = var.log_retention_days - tags = merge(var.tags, { Name = "${var.name_prefix}-match" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-match-logs-${var.environment}" }) } # --- roles ------------------------------------------------------------------- @@ -96,7 +96,7 @@ data "aws_iam_policy_document" "assume_ecs_tasks" { # Written out rather than attaching AmazonECSTaskExecutionRolePolicy, which # grants ECR pull on every repository in the account and logs on every group. resource "aws_iam_role" "execution" { - name = "${var.name_prefix}-task-execution" + name = "${var.name_prefix}-match-task-execution-role-${var.environment}" assume_role_policy = data.aws_iam_policy_document.assume_ecs_tasks.json tags = var.tags } @@ -149,7 +149,7 @@ resource "aws_iam_role_policy" "execution" { # obvious reason to argue about it first. See # docs/architecture.md#the-task-never-gets-a-credential. resource "aws_iam_role" "task" { - name = "${var.name_prefix}-task" + name = "${var.name_prefix}-match-task-role-${var.environment}" assume_role_policy = data.aws_iam_policy_document.assume_ecs_tasks.json tags = merge(var.tags, { Note = "intentionally-no-permissions" }) @@ -163,11 +163,11 @@ resource "aws_iam_role" "task" { # reaches the port is whoever the game thinks they are. The proxy is the auth # boundary. See docs/architecture.md#networking. resource "aws_security_group" "task" { - name = "${var.name_prefix}-task" + name = "${var.name_prefix}-match-task-sg-${var.environment}" description = "Match tasks. Egress only unless a proxy security group is named." vpc_id = var.vpc_id - tags = merge(var.tags, { Name = "${var.name_prefix}-task" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-match-task-sg-${var.environment}" }) } resource "aws_vpc_security_group_ingress_rule" "from_proxy" { @@ -240,7 +240,7 @@ locals { } resource "aws_ecs_task_definition" "arena" { - family = "${var.name_prefix}-arena" + family = "${var.name_prefix}-arena-task-${var.environment}" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" @@ -257,5 +257,5 @@ resource "aws_ecs_task_definition" "arena" { container_definitions = jsonencode([local.container]) - tags = merge(var.tags, { Name = "${var.name_prefix}-arena" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-arena-task-${var.environment}" }) } diff --git a/modules/match-cluster/variables.tf b/modules/match-cluster/variables.tf index 454f0f5..2e866a8 100644 --- a/modules/match-cluster/variables.tf +++ b/modules/match-cluster/variables.tf @@ -1,5 +1,10 @@ variable "name_prefix" { - description = "Prefix for resource names, e.g. lance-blue-prod." + description = "Prefix for resource names, e.g. lance-blue." + type = string +} + +variable "environment" { + description = "Environment these resources belong to, e.g. prod. Appended to every name." type = string } diff --git a/modules/network/main.tf b/modules/network/main.tf index 755135d..6087c04 100644 --- a/modules/network/main.tf +++ b/modules/network/main.tf @@ -53,13 +53,13 @@ resource "aws_vpc" "this" { enable_dns_support = true enable_dns_hostnames = true - tags = merge(var.tags, { Name = var.name_prefix }) + tags = merge(var.tags, { Name = "${var.name_prefix}-vpc-${var.environment}" }) } resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id - tags = merge(var.tags, { Name = var.name_prefix }) + tags = merge(var.tags, { Name = "${var.name_prefix}-igw-${var.environment}" }) } resource "aws_subnet" "public" { @@ -73,7 +73,7 @@ resource "aws_subnet" "public" { # 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}" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-public-subnet-${each.key}-${var.environment}" }) } # One route table for all public subnets - they are identical, and per-subnet @@ -81,7 +81,7 @@ resource "aws_subnet" "public" { resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id - tags = merge(var.tags, { Name = "${var.name_prefix}-public" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-public-rt-${var.environment}" }) } resource "aws_route" "default" { @@ -110,7 +110,7 @@ resource "aws_vpc_endpoint" "s3" { vpc_endpoint_type = "Gateway" route_table_ids = [aws_route_table.public.id] - tags = merge(var.tags, { Name = "${var.name_prefix}-s3" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-s3-endpoint-${var.environment}" }) } # A VPC's default security group allows all traffic between anything that uses @@ -119,5 +119,5 @@ resource "aws_vpc_endpoint" "s3" { resource "aws_default_security_group" "this" { vpc_id = aws_vpc.this.id - tags = merge(var.tags, { Name = "${var.name_prefix}-default-do-not-use" }) + tags = merge(var.tags, { Name = "${var.name_prefix}-default-sg-do-not-use-${var.environment}" }) } diff --git a/modules/network/variables.tf b/modules/network/variables.tf index 2a58921..dece7df 100644 --- a/modules/network/variables.tf +++ b/modules/network/variables.tf @@ -1,5 +1,10 @@ variable "name_prefix" { - description = "Prefix for resource names, e.g. lance-blue-prod." + description = "Prefix for resource names, e.g. lance-blue." + type = string +} + +variable "environment" { + description = "Environment these resources belong to, e.g. prod. Appended to every name." type = string } diff --git a/modules/static-site/main.tf b/modules/static-site/main.tf index 0d00d79..342a58b 100644 --- a/modules/static-site/main.tf +++ b/modules/static-site/main.tf @@ -1,93 +1,199 @@ -# Hosting for the headquarters frontend. +# Hosting for the headquarters frontend: a private S3 bucket behind +# CloudFront. # -# Amplify with **no repository connected**. Amplify's git integration supports -# GitHub, GitLab, Bitbucket and CodeCommit - not tangled.org, which is where -# these repos live. So the feature that would make Amplify obviously worth -# choosing is unavailable, and deploys are manual either way: +# Headquarters builds the site and pushes it to the bucket; this module only +# provides the bucket and the distribution. A deploy is: # -# aws amplify create-deployment --app-id … --branch-name main -# # PUT the built site as a zip to the presigned URL it returns -# aws amplify start-deployment --app-id … --branch-name main --job-id … +# aws s3 sync dist/ s3:/// --delete +# aws cloudfront create-invalidation --distribution-id --paths '/*' # -# What is left is that Amplify is one resource that already does atomic -# deploys, custom domains, TLS and SPA rewrites, where S3 + CloudFront + OAC + -# a cache-invalidation story is five. See -# docs/decisions.md#amplify-for-the-static-site-deployed-manually. +# See docs/decisions.md#s3--cloudfront-for-the-static-site. terraform { required_version = ">= 1.11" required_providers { aws = { - source = "hashicorp/aws" - version = "~> 6.0" + source = "hashicorp/aws" + version = "~> 6.0" + configuration_aliases = [aws.us_east_1] } } } -resource "aws_amplify_app" "this" { - name = var.name - platform = "WEB" - - # No `repository`, no `oauth_token`, no `access_token`. Nothing to connect to, - # and connecting would mean storing a credential in a repo that stores none. - enable_branch_auto_build = false - enable_branch_auto_deletion = false - enable_auto_branch_creation = false - enable_basic_auth = false - - # Serve index.html for anything that is not a file, so client-side routes - # survive a refresh. The regex is Amplify's own documented form: any path - # whose last segment has no extension in the listed set. - dynamic "custom_rule" { - for_each = var.single_page_app ? [1] : [] - - content { - source = "" - target = "/index.html" - status = "200" +resource "aws_s3_bucket" "site" { + bucket = var.bucket_name + + tags = merge(var.tags, { Name = var.name }) +} + +# Only CloudFront reads the bucket. Nothing is public. +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 +} + +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" } + bucket_key_enabled = true } +} + +resource "aws_s3_bucket_policy" "site" { + bucket = aws_s3_bucket.site.id + policy = data.aws_iam_policy_document.site.json +} + +data "aws_iam_policy_document" "site" { + statement { + sid = "CloudFrontRead" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.site.arn}/*"] - tags = var.tags + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.site.arn] + } + } } -# One branch, holding the deployed artifact. With no repository connected this -# is a deployment target rather than a git branch - the name is what -# `start-deployment` addresses. -resource "aws_amplify_branch" "main" { - app_id = aws_amplify_app.this.id - branch_name = var.branch_name - stage = "PRODUCTION" +# CloudFront only accepts certificates from us-east-1, so the certificate uses +# the aliased provider. +resource "aws_acm_certificate" "site" { + provider = aws.us_east_1 + + domain_name = var.domain_name + validation_method = "DNS" + + tags = merge(var.tags, { Name = var.name }) - enable_auto_build = false + lifecycle { + create_before_destroy = true + } } -# Amplify writes the DNS records itself when the hosted zone is in the same -# account, and provisions its own certificate. The dns module issues none. -# -# `wait_for_verification = false` because verification cannot complete until the -# domain is delegated to Route 53 at the registrar, which is manual. Waiting -# would make the first apply hang on a step Terraform cannot perform. -resource "aws_amplify_domain_association" "this" { - count = var.domain_name == null ? 0 : 1 - - app_id = aws_amplify_app.this.id - domain_name = var.domain_name - wait_for_verification = false - - # The apex. - sub_domain { - branch_name = aws_amplify_branch.main.branch_name - prefix = "" +# Validation records go into the hosted zone. Validation only completes once +# the domain is delegated to Route 53 at the registrar - see the runbook for +# the first-apply order. +resource "aws_route53_record" "validation" { + for_each = { + for dvo in aws_acm_certificate.site.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + type = dvo.resource_record_type + record = dvo.resource_record_value + } } - dynamic "sub_domain" { - for_each = var.www_subdomain ? [1] : [] + zone_id = var.zone_id + name = each.value.name + type = each.value.type + ttl = 300 + records = [each.value.record] + allow_overwrite = true +} + +resource "aws_acm_certificate_validation" "site" { + provider = aws.us_east_1 - content { - branch_name = aws_amplify_branch.main.branch_name - prefix = "www" + certificate_arn = aws_acm_certificate.site.arn + validation_record_fqdns = [for r in aws_route53_record.validation : r.fqdn] +} + +resource "aws_cloudfront_origin_access_control" "site" { + name = var.name + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +data "aws_cloudfront_cache_policy" "caching_optimized" { + name = "Managed-CachingOptimized" +} + +resource "aws_cloudfront_distribution" "site" { + enabled = true + is_ipv6_enabled = true + comment = var.name + default_root_object = "index.html" + aliases = [var.domain_name] + + # US and Europe edges only. The cheaper class; matches the single-region + # cost posture. + price_class = "PriceClass_100" + + origin { + domain_name = aws_s3_bucket.site.bucket_regional_domain_name + origin_id = "s3" + origin_path = var.origin_path + origin_access_control_id = aws_cloudfront_origin_access_control.site.id + } + + default_cache_behavior { + target_origin_id = "s3" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id + } + + # Serve index.html for missing paths so client-side routes survive a + # refresh. Missing keys come back as 403, not 404, because the bucket only + # grants GetObject; handle both. + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + } + + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + restrictions { + geo_restriction { + restriction_type = "none" } } + + viewer_certificate { + acm_certificate_arn = aws_acm_certificate_validation.site.certificate_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + + tags = merge(var.tags, { Name = var.name }) +} + +# The apex points at the distribution: A for IPv4, AAAA for IPv6. +resource "aws_route53_record" "site" { + for_each = toset(["A", "AAAA"]) + + zone_id = var.zone_id + name = var.domain_name + type = each.value + + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } } diff --git a/modules/static-site/outputs.tf b/modules/static-site/outputs.tf index 0ebd7d3..344b56c 100644 --- a/modules/static-site/outputs.tf +++ b/modules/static-site/outputs.tf @@ -1,19 +1,14 @@ -output "app_id" { - description = "Amplify app id. What the deployment commands address." - value = aws_amplify_app.this.id +output "bucket_name" { + description = "Bucket the built site is pushed to." + value = aws_s3_bucket.site.id } -output "branch_name" { - description = "Deployment target branch." - value = aws_amplify_branch.main.branch_name +output "distribution_id" { + description = "Distribution id, for invalidations." + value = aws_cloudfront_distribution.site.id } -output "default_domain" { - description = "The amplifyapp.com domain, which works before the custom domain is delegated." - value = aws_amplify_app.this.default_domain -} - -output "branch_url" { - description = "URL of the deployed branch on the default domain." - value = "https://${aws_amplify_branch.main.branch_name}.${aws_amplify_app.this.default_domain}" +output "distribution_domain_name" { + description = "The cloudfront.net address. Works before the domain is delegated." + value = aws_cloudfront_distribution.site.domain_name } diff --git a/modules/static-site/variables.tf b/modules/static-site/variables.tf index dd9cc81..52e4882 100644 --- a/modules/static-site/variables.tf +++ b/modules/static-site/variables.tf @@ -1,34 +1,31 @@ variable "name" { - description = "Amplify app name." + description = "Name for the distribution, the origin access control and tags." type = string } -variable "branch_name" { - description = "Branch to deploy to. With no repository connected this is a deployment target, not a git branch - it is the name `start-deployment` addresses." +variable "bucket_name" { + description = "Bucket the built site is pushed to. Bucket names are global; the caller picks something unique." type = string - default = "main" } variable "domain_name" { - description = "Apex domain to associate, or null for none. Amplify writes the Route 53 records and provisions its own certificate when the zone is in the same account." + description = "Domain the site is served at, e.g. lance.blue." type = string - default = null } -variable "www_subdomain" { - description = "Also serve www.." - type = bool - default = true +variable "zone_id" { + description = "Hosted zone for the certificate validation records and the site's alias records." + type = string } -variable "single_page_app" { - description = "Add the rewrite that serves index.html for non-file paths, so client-side routes survive a refresh." - type = bool - default = true +variable "origin_path" { + description = "Optional prefix inside the bucket that CloudFront serves from, e.g. /releases/v12. Empty serves the bucket root." + type = string + default = "" } variable "tags" { - description = "Tags to merge into every resource." + description = "Tags for every resource here." type = map(string) default = {} } diff --git a/prek.toml b/prek.toml index c842d7a..2a060a2 100644 --- a/prek.toml +++ b/prek.toml @@ -7,7 +7,9 @@ hooks = [ { id = "check-merge-conflict" }, { id = "check-added-large-files" }, { id = "mixed-line-ending", args = ["--fix=lf"] }, - { id = "end-of-file-fixer" }, + # tofu writes state without a trailing newline; fixing it would make every + # bootstrap apply dirty the file again. + { id = "end-of-file-fixer", exclude = '^bootstrap/terraform\.tfstate$' }, { id = "trailing-whitespace" }, ] diff --git a/scripts/apply.sh b/scripts/apply.sh index 8be10c1..90e2418 100755 --- a/scripts/apply.sh +++ b/scripts/apply.sh @@ -2,7 +2,7 @@ # Apply the plan that plan.sh saved. Not a fresh plan - the point is that what # was reviewed is what runs. # -# ./scripts/plan.sh prod && ./scripts/apply.sh prod +# ./scripts/plan.sh lance.blue && ./scripts/apply.sh lance.blue # # Terraform refuses a stale plan itself (the state has moved on since), so # there is no need for this script to guess at freshness. diff --git a/scripts/common.sh b/scripts/common.sh index 543e6e6..c10d2a6 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -13,6 +13,11 @@ WORK="$ROOT/.work" TF="${TF:-terraform}" +# Resources are tagged with the commit they were planned from. Empty (outside +# git, or git missing) omits the tag rather than failing. +TF_VAR_git_commit="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" +export TF_VAR_git_commit + die() { echo "ERROR: $*" >&2; exit 1; } need_tf() { diff --git a/scripts/plan.sh b/scripts/plan.sh index 8fa325c..acee304 100755 --- a/scripts/plan.sh +++ b/scripts/plan.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # Plan an environment, saving the plan so apply.sh runs exactly what was read. # -# ./scripts/plan.sh prod -# ./scripts/apply.sh prod -# ./scripts/plan.sh prod -target=module.ecr # iterating on one module +# ./scripts/plan.sh lance.blue +# ./scripts/apply.sh lance.blue +# ./scripts/plan.sh lance.blue -target=module.ecr # iterating on one module # # The saved plan lands in .work/.tfplan, which is gitignored. A plan file # holds resolved variable values, so treat it as sensitive even though nothing -- 2.51.2