diff --git a/.github/workflows/deploy-lean.yml b/.github/workflows/deploy-lean.yml index cb0ca253f..592745eba 100644 --- a/.github/workflows/deploy-lean.yml +++ b/.github/workflows/deploy-lean.yml @@ -2,9 +2,9 @@ name: Deploy lean (AWS ECS Express Mode) # Lean-deployment pipeline (see infrastructure/README.md): # build theme assets -> build image -> push to ECR -> register new -# mmgis-admin + mmgis-publish task-definition revisions -> point the -# ECS Express Mode gateway service's primary container at the new -# image and let the managed rollout run. +# admin + publish task-definition revisions (families from vars) -> +# point the ECS Express Mode gateway service's primary container at +# the new image and let the managed rollout run. # # Per the D1 decision, ECS Express Mode owns the ALB, target groups, # rollout strategy, and scaling — this workflow deliberately defines and @@ -15,7 +15,9 @@ name: Deploy lean (AWS ECS Express Mode) # below still matter (see that step's comment). # # Repository configuration: -# vars: AWS_REGION, ECR_REPOSITORY, ECS_CLUSTER, ECS_SERVICE +# vars: AWS_REGION, ECR_REPOSITORY, ECS_CLUSTER, ECS_SERVICE, +# ADMIN_TASK_FAMILY, PUBLISH_TASK_FAMILY (per-environment +# task-def families; default to mmgis-admin / mmgis-publish) # secrets: AWS_DEPLOY_ROLE_ARN (OIDC-assumable deploy role; no # long-lived keys) # @@ -39,8 +41,13 @@ permissions: contents: read env: - ADMIN_TASK_FAMILY: mmgis-admin - PUBLISH_TASK_FAMILY: mmgis-publish + # Task-definition family names are region-global, so each environment uses + # its own (mmgis--admin / -publish) to keep a production deploy from + # registering a revision development's publish-by-family flow would pick up. + # These come from Actions variables; the legacy hard-coded names remain as + # documented fallbacks so an unconfigured repo still behaves as before. + ADMIN_TASK_FAMILY: ${{ vars.ADMIN_TASK_FAMILY || 'mmgis-admin' }} + PUBLISH_TASK_FAMILY: ${{ vars.PUBLISH_TASK_FAMILY || 'mmgis-publish' }} jobs: deploy: diff --git a/.gitignore b/.gitignore index a66eec216..62f9eeb45 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ nul sessions .terraform/ -.terraform.lock.hcl .mcp.json diff --git a/infrastructure/README.md b/infrastructure/README.md index 22e42b314..531e1a9a0 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -1,136 +1,289 @@ # MMGIS Lean AWS Infrastructure -Recipes for running MMGIS in the **lean** deployment shape (`MMGIS_DEPLOYMENT_MODE=lean`) on AWS: the admin app as a long-running ECS service, a short-lived ECS task that publishes a mission as a standalone static dashboard, the least-privilege IAM for both, the CloudFront distribution in front of the admin, the password-gate CloudFront Function reference, and the shared S3 asset bucket. - -**Dual-deployment posture:** the **full** deployment is the upstream MMGIS default (docker-compose, bundled sidecar services) and uses **none** of this directory. The **lean** deployment is this directory plus `.github/workflows/deploy-lean.yml`. The same image serves both; `MMGIS_DEPLOYMENT_MODE` is a runtime ECS environment variable, never a Docker build-arg (the `Dockerfile` is shared and unmodified). - -## Contents - -| File | What it is | -|---|---| -| `ecs/admin-task.json` | Task definition for the admin app (long-running service, ECS Express Mode) | -| `ecs/publish-task.json` | Task definition for the publish job (`node scripts/publish-static.js`, started per publish via `RunTask`) | -| `iam/admin-task-execution-role.json` | ECS-side role for the admin task (image pull, logs, secret injection) | -| `iam/admin-task-role.json` | Runtime role for the admin container code (RunTask + PassRole, dashboard stack read/delete, asset upload). Also carries the `mmgis-dashboard-*` S3/CloudFront **teardown** grants: the DELETE handler runs `DeleteStack` inline with no CloudFormation service role, so CloudFormation deletes the stack's resources with this role's credentials. (Attaching a dedicated CFN service role to the stacks would be the stricter future alternative.) | -| `iam/publish-task-execution-role.json` | ECS-side role for the publish task | -| `iam/publish-task-role.json` | Runtime role for the publish container code (dashboard stack create + the resources CloudFormation manages for it) | -| `iam/express-infrastructure-role.json` | Infrastructure role required by `create-express-gateway-service`: trusts `ecs.amazonaws.com`, carries **no inline policy** — attach the AWS managed policy `arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices` (note the lowercase "for"; the camel-cased name does not exist). It provisions the service's ALB/SGs/certs and **cannot be modified after service creation** | -| `cloudfront-admin.json` | `DistributionConfig` for the admin CloudFront distribution (`aws cloudfront create-distribution --distribution-config file://cloudfront-admin.json`) — bare-CloudFront posture: default viewer cert, no Aliases, VPC origin to the Express service's internal ALB. A custom-domain variant (Aliases + ACM cert + public-ALB custom origin) exists in git history on this file if DNS is ever adopted | -| `cloudfront-function.js` | Canonical reference for the per-dashboard password-gate Function (the deployed copy is generated by `scripts/lib/cfn-template.js`) | -| `s3-asset-bucket.json` | CloudFormation template for the shared admin asset bucket | - -The IAM files are `AWS::IAM::Role` resource snippets — drop them into a CloudFormation template's `Resources` block, or translate the `Policies[].PolicyDocument` blocks into `aws iam create-role` / `put-role-policy` calls. - -## Placeholders - -Replace these consistently across every file before applying: - -| Placeholder | Meaning | -|---|---| -| `` | AWS account id | -| `` | AWS region (same value as the `AWS_REGION` env var) | -| `` | ECR repository name holding the MMGIS image | -| `` | Full image URI incl. tag (the deploy workflow overwrites this per release) | -| `` | ECS cluster both tasks run on (value of `MMGIS_PUBLISH_ECS_CLUSTER`) | -| `` | Comma-separated subnet ids for the publish task (value of `MMGIS_PUBLISH_SUBNETS`) | -| `` | Comma-separated security group ids for the publish task (value of `MMGIS_PUBLISH_SECURITY_GROUPS`) | -| `` | Shared asset bucket name (value of `MMGIS_SHARED_ASSET_BUCKET`; created by `s3-asset-bucket.json`) | -| `` | Secrets Manager ARN of the DB-credentials secret (JSON keys `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`) | -| `` | Secrets Manager ARN of the express-session secret (injected as env `SECRET` — the name `scripts/server.js` reads) | -| `` | Secrets Manager ARN of the superadmin seed username (`SEED_SUPERADMIN_USERNAME`) | -| `` | Secrets Manager ARN of the superadmin seed password (`SEED_SUPERADMIN_PASSWORD`) | -| `` | Secrets Manager ARN of the shared dashboards password (`MMGIS_DASHBOARDS_PASSWORD`) | -| `` | base64 of the **per-region** RDS CA bundle (`https://truststore.pki.rds.amazonaws.com//-bundle.pem`). RDS forces SSL by default; both task defs set `DB_SSL=true` + `DB_SSL_CERT_BASE64`. Use the regional bundle — the global bundle is too big for ECS environment-variable limits | -| `` | The Express service's endpoint name from `ingressPaths[0]` (`mm-.ecs..on.aws`) — the CloudFront origin `DomainName` MUST be this name (it satisfies the ALB cert's SNI and its host-header rule) | -| `` | Id of the CloudFront VPC origin (`aws cloudfront create-vpc-origin`) pointing at the Express service's internal ALB ARN | -| `` | CloudFront Origin Access Control id created for the asset-bucket origin | -| `` | The admin CloudFront distribution id (for the asset bucket policy's `AWS:SourceArn` condition) | - -## Prerequisites (operator-provided, not created here) - -- **VPC + subnets.** An existing VPC; subnets for the admin service and for the publish task (`MMGIS_PUBLISH_SUBNETS`). The subnets you hand the Express service decide its endpoint visibility (see [Endpoint and subnet semantics](#endpoint-and-subnet-semantics)) — use **private** subnets so the admin is reachable only through CloudFront. -- **No custom domain required.** The admin runs in the bare-CloudFront posture: viewers use the default `*.cloudfront.net` certificate and the distribution carries no Aliases. No DNS record, no ACM cert. (The custom-domain variant of `cloudfront-admin.json` lives in git history if DNS is ever adopted.) -- **PostgreSQL on RDS.** Two hard-won requirements: the **master username must be `postgres`** — `scripts/init-db.js`'s bootstrap connection defaults the maintenance database name to the username, so a non-`postgres` master user fails the very first connection; and **RDS forces SSL by default**, so both task defs set `DB_SSL=true` and `DB_SSL_CERT_BASE64` (base64 of the small **per-region** CA bundle from `truststore.pki.rds.amazonaws.com//-bundle.pem` — the global bundle exceeds env-var size limits). -- **Secrets Manager entries:** the DB-credentials secret (JSON with `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASS` keys), the session secret, the dashboards shared password, and the superadmin seed credentials `SEED_SUPERADMIN_USERNAME`/`SEED_SUPERADMIN_PASSWORD`. The admin task def injects the seed credentials and sets `DISABLE_FIRST_SIGNUP=true` so that the superadmin is seeded automatically and the open first-signup route stays closed on a fresh lean deploy. -- **Outbound HTTPS egress.** The admin fires `triggerWebhooks(...)` to external URLs on Config saves and on Dashboards Publish/Update/Delete; a task in a private subnet needs a NAT gateway (or VPC endpoints for the AWS APIs plus egress for webhook targets) or webhooks hang and time out silently. The publish task also needs egress to reach CloudFormation/S3/CloudFront. -- **CloudWatch log groups** `/ecs/mmgis-admin` and `/ecs/mmgis-publish` pre-created (the execution roles deliberately omit `logs:CreateLogGroup`). -- **ECS cluster + Express Mode service.** Create the cluster and an Express Mode gateway service for the admin (see [Creating the Express Mode service](#creating-the-express-mode-service--cloudfront-front-door) for the exact CLI flow). Per the D1 decision, Express Mode owns the ALB, target groups, rollout strategy, and scaling — **no** ALB/target-group/scaling-policy definitions live in this directory. Note that Express Mode services are **not task-definition driven**: the service is created/updated via the `*-express-gateway-service` API with an inline `--primary-container`, and the deploy workflow rolls it by updating that primary container's image. `ecs/admin-task.json` stays registered as the source-of-truth the primary container is derived from; `ecs/publish-task.json` is genuinely load-bearing (RunTask resolves the family). -- **ECR repository** for the image, and (for `deploy-lean.yml`) an OIDC deploy role with permission to push to it and roll the service. -- **linux/amd64 images only.** The task defs pin `cpuArchitecture: X86_64`; an image built on an ARM machine (Apple Silicon) must use `docker buildx build --platform linux/amd64`. The GitHub-hosted CI runners are amd64, so `deploy-lean.yml`'s plain `docker build` is fine. - -## Creating the Express Mode service + CloudFront front door - -The order matters (each step consumes the previous step's output). All of this is one-time setup; afterwards `deploy-lean.yml` only updates the primary container's image. - -1. **Create the infrastructure role** from `iam/express-infrastructure-role.json`: trust `ecs.amazonaws.com`, attach the AWS managed policy `arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices` (lowercase "for"), **no inline policy**. ECS uses it to provision the service's ALB, security groups, and certificates. It cannot be changed after the service is created, so get it right first. - -2. **Create the gateway service.** The `--primary-container` JSON is derived from `ecs/admin-task.json`'s container definition (same `environment[]`/`secrets[]` name/valueFrom shape as task defs): - - ```sh - aws ecs create-express-gateway-service \ - --service-name mmgis-admin \ - --cluster \ - --execution-role-arn arn:aws:iam:::role/mmgis-admin-task-execution-role \ - --task-role-arn arn:aws:iam:::role/mmgis-admin-task-role \ - --infrastructure-role-arn arn:aws:iam:::role/mmgis-express-infrastructure-role \ - --cpu 1024 --memory 2048 \ - --health-check-path /api/utils/healthcheck \ - --network-configuration "securityGroups=,subnets=" \ - --primary-container '{ - "image": "", - "containerPort": 8888, - "awsLogsConfiguration": { "logGroup": "/ecs/mmgis-admin", "logStreamPrefix": "mmgis-admin" }, - "environment": [ ...admin-task.json environment[]... ], - "secrets": [ ...admin-task.json secrets[]... ] - }' - ``` - - Pass **private** subnets so the endpoint comes out PRIVATE with an internal ALB (next section). `monitor-express-gateway-service` / `describe-express-gateway-service` show progress. - -3. **Read the endpoint** from `describe-express-gateway-service`: `ingressPaths[0]` yields `` (`mm-.ecs..on.aws`) and the managed ALB's ARN. - -4. **Open the ALB to CloudFront's VPC-origin ENIs.** Add a `:443` ingress rule **from the VPC CIDR** to the ALB's security group — VPC-origin traffic arrives from ENIs inside the VPC, not from CloudFront's public ranges. - -5. **Create the VPC origin** pointing at the internal ALB: - - ```sh - aws cloudfront create-vpc-origin \ - --vpc-origin-endpoint-config '{ - "Name": "mmgis-admin-vpc-origin", - "Arn": "", - "HTTPPort": 80, - "HTTPSPort": 443, - "OriginProtocolPolicy": "https-only", - "OriginSslProtocols": { "Quantity": 1, "Items": ["TLSv1.2"] } - }' - ``` - - Its id is ``. Note: VPC origins **cannot be updated while status=Deploying**, and deploy cycles run ~6–10 minutes — be patient between changes. - -6. **Create the distribution** from `cloudfront-admin.json` (placeholders filled). Two details are load-bearing: the origin `DomainName` must be `` (NOT the ALB's DNS name — the on.aws name is what satisfies the ALB cert's SNI and its host-header rule), and the default behavior must use the **AllViewerExceptHostHeader** origin-request policy (`b689b0a8-53d0-40ab-baf2-68738e2966ac`) — forwarding the viewer's Host (the `*.cloudfront.net` name) misses the host rule and hits the listener's fixed-response default. - -### Endpoint and subnet semantics - -- The service endpoint's `accessType` (PUBLIC | PRIVATE) is **derived from the subnets you pass** to `create-express-gateway-service`: private subnets → PRIVATE endpoint + an **internal** ALB; public subnets → a public one. There is no separate flag. -- The managed ALB carries an **HTTPS:443 listener only**, with an AWS-managed ACM certificate for the on.aws name and a **host-header rule matching only that name**; the listener's default action is a fixed response. This is why CloudFront must send the on.aws name as Host (AllViewerExceptHostHeader rewrites Host to the origin's DomainName) and why the origin DomainName must be the on.aws endpoint, never the raw ALB DNS name. -- In the lean posture the admin uses private subnets, so the only path in is CloudFront → VPC origin → internal ALB → task. Outbound (webhooks, AWS APIs) still needs the NAT/VPC-endpoint egress noted above. - -## How the pieces fit - -- **Admin task** (`ecs/admin-task.json`): runs the stock image (`_docker-entrypoint.sh`). `environment[]` carries `MMGIS_DEPLOYMENT_MODE=lean`, `DISABLE_FIRST_SIGNUP=true`, and the publish-flow variables the Deployments backend reads (`AWS_REGION`, `MMGIS_PUBLISH_ECS_CLUSTER`, `MMGIS_PUBLISH_TASK_DEFINITION`, `MMGIS_PUBLISH_SUBNETS`, `MMGIS_PUBLISH_SECURITY_GROUPS`, `MMGIS_PUBLISH_CONTAINER_NAME`, `MMGIS_SHARED_ASSET_BUCKET`). Sensitive values come through `secrets[]`. Note the env name `SECRET` (what `scripts/server.js` reads), not `SESSION_SECRET`. -- **Publish task** (`ecs/publish-task.json`): same image, `command` overridden to `node scripts/publish-static.js`. `MMGIS_DEPLOYMENT_ID` and `MMGIS_DEPLOYMENT_ACTION` (`publish` | `update`) are **not** in the task definition — `runPublishTask()` in `scripts/lib/aws-provision.js` passes them per run via `RunTask` container overrides, on the container named `mmgis` (the `MMGIS_PUBLISH_CONTAINER_NAME` default; the names must match). The generous cpu/memory is for the in-task webpack static build. -- **Two roles per task.** The *execution* role is what ECS itself uses (pull image, write logs, inject `secrets[]`); the *task* role is what the container code's AWS SDK calls use. They are intentionally separate and minimal. The classic gotcha: because the admin calls `ecs:RunTask` and hands the publish task its two roles, the **admin task role must hold `iam:PassRole` on both publish role ARNs** — without it, `RunTask` fails with an opaque AccessDenied that never mentions PassRole. -- **Everything is pinned — with one deliberate exception.** Dashboard-facing grants are pinned to the `mmgis-dashboard-*` prefix (stack names, bucket names, and CloudFront Function names all carry it — see `STACK_NAME_PREFIX` in `scripts/lib/cfn-template.js`); asset grants are pinned to ``. CloudFront distribution and origin-access-control ARNs cannot be name-pinned (their ids are random), so those statements are scoped to the resource type within the account. The one `Resource: "*"` in this directory is the `EcrAuthTokenNoResourceScoping` statement in both execution roles: `ecr:GetAuthorizationToken` supports **no** resource-level permissions, so it authorizes against `*` by design (any narrower Resource is an implicit deny that fails every Fargate image pull). This matches AWS's own `AmazonECSTaskExecutionRolePolicy`, and the action only returns a registry auth token — it grants no access to repository data (the pull actions stay pinned to ``). -- **Admin-side teardown.** The Deployments DELETE handler empties the dashboard bucket and calls `DeleteStack` inline under the **admin task role**, with no CloudFormation service role attached to the stack — so CloudFormation deletes the stack's S3 bucket, CloudFront distribution, password-gate Function, and origin access control with the caller's (the admin role's) credentials. That is why `iam/admin-task-role.json` carries `s3:DeleteBucket`/`s3:DeleteBucketPolicy` and the CloudFront disable/delete set, still pinned to the `mmgis-dashboard-*` prefix (Function names) or the account's distribution/OAC ARN space. Giving the stacks a dedicated CloudFormation service role would let the admin role shed these grants and is the stricter future alternative. -- **Publish task role vs. `cfn-template.js`.** Beyond the core create set, the role carries the actions the rendered template actually requires: origin-access-control lifecycle (the template creates an `AWS::CloudFront::OriginAccessControl`), `s3:PutBucketPublicAccessBlock`/`s3:PutEncryptionConfiguration`/`s3:DeleteBucketPolicy` (the bucket ships with public-access block + encryption + a bucket policy), and `cloudfront:TagResource`/`s3:PutBucketTagging` (`createStack()` tags the stack and CloudFormation propagates tags to resources). **No `rds-db:connect`** — the app uses password auth. **No `secretsmanager:GetSecretValue` either**: the dashboards password reaches `publish-static.js` as the `MMGIS_DASHBOARDS_PASSWORD` env var through the publish *execution* role's `secrets[]` injection — the container code never reads Secrets Manager at runtime (the spec's "read at runtime" wording is outdated). For the same reason the password rides only the publish task definition; the admin task neither injects it nor may read it. -- **Admin WebSockets are enabled by the task definition.** `admin-task.json` sets `ENABLE_MMGIS_WEBSOCKETS=true` and `ENABLE_CONFIG_WEBSOCKETS=true` — both default to off in the app — because the lean admin relies on the two ADR-committed WebSocket flows (Configure lock warnings when one admin saves over another's edit, and layer-update push so open map clients refresh on config changes). Published dashboards never connect regardless (their static builds skip the WebSocket entirely). -- **Admin CloudFront** (`cloudfront-admin.json`): the default behavior reaches the Express service's internal ALB through a **VPC origin** (`https-only`, port 443) whose `DomainName` is the on.aws endpoint, with the AWS managed policies **AllViewerExceptHostHeader** origin-request policy (`b689b0a8-53d0-40ab-baf2-68738e2966ac` — forwards all cookies, headers, and query strings *except* Host, which CloudFront rewrites to the origin's on.aws name so the ALB's host rule matches; forwarding the viewer Host breaks it) and **CachingDisabled** cache policy (`4135ea2d-6df8-44a3-9df3-4b5a84be39ad`). The full forwarding is required for login, Postgres-backed sessions, and WebSocket upgrade headers — CloudFront's defaults forward nothing and would silently break auth. Viewer side: default CloudFront certificate, `redirect-to-https`, no Aliases. The `/assets/*` behavior targets the shared asset bucket with **CachingOptimized** (`658327ea-f89d-4fab-a63d-7e88639e58f6`), so admin-uploaded images resolve same-origin at the root-relative `/assets/...` paths the app stores. -- **Asset bucket** (`s3-asset-bucket.json`): one private shared bucket for admin uploads. The admin task role may `PutObject` to it; the publish task role reads it (`GetObject`/`ListBucket`) to same-key copy a mission's `assets//...` prefix (and `Missions//Data/mosaic_parameters.csv` when present) into that dashboard's own `mmgis-dashboard-*` bucket at publish. Assets carry no special auth of their own — they inherit whichever distribution serves them (the admin distribution here; a dashboard's password gate once copied). -- **Password gate** (`cloudfront-function.js`): reference source only. At publish, `renderAuthFunctionCode()` in `scripts/lib/cfn-template.js` bakes `base64("mmgis:" + MMGIS_DASHBOARDS_PASSWORD)` into the Function body inside each dashboard stack. A unit test (`tests/unit/infrastructure.spec.js`) keeps this reference and the generator in sync. -- **`trust proxy`**: `scripts/server.js` sets `app.set("trust proxy", 2)` to match the lean topology's two proxy hops (CloudFront → ALB → ECS) so `Secure` cookies, rate limiting, and `X-Forwarded-For` resolve the real client. +Terraform for running MMGIS in the **lean** deployment shape +(`MMGIS_DEPLOYMENT_MODE=lean`) on AWS: the admin app as a long-running ECS +Express Mode service, a short-lived ECS task that publishes a mission as a +standalone static dashboard, the least-privilege IAM for both, the CloudFront +distribution in front of the admin, the password-gate CloudFront Function +reference, and the shared S3 asset bucket. -## Deploy pipeline +Standing up or changing an environment is `terraform plan` → review → `apply` — +never a runbook of CLI commands. One reusable module describes a complete +environment; thin per-environment roots instantiate it. The only things outside +Terraform are the one-time state-bucket bootstrap, the secret **values**, and +the runtime-created per-dashboard CloudFormation stacks (application behavior, +explicitly staying CloudFormation). + +**Dual-deployment posture:** the **full** deployment is the upstream MMGIS +default (docker-compose, bundled sidecar services) and uses **none** of this +directory. The **lean** deployment is this directory plus +`.github/workflows/deploy-lean.yml`. The same image serves both; +`MMGIS_DEPLOYMENT_MODE` is a runtime ECS environment variable, never a Docker +build-arg (the `Dockerfile` is shared and unmodified). + +## Layout + +``` +infrastructure/ +├── terraform/ +│ ├── modules/mmgis-environment/ # one complete environment, parameterized +│ └── environments/ +│ ├── development/ # thin root: module call + backend + tfvars +│ └── production/ # thin root (instantiated by #195) +├── ecs/*.json # recipe source (provenance; see below) +├── iam/*.json # recipe source (provenance) +├── cloudfront-admin.json # recipe source (provenance) +├── cloudfront-function.js # canonical password-gate Function reference +└── s3-asset-bucket.json # recipe source (provenance) +``` + +The module builds: ECS cluster; the admin Express Mode gateway service; +`mmgis--admin` / `mmgis--publish` task definitions; RDS PostgreSQL + +subnet group; two task security groups; two log groups; five Secrets Manager +secret shells; a per-environment ECR repository; the shared asset bucket + OAC +(+ policy); the CloudFront distribution + VPC origin; the task/exec/infra IAM +roles; and the per-environment GitHub OIDC deploy role. + +### The recipe JSON files are provenance, not applied + +`ecs/*.json`, `iam/*.json`, `cloudfront-admin.json`, and `s3-asset-bucket.json` +are the June recipes the Terraform module was translated from — every attribute +value in them is production-tested. They are **kept in place**: they document +where each Terraform value came from and are referenced by #195 discussions. +`cloudfront-function.js` is still load-bearing as the canonical reference the +publish generator (`scripts/lib/cfn-template.js`) is kept in sync with (see +`tests/unit/infrastructure.spec.js`). Nothing here is applied directly anymore. + +## Prerequisites (operator-provided, not created by Terraform) + +- **VPC + private subnets.** The account is limited to existing shared VPCs and + **cannot create any**, so `vpc_id` and `private_subnet_ids` are required + inputs (uncommitted, via tfvars). Use **at least two** private subnets in + different AZs (ECS Express Mode requires two) with **NAT egress** — a private + task needs it or webhooks and AWS-API calls hang silently. Private subnets + make the admin reachable only through CloudFront. +- **A per-environment Terraform state bucket**, bootstrapped once (below). + Development's is created here; production's is created by #195. +- **The account's GitHub OIDC identity provider** + (`token.actions.githubusercontent.com`) must already exist — the module + references it by data source and never creates it. The very first `plan` + fails without it. +- **Secret values**, set out-of-band (below). Terraform defines the secrets' + existence and names only. +- **The RDS regional CA bundle**, supplied as `rds_ca_bundle_base64` (below). +- **No custom domain.** Bare-CloudFront posture: viewers use the default + `*.cloudfront.net` certificate; the distribution carries no aliases. +- **linux/amd64 images only.** The task defs pin `X86_64`; a local build on + Apple Silicon must use `docker buildx build --platform linux/amd64`. The + GitHub-hosted CI runners are amd64, so `deploy-lean.yml`'s plain build is fine. + +## One-time state-bucket bootstrap (per environment) + +Create the bucket that holds this environment's state (S3 native locking, so no +DynamoDB table). Do this once per environment, before the first `init`: + +```sh +aws s3api create-bucket \ + --bucket mmgis-development-tfstate- \ + --region \ + --create-bucket-configuration LocationConstraint= +aws s3api put-bucket-versioning \ + --bucket mmgis-development-tfstate- \ + --versioning-configuration Status=Enabled +aws s3api put-public-access-block \ + --bucket mmgis-development-tfstate- \ + --public-access-block-configuration \ + BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true +``` + +Then record the bucket in `backend.hcl` (copy `backend.hcl.example`). Nothing +is shared between environments — applying one can never touch another's state. + +## Apply flow + +The Express Mode service does not expose its internal ALB ARN or on.aws +endpoint as Terraform attributes (only `service_arn` and `ingress_paths` are +exported), and the CloudFront VPC origin needs the ALB ARN. So the front door +is built in a **second apply**. + +From `terraform/environments/development/` (production is analogous, per #195): + +```sh +cp terraform.tfvars.example terraform.tfvars # fill in vpc_id, subnets, CA bundle +cp backend.hcl.example backend.hcl # fill in the state bucket + +terraform init -backend-config=backend.hcl +``` + +### Phase 1 — everything except CloudFront + +With `express_internal_alb_arn` / `express_onaws_endpoint` left empty: + +```sh +terraform apply +``` + +This creates the cluster, Express service (which provisions its own internal +ALB), task defs, RDS, secrets shells, ECR, asset bucket + OAC, IAM roles, and +the deploy role. + +### Set the secret values out-of-band + +Terraform created empty secret shells; give them values (nothing here touches +Terraform state). RDS generated the master password in its **own** managed +secret — copy it (and the endpoint) into the app-shaped DB secret: -`.github/workflows/deploy-lean.yml` runs on push to the `development` branch (and manually via `workflow_dispatch`): it builds the theme assets (`npm run build:themes` — the `Dockerfile` does not run it, and the generated `public/` CSS/fonts must be in the build context so themed missions and dashboards don't render unstyled), builds and pushes the image to ECR, registers new `mmgis-admin` **and** `mmgis-publish` task-definition revisions pointing at the new image (the two families share it), and rolls the Express Mode gateway service by updating its primary container with `aws ecs update-express-gateway-service`. It defines no ALB/target-group/scaling resources (D1). +```sh +# Where RDS put the generated master password, and the DB endpoint: +terraform output rds_managed_master_secret_arn +terraform output rds_endpoint + +aws secretsmanager put-secret-value --secret-id mmgis/development/db \ + --secret-string '{"DB_HOST":"","DB_PORT":"5432","DB_NAME":"","DB_USER":"postgres","DB_PASS":""}' + +aws secretsmanager put-secret-value --secret-id mmgis/development/session-secret --secret-string '' +aws secretsmanager put-secret-value --secret-id mmgis/development/superadmin-username --secret-string '' +aws secretsmanager put-secret-value --secret-id mmgis/development/superadmin-password --secret-string '' +aws secretsmanager put-secret-value --secret-id mmgis/development/dashboards-password --secret-string '' +``` + +The DB master **username must be `postgres`** (`scripts/init-db.js`'s bootstrap +connection defaults the maintenance DB name to the username, and a fresh RDS +instance only has the `postgres` database). That is enforced in the module. + +Secrets Manager keeps **deleted secret names for 30 days** by default, so a +destroy/re-apply cycle collides on all five names. The development root sets +`secret_recovery_window_days = 0` (immediate deletion) for exactly this +reason; production keeps the 30-day window on purpose. + +### First image deploy — before CloudFront + +A from-scratch phase 1 leaves the service pointing at a **placeholder image +that does not exist in ECR** (CI pushes commit-SHA tags only), so the tasks +crash-loop until a real image arrives. Push one through the pipeline now: + +1. Set the repository's GitHub Actions variables from + `terraform output workflow_variables` (region, ECR repo, cluster, service, + both task families) and the `AWS_DEPLOY_ROLE_ARN` secret from + `terraform output deploy_role_arn`. +2. Run `deploy-lean.yml` (manual dispatch) and wait for the rollout to + converge — the run summary shows the deployed image. + +Only then move on: phase 2's inputs are read from the now-healthy service, and +Publish depends on the task families pointing at a real image. + +### Phase 2 — CloudFront front door + +Read **three values** from the running service with one call — +`aws ecs describe-express-gateway-service --service-arn "$(terraform output -raw express_service_arn)"`: +the internal ALB ARN, the on.aws endpoint host (also visible via +`terraform output express_ingress_paths`), and the ECS-managed ALB's +**security-group id**. Set them in `terraform.tfvars` and re-apply: + +```hcl +express_internal_alb_arn = "arn:aws:elasticloadbalancing:::loadbalancer/app/ecs-express-gateway-alb-xxxx/xxxx" +express_onaws_endpoint = "mm-xxxx.ecs..on.aws" +express_alb_security_group_id = "sg-xxxxxxxxxxxxxxxxx" +``` + +```sh +terraform apply # VPC origin, distribution, asset-bucket policy, and the + # :443-from-VPC-CIDR ingress rule on the ECS-managed ALB SG +``` + +The ALB security group itself is created and owned by ECS Express Mode — the +module only adds the one ingress rule to it, so no hand-executed mutation +remains anywhere in the flow. + +VPC origins **cannot be updated while status=Deploying** and deploy cycles run +~6–10 minutes — be patient between changes. + +## Workflow variables + +`terraform output workflow_variables` prints the exact values. Set them as the +environment's GitHub Actions variables (and the one secret): + +| Variable | Source output | Notes | +|---|---|---| +| `AWS_REGION` | `aws_region` | | +| `ECR_REPOSITORY` | `ecr_repository_name` | per-environment repo | +| `ECS_CLUSTER` | `ecs_cluster_name` | | +| `ECS_SERVICE` | `ecs_service_name` | | +| `ADMIN_TASK_FAMILY` | `admin_task_family` | `mmgis--admin` — **new**; the workflow now reads this (falls back to `mmgis-admin`) | +| `PUBLISH_TASK_FAMILY` | `publish_task_family` | `mmgis--publish` — **new** (falls back to `mmgis-publish`) | +| `AWS_DEPLOY_ROLE_ARN` (secret) | `deploy_role_arn` | OIDC-assumable; no long-lived keys | + +The two `*_TASK_FAMILY` variables are the one required change to +`deploy-lean.yml`: families are region-global, so per-environment names stop a +production deploy from registering a revision development's publish-by-family +flow would silently pick up. Everything else the workflow needs already came +from Actions variables. The deploy role's trust is **branch-scoped for now** +(`repo:NASA-IMPACT/MMGIS:ref:refs/heads/`); #195 tightens both +environments to GitHub-Environment-scoped trust when it wires `environment:` +into the job. + +## Operational notes (still true, now in the Terraform world) + +- **After ANY Terraform change that touches a task definition, run + `deploy-lean.yml`.** Terraform's task defs point at a placeholder `:latest` + tag that never exists in ECR; a re-registered revision therefore points at a + nonexistent image, and Publish (`RunTask` on the family's latest revision) + silently breaks until the deploy workflow re-registers both families with a + real commit-SHA image. +- **Express Mode is not task-definition driven.** The service runs from its own + inline `primary_container`; the deploy workflow rolls it with + `aws ecs update-express-gateway-service --primary-container`, not + `update-service --task-definition`. Terraform sets the primary container but + ignores drift on its image (the workflow owns the image). The + `mmgis--admin` task def is still registered as the human-auditable + source-of-truth the primary container mirrors; `mmgis--publish` is + genuinely load-bearing (the Deployments backend `RunTask`s it by family name). +- **RDS forces SSL.** Both task defs set `DB_SSL=true` and `DB_SSL_CERT_BASE64` + (base64 of the small **per-region** CA bundle from + `truststore.pki.rds.amazonaws.com//-bundle.pem` — the global + bundle exceeds the ECS env-var size limit). Supply it as `rds_ca_bundle_base64`. +- **Two secrets for the database, on purpose.** `manage_master_user_password` + makes RDS generate and rotate the master password in its own managed secret + (nothing in Terraform state). Its `{username,password}` shape does **not** + match what the app reads, so the separate app-shaped `mmgis//db` secret + (`DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS`) exists too, set out-of-band. +- **Two roles per task.** The *execution* role is what ECS uses (image pull, + logs, `secrets[]` injection); the *task* role is what the container code's SDK + calls use. The admin task role holds `iam:PassRole` on both publish role ARNs + — without it `RunTask` fails with an opaque AccessDenied that never mentions + PassRole. +- **CloudFront origin details are load-bearing.** The admin origin `DomainName` + must be the on.aws endpoint (it satisfies the ALB cert's SNI and its + host-header rule; the raw ALB DNS name would miss it), with the + **AllViewerExceptHostHeader** origin-request policy and **CachingDisabled** + cache policy so login, Postgres-backed sessions, and WebSocket upgrades work. + `/assets/*` serves the shared bucket with **CachingOptimized**. The recipe's + `MinimumProtocolVersion` is intentionally omitted — the provider forbids + setting it alongside the default certificate. +- **CI deploy role, hard-won facts** (encoded in the module): + `ecs:DescribeServices` on the admin service ARN (the workflow resolves + name→ARN); the ExpressGatewayService actions' `Resource` includes **both** the + `express-gateway-service/*` shape **and** the `service//` + ARN (the API authorizes Update/Describe against the service ARN); and the CLI + rejects `--cluster` on `update-express-gateway-service` (ARN-only) — the + workflow already resolves the ARN first. + +## Placeholders in the recipe JSON + +The recipe files still use ``, ``, ``, +``, ``, ``, +``, the `<*_SECRET_ARN>` set, ``, +``, ``, and ``. In +the Terraform module these are resolved from `data.aws_caller_identity`, the +`region` variable, resource attributes, and the two-phase CloudFront inputs — +you do not fill them in by hand anymore. + +## Deploy pipeline -Workflow configuration (GitHub repo settings): variables `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE`; secret `AWS_DEPLOY_ROLE_ARN` (an OIDC-assumable role — the workflow uses GitHub's OIDC provider, no long-lived keys). +`.github/workflows/deploy-lean.yml` runs on push to `development` (and via +`workflow_dispatch`): it builds theme assets (`npm run build:themes`), builds +and pushes the image to ECR, registers new `ADMIN_TASK_FAMILY` **and** +`PUBLISH_TASK_FAMILY` task-def revisions pointing at the new image, and rolls +the Express Mode service by updating its primary container. It defines no +ALB/target-group/scaling resources (Express Mode owns those). See the +[Workflow variables](#workflow-variables) table for its configuration. diff --git a/infrastructure/terraform/.gitignore b/infrastructure/terraform/.gitignore new file mode 100644 index 000000000..77b5fcdfc --- /dev/null +++ b/infrastructure/terraform/.gitignore @@ -0,0 +1,11 @@ +# Local Terraform state, plugins, and crash logs +.terraform/ +*.tfstate +*.tfstate.* +crash.log +crash.*.log + +# Uncommitted, account-specific inputs (only the *.example files are committed) +terraform.tfvars +*.auto.tfvars +backend.hcl diff --git a/infrastructure/terraform/environments/development/.terraform.lock.hcl b/infrastructure/terraform/environments/development/.terraform.lock.hcl new file mode 100644 index 000000000..eda04ec11 --- /dev/null +++ b/infrastructure/terraform/environments/development/.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.55.0" + constraints = ">= 6.22.0" + hashes = [ + "h1:99+MYIg/y3gmsZkhAcffwOpMat+liRJ8b+eyCIax6hk=", + "zh:1161fb2d032ad982587b2662a5229e5d06598c5b7fc5c86b2ad64d49225047cd", + "zh:1f412b09bbece216da0ba08106f3bbb42d8c8971c02d032ab518629915086966", + "zh:2c8b789450bb67181b5f0546714bf6336ba21183c307e001fe848c22dac1f8a6", + "zh:31eec91f896743bab641c06930fe0c277143f17dd25b2510991c08e013c8da67", + "zh:4419d3e906f1ca9c99703b2c4c5082f58aaeb8b8b82e2657a187a6bdf42d8881", + "zh:58e9a7e0581e8cd5f35eb2ce308b2d572073c112facdd0a60aee032146b146b5", + "zh:72fdb02a0cb6351626df460c047d1471f26dad781160cc95abd84f8849daf950", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:aa527913348c33969d80527d424917876657741493f294e160f1191dbc7c1d45", + "zh:b66e5abd756064f55ff06e1ef83eb8d0b7ea6d96625cfcd24408df5472d5f899", + "zh:ba9ab4ba151ac69a854407e27d3a12a2eb260fc4205bbb5c25618e6c8ce69568", + "zh:e1bf63e8a6b9790836f74848b458cdf679a7841e66a25b4b9f6034efc9310b34", + "zh:ec4adcec426f7181faa5de976cedbd35e15d7d1ac2914bbd9cab9f4f8b018b7f", + "zh:f17b485ae74bb4272d2bd680e7d47b0cfd073d192a4f10c8bdfd9d9f25c990a1", + "zh:f6650c2d0d3e614c3ccd623bb027a52bce1f5285c4f1824a26f265eb7529a45a", + "zh:f7383732f8704099db2166a3516e0a803bf46f42c30cd20a0471b55658ae6e51", + ] +} diff --git a/infrastructure/terraform/environments/development/backend.hcl.example b/infrastructure/terraform/environments/development/backend.hcl.example new file mode 100644 index 000000000..289f28efa --- /dev/null +++ b/infrastructure/terraform/environments/development/backend.hcl.example @@ -0,0 +1,5 @@ +# Copy to backend.hcl and fill in, then: terraform init -backend-config=backend.hcl +# The bucket is bootstrapped once, out-of-band (see infrastructure/README.md). +# S3 native locking (use_lockfile, set in backend.tf) needs no DynamoDB table. +bucket = "mmgis-development-tfstate-" +region = "us-west-2" diff --git a/infrastructure/terraform/environments/development/backend.tf b/infrastructure/terraform/environments/development/backend.tf new file mode 100644 index 000000000..c46916a6e --- /dev/null +++ b/infrastructure/terraform/environments/development/backend.tf @@ -0,0 +1,13 @@ +terraform { + # Development keeps its Terraform state in its OWN dedicated bucket; applying + # one environment can never touch another's state. The bucket is bootstrapped + # once, out-of-band (see infrastructure/README.md) — this config never creates + # it. Bucket + region are supplied at init time via -backend-config so no + # account-specific value is committed: + # terraform init -backend-config=backend.hcl + backend "s3" { + key = "mmgis/development/terraform.tfstate" + encrypt = true + use_lockfile = true + } +} diff --git a/infrastructure/terraform/environments/development/main.tf b/infrastructure/terraform/environments/development/main.tf new file mode 100644 index 000000000..59d9bde2b --- /dev/null +++ b/infrastructure/terraform/environments/development/main.tf @@ -0,0 +1,31 @@ +module "mmgis" { + source = "../../modules/mmgis-environment" + + environment = "development" + region = var.region + + # Network (uncommitted). + vpc_id = var.vpc_id + private_subnet_ids = var.private_subnet_ids + + # RDS — development is disposable, so allow a clean teardown. + db_instance_class = "db.t3.micro" + db_allocated_storage = 20 + db_engine_version = "17" + db_multi_az = false + db_skip_final_snapshot = true + rds_ca_bundle_base64 = var.rds_ca_bundle_base64 + + # CI deploy role: development branch, this repo. + github_repo = "NASA-IMPACT/MMGIS" + deploy_role_branch = "development" + + # Development is disposable: no recovery window, so destroy/re-apply + # doesn't collide with secret names held for 30 days. + secret_recovery_window_days = 0 + + # CloudFront two-phase inputs (empty on the first apply). + express_internal_alb_arn = var.express_internal_alb_arn + express_onaws_endpoint = var.express_onaws_endpoint + express_alb_security_group_id = var.express_alb_security_group_id +} diff --git a/infrastructure/terraform/environments/development/outputs.tf b/infrastructure/terraform/environments/development/outputs.tf new file mode 100644 index 000000000..5194934ad --- /dev/null +++ b/infrastructure/terraform/environments/development/outputs.tf @@ -0,0 +1,45 @@ +output "workflow_variables" { + description = "Values to set as the environment's GitHub Actions variables/secrets." + value = { + AWS_REGION = module.mmgis.aws_region + ECR_REPOSITORY = module.mmgis.ecr_repository_name + ECS_CLUSTER = module.mmgis.ecs_cluster_name + ECS_SERVICE = module.mmgis.ecs_service_name + ADMIN_TASK_FAMILY = module.mmgis.admin_task_family + PUBLISH_TASK_FAMILY = module.mmgis.publish_task_family + AWS_DEPLOY_ROLE_ARN = module.mmgis.deploy_role_arn + } +} + +output "express_ingress_paths" { + description = "Read the on.aws endpoint here for the phase-2 CloudFront apply." + value = module.mmgis.express_ingress_paths +} + +output "express_service_arn" { + value = module.mmgis.express_service_arn +} + +output "rds_endpoint" { + value = module.mmgis.rds_endpoint +} + +output "rds_managed_master_secret_arn" { + value = module.mmgis.rds_managed_master_secret_arn +} + +output "app_db_secret_arn" { + value = module.mmgis.app_db_secret_arn +} + +output "asset_bucket_name" { + value = module.mmgis.asset_bucket_name +} + +output "admin_distribution_id" { + value = module.mmgis.admin_distribution_id +} + +output "admin_url" { + value = module.mmgis.admin_url +} diff --git a/infrastructure/terraform/environments/development/providers.tf b/infrastructure/terraform/environments/development/providers.tf new file mode 100644 index 000000000..fb5b92a56 --- /dev/null +++ b/infrastructure/terraform/environments/development/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.region + + default_tags { + tags = { + Project = "MMGIS" + Environment = "development" + ManagedBy = "Terraform" + } + } +} diff --git a/infrastructure/terraform/environments/development/terraform.tfvars.example b/infrastructure/terraform/environments/development/terraform.tfvars.example new file mode 100644 index 000000000..c99287af4 --- /dev/null +++ b/infrastructure/terraform/environments/development/terraform.tfvars.example @@ -0,0 +1,22 @@ +# Copy to terraform.tfvars and fill in. terraform.tfvars is git-ignored — it +# carries account-specific network ids that must never be committed. + +# AWS region (optional; defaults to us-west-2). +# region = "us-west-2" + +# Existing VPC and >= 2 private subnets (different AZs) with NAT egress. +vpc_id = "vpc-xxxxxxxxxxxxxxxxx" +private_subnet_ids = ["subnet-xxxxxxxxxxxxxxxxx", "subnet-yyyyyyyyyyyyyyyyy"] + +# base64 of the region's RDS CA bundle. Generate with: +# curl -s https://truststore.pki.rds.amazonaws.com/us-west-2/us-west-2-bundle.pem | base64 | tr -d '\n' +rds_ca_bundle_base64 = "PASTE_BASE64_HERE" + +# ── Phase 2 only (leave empty/omit for the first apply) ── +# After phase 1, read these from `aws ecs describe-express-gateway-service` +# (or `terraform output express_ingress_paths`) and re-apply to build CloudFront. +# express_internal_alb_arn = "arn:aws:elasticloadbalancing:us-west-2:ACCOUNT:loadbalancer/app/ecs-express-gateway-alb-xxxx/xxxx" +# express_onaws_endpoint = "mm-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.ecs.us-west-2.on.aws" + +# Phase 2 (see README): SG id of the ECS-managed ALB +# express_alb_security_group_id = "sg-xxxxxxxxxxxxxxxxx" diff --git a/infrastructure/terraform/environments/development/variables.tf b/infrastructure/terraform/environments/development/variables.tf new file mode 100644 index 000000000..e88d0b431 --- /dev/null +++ b/infrastructure/terraform/environments/development/variables.tf @@ -0,0 +1,43 @@ +# Only values that must NOT be committed (network ids, the two-phase CloudFront +# inputs, the CA bundle) are variables. Everything safe to commit is set inline +# in the module call (main.tf). Supply these via an uncommitted terraform.tfvars +# (see terraform.tfvars.example). + +variable "region" { + description = "AWS region. Safe to commit as a default; override in tfvars if needed." + type = string + default = "us-west-2" +} + +variable "vpc_id" { + description = "Existing VPC id for the development environment." + type = string +} + +variable "private_subnet_ids" { + description = "Private subnet ids (>= 2, different AZs) for the Express service and publish task." + type = list(string) +} + +variable "rds_ca_bundle_base64" { + description = "base64 of the region's RDS CA bundle (public, region-specific)." + type = string +} + +variable "express_internal_alb_arn" { + description = "Phase 2 only. Internal ALB ARN from `aws ecs describe-express-gateway-service`. Leave empty for the first apply." + type = string + default = "" +} + +variable "express_onaws_endpoint" { + description = "Phase 2 only. The on.aws endpoint host from the Express service. Leave empty for the first apply." + type = string + default = "" +} + +variable "express_alb_security_group_id" { + description = "Phase 2. SG id of the ECS-managed ALB (same describe call as the ALB ARN). Empty in phase 1." + type = string + default = "" +} diff --git a/infrastructure/terraform/environments/development/versions.tf b/infrastructure/terraform/environments/development/versions.tf new file mode 100644 index 000000000..ce5666a9f --- /dev/null +++ b/infrastructure/terraform/environments/development/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.22.0" + } + } +} diff --git a/infrastructure/terraform/environments/production/.terraform.lock.hcl b/infrastructure/terraform/environments/production/.terraform.lock.hcl new file mode 100644 index 000000000..eda04ec11 --- /dev/null +++ b/infrastructure/terraform/environments/production/.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.55.0" + constraints = ">= 6.22.0" + hashes = [ + "h1:99+MYIg/y3gmsZkhAcffwOpMat+liRJ8b+eyCIax6hk=", + "zh:1161fb2d032ad982587b2662a5229e5d06598c5b7fc5c86b2ad64d49225047cd", + "zh:1f412b09bbece216da0ba08106f3bbb42d8c8971c02d032ab518629915086966", + "zh:2c8b789450bb67181b5f0546714bf6336ba21183c307e001fe848c22dac1f8a6", + "zh:31eec91f896743bab641c06930fe0c277143f17dd25b2510991c08e013c8da67", + "zh:4419d3e906f1ca9c99703b2c4c5082f58aaeb8b8b82e2657a187a6bdf42d8881", + "zh:58e9a7e0581e8cd5f35eb2ce308b2d572073c112facdd0a60aee032146b146b5", + "zh:72fdb02a0cb6351626df460c047d1471f26dad781160cc95abd84f8849daf950", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:aa527913348c33969d80527d424917876657741493f294e160f1191dbc7c1d45", + "zh:b66e5abd756064f55ff06e1ef83eb8d0b7ea6d96625cfcd24408df5472d5f899", + "zh:ba9ab4ba151ac69a854407e27d3a12a2eb260fc4205bbb5c25618e6c8ce69568", + "zh:e1bf63e8a6b9790836f74848b458cdf679a7841e66a25b4b9f6034efc9310b34", + "zh:ec4adcec426f7181faa5de976cedbd35e15d7d1ac2914bbd9cab9f4f8b018b7f", + "zh:f17b485ae74bb4272d2bd680e7d47b0cfd073d192a4f10c8bdfd9d9f25c990a1", + "zh:f6650c2d0d3e614c3ccd623bb027a52bce1f5285c4f1824a26f265eb7529a45a", + "zh:f7383732f8704099db2166a3516e0a803bf46f42c30cd20a0471b55658ae6e51", + ] +} diff --git a/infrastructure/terraform/environments/production/backend.hcl.example b/infrastructure/terraform/environments/production/backend.hcl.example new file mode 100644 index 000000000..27a5fd802 --- /dev/null +++ b/infrastructure/terraform/environments/production/backend.hcl.example @@ -0,0 +1,4 @@ +# Copy to backend.hcl and fill in, then: terraform init -backend-config=backend.hcl +# Production's state bucket is bootstrapped in #195. +bucket = "mmgis-production-tfstate-" +region = "us-west-2" diff --git a/infrastructure/terraform/environments/production/backend.tf b/infrastructure/terraform/environments/production/backend.tf new file mode 100644 index 000000000..b6d83d3e3 --- /dev/null +++ b/infrastructure/terraform/environments/production/backend.tf @@ -0,0 +1,12 @@ +terraform { + # Production keeps its Terraform state in its OWN dedicated bucket. Its + # bootstrap belongs to #195 (this issue creates nothing production-flavored in + # the account); the config exists here so #195 can instantiate it. Bucket + + # region are supplied at init time via -backend-config: + # terraform init -backend-config=backend.hcl + backend "s3" { + key = "mmgis/production/terraform.tfstate" + encrypt = true + use_lockfile = true + } +} diff --git a/infrastructure/terraform/environments/production/main.tf b/infrastructure/terraform/environments/production/main.tf new file mode 100644 index 000000000..9b118a009 --- /dev/null +++ b/infrastructure/terraform/environments/production/main.tf @@ -0,0 +1,33 @@ +module "mmgis" { + source = "../../modules/mmgis-environment" + + environment = "production" + region = var.region + + # Network (uncommitted). + vpc_id = var.vpc_id + private_subnet_ids = var.private_subnet_ids + + # RDS — runtime spec is IDENTICAL to development (the admin is internal + # tooling; the shipped product is the published dashboards, which serve + # independently of this stack). The environments differ only in DELETION + # POLICY: production always leaves a final snapshot on destroy and keeps + # the default 30-day secret recovery window, while development skips both + # so destroy/rebuild cycles leave no residue. + db_instance_class = "db.t3.micro" + db_allocated_storage = 20 + db_engine_version = "17" + db_multi_az = false + db_skip_final_snapshot = false + rds_ca_bundle_base64 = var.rds_ca_bundle_base64 + + # CI deploy role. NOTE: branch-scoped for now; #195 tightens both environments + # to GitHub-Environment-scoped trust when it wires `environment:` into the job. + github_repo = "NASA-IMPACT/MMGIS" + deploy_role_branch = "production" + + # CloudFront two-phase inputs (empty on the first apply). + express_internal_alb_arn = var.express_internal_alb_arn + express_onaws_endpoint = var.express_onaws_endpoint + express_alb_security_group_id = var.express_alb_security_group_id +} diff --git a/infrastructure/terraform/environments/production/outputs.tf b/infrastructure/terraform/environments/production/outputs.tf new file mode 100644 index 000000000..5194934ad --- /dev/null +++ b/infrastructure/terraform/environments/production/outputs.tf @@ -0,0 +1,45 @@ +output "workflow_variables" { + description = "Values to set as the environment's GitHub Actions variables/secrets." + value = { + AWS_REGION = module.mmgis.aws_region + ECR_REPOSITORY = module.mmgis.ecr_repository_name + ECS_CLUSTER = module.mmgis.ecs_cluster_name + ECS_SERVICE = module.mmgis.ecs_service_name + ADMIN_TASK_FAMILY = module.mmgis.admin_task_family + PUBLISH_TASK_FAMILY = module.mmgis.publish_task_family + AWS_DEPLOY_ROLE_ARN = module.mmgis.deploy_role_arn + } +} + +output "express_ingress_paths" { + description = "Read the on.aws endpoint here for the phase-2 CloudFront apply." + value = module.mmgis.express_ingress_paths +} + +output "express_service_arn" { + value = module.mmgis.express_service_arn +} + +output "rds_endpoint" { + value = module.mmgis.rds_endpoint +} + +output "rds_managed_master_secret_arn" { + value = module.mmgis.rds_managed_master_secret_arn +} + +output "app_db_secret_arn" { + value = module.mmgis.app_db_secret_arn +} + +output "asset_bucket_name" { + value = module.mmgis.asset_bucket_name +} + +output "admin_distribution_id" { + value = module.mmgis.admin_distribution_id +} + +output "admin_url" { + value = module.mmgis.admin_url +} diff --git a/infrastructure/terraform/environments/production/providers.tf b/infrastructure/terraform/environments/production/providers.tf new file mode 100644 index 000000000..6043e3e45 --- /dev/null +++ b/infrastructure/terraform/environments/production/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.region + + default_tags { + tags = { + Project = "MMGIS" + Environment = "production" + ManagedBy = "Terraform" + } + } +} diff --git a/infrastructure/terraform/environments/production/terraform.tfvars.example b/infrastructure/terraform/environments/production/terraform.tfvars.example new file mode 100644 index 000000000..39d798382 --- /dev/null +++ b/infrastructure/terraform/environments/production/terraform.tfvars.example @@ -0,0 +1,18 @@ +# Copy to terraform.tfvars and fill in. terraform.tfvars is git-ignored. +# Production is instantiated by #195. + +# region = "us-west-2" + +vpc_id = "vpc-xxxxxxxxxxxxxxxxx" +private_subnet_ids = ["subnet-xxxxxxxxxxxxxxxxx", "subnet-yyyyyyyyyyyyyyyyy"] + +# base64 of the region's RDS CA bundle. Generate with: +# curl -s https://truststore.pki.rds.amazonaws.com/us-west-2/us-west-2-bundle.pem | base64 | tr -d '\n' +rds_ca_bundle_base64 = "PASTE_BASE64_HERE" + +# ── Phase 2 only (leave empty/omit for the first apply) ── +# express_internal_alb_arn = "arn:aws:elasticloadbalancing:us-west-2:ACCOUNT:loadbalancer/app/ecs-express-gateway-alb-xxxx/xxxx" +# express_onaws_endpoint = "mm-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.ecs.us-west-2.on.aws" + +# Phase 2 (see README): SG id of the ECS-managed ALB +# express_alb_security_group_id = "sg-xxxxxxxxxxxxxxxxx" diff --git a/infrastructure/terraform/environments/production/variables.tf b/infrastructure/terraform/environments/production/variables.tf new file mode 100644 index 000000000..503354b62 --- /dev/null +++ b/infrastructure/terraform/environments/production/variables.tf @@ -0,0 +1,41 @@ +# Only values that must NOT be committed are variables. See development for the +# same pattern. Production is instantiated by #195. + +variable "region" { + description = "AWS region." + type = string + default = "us-west-2" +} + +variable "vpc_id" { + description = "Existing VPC id for the production environment." + type = string +} + +variable "private_subnet_ids" { + description = "Private subnet ids (>= 2, different AZs)." + type = list(string) +} + +variable "rds_ca_bundle_base64" { + description = "base64 of the region's RDS CA bundle (public, region-specific)." + type = string +} + +variable "express_internal_alb_arn" { + description = "Phase 2 only. Internal ALB ARN from `aws ecs describe-express-gateway-service`." + type = string + default = "" +} + +variable "express_onaws_endpoint" { + description = "Phase 2 only. The on.aws endpoint host from the Express service." + type = string + default = "" +} + +variable "express_alb_security_group_id" { + description = "Phase 2. SG id of the ECS-managed ALB (same describe call as the ALB ARN). Empty in phase 1." + type = string + default = "" +} diff --git a/infrastructure/terraform/environments/production/versions.tf b/infrastructure/terraform/environments/production/versions.tf new file mode 100644 index 000000000..ce5666a9f --- /dev/null +++ b/infrastructure/terraform/environments/production/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.22.0" + } + } +} diff --git a/infrastructure/terraform/modules/mmgis-environment/cloudfront.tf b/infrastructure/terraform/modules/mmgis-environment/cloudfront.tf new file mode 100644 index 000000000..7fe906972 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/cloudfront.tf @@ -0,0 +1,89 @@ +# CloudFront front door — PHASE 2. Created only once express_internal_alb_arn +# and express_onaws_endpoint are supplied (see variables.tf / README). The +# Express service does not expose its internal ALB ARN as a Terraform +# attribute, so the VPC origin cannot be wired directly; the two values are +# read from `aws ecs describe-express-gateway-service` after phase 1. + +resource "aws_cloudfront_vpc_origin" "admin" { + count = local.enable_cloudfront ? 1 : 0 + + vpc_origin_endpoint_config { + name = "${local.name_prefix}-admin-vpc-origin" + arn = var.express_internal_alb_arn + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + + origin_ssl_protocols { + items = ["TLSv1.2"] + quantity = 1 + } + } +} + +resource "aws_cloudfront_distribution" "admin" { + count = local.enable_cloudfront ? 1 : 0 + + enabled = true + http_version = "http2" + comment = "MMGIS ${var.environment} admin distribution (bare-CloudFront posture: default viewer cert, no aliases)." + + # Admin origin: the Express service's internal ALB via a VPC origin. The + # DomainName MUST be the on.aws endpoint (it satisfies the ALB cert's SNI and + # its host-header rule); the raw ALB DNS name would miss the host rule. + origin { + origin_id = "AdminExpressVpcOrigin" + domain_name = var.express_onaws_endpoint + + vpc_origin_config { + vpc_origin_id = aws_cloudfront_vpc_origin.admin[0].id + } + } + + # Asset bucket origin, locked to CloudFront via OAC. + origin { + origin_id = "AssetBucketOrigin" + domain_name = aws_s3_bucket.assets.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.assets.id + } + + # Default behavior: full forwarding (login, Postgres-backed sessions, + # WebSocket upgrade) via AllViewerExceptHostHeader — CloudFront rewrites Host + # to the origin's on.aws name so the ALB host rule matches. CachingDisabled + # keeps auth/sessions correct. + default_cache_behavior { + target_origin_id = "AdminExpressVpcOrigin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.cache_policy_caching_disabled + origin_request_policy_id = local.origin_request_all_except_host_hdr + } + + # /assets/* serves admin-uploaded images same-origin from the shared bucket. + ordered_cache_behavior { + path_pattern = "/assets/*" + target_origin_id = "AssetBucketOrigin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.cache_policy_caching_optimized + } + + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } + + # Bare posture: default *.cloudfront.net certificate, no aliases. Note the + # recipe JSON's MinimumProtocolVersion is intentionally dropped — the + # provider forbids setting it alongside the default certificate (which pins + # TLSv1 on the viewer side). + viewer_certificate { + cloudfront_default_certificate = true + } +} diff --git a/infrastructure/terraform/modules/mmgis-environment/ecr.tf b/infrastructure/terraform/modules/mmgis-environment/ecr.tf new file mode 100644 index 000000000..4bbe9a2fc --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/ecr.tf @@ -0,0 +1,10 @@ +# Per-environment ECR repository. Nothing is shared between environments. +resource "aws_ecr_repository" "this" { + name = local.name_prefix + image_tag_mutability = "MUTABLE" + force_delete = var.ecr_force_delete + + image_scanning_configuration { + scan_on_push = true + } +} diff --git a/infrastructure/terraform/modules/mmgis-environment/ecs.tf b/infrastructure/terraform/modules/mmgis-environment/ecs.tf new file mode 100644 index 000000000..ee0aa34f4 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/ecs.tf @@ -0,0 +1,201 @@ +locals { + # ⚠️ TRAP — this tag never exists in ECR (CI pushes commit-SHA tags only). + # The Express service ignores image drift (lifecycle ignore_changes below), + # but the task DEFINITIONS don't: any Terraform change that touches a task + # def registers a new revision pointing at this nonexistent tag, and Publish + # RunTasks the family's LATEST revision — silently broken until the next CI + # deploy re-registers both families with a real image. After ANY task-def + # change here, run deploy-lean.yml. (See README "Operational notes".) + placeholder_image = "${aws_ecr_repository.this.repository_url}:latest" + db_secret_arn = aws_secretsmanager_secret.db.arn + + # DB credentials are injected per-JSON-key from the app-shaped DB secret. + db_secrets = [ + { name = "DB_HOST", value_from = "${local.db_secret_arn}:DB_HOST::" }, + { name = "DB_PORT", value_from = "${local.db_secret_arn}:DB_PORT::" }, + { name = "DB_NAME", value_from = "${local.db_secret_arn}:DB_NAME::" }, + { name = "DB_USER", value_from = "${local.db_secret_arn}:DB_USER::" }, + { name = "DB_PASS", value_from = "${local.db_secret_arn}:DB_PASS::" }, + ] + + admin_secrets = concat(local.db_secrets, [ + # env name SECRET is what scripts/server.js reads (not SESSION_SECRET). + { name = "SECRET", value_from = aws_secretsmanager_secret.session.arn }, + { name = "SEED_SUPERADMIN_USERNAME", value_from = aws_secretsmanager_secret.seed_username.arn }, + { name = "SEED_SUPERADMIN_PASSWORD", value_from = aws_secretsmanager_secret.seed_password.arn }, + ]) + + publish_secrets = concat(local.db_secrets, [ + { name = "MMGIS_DASHBOARDS_PASSWORD", value_from = aws_secretsmanager_secret.dashboards_password.arn }, + ]) + + admin_environment = [ + { name = "MMGIS_DEPLOYMENT_MODE", value = "lean" }, + { name = "DISABLE_FIRST_SIGNUP", value = "true" }, + { name = "ENABLE_MMGIS_WEBSOCKETS", value = "true" }, + { name = "ENABLE_CONFIG_WEBSOCKETS", value = "true" }, + { name = "NODE_ENV", value = "production" }, + { name = "PORT", value = "8888" }, + { name = "AUTH", value = "local" }, + { name = "DB_SSL", value = "true" }, + { name = "DB_SSL_CERT_BASE64", value = var.rds_ca_bundle_base64 }, + { name = "AWS_REGION", value = local.region }, + { name = "MMGIS_PUBLISH_ECS_CLUSTER", value = local.cluster_name }, + { name = "MMGIS_PUBLISH_TASK_DEFINITION", value = local.publish_family }, + { name = "MMGIS_PUBLISH_SUBNETS", value = join(",", var.private_subnet_ids) }, + { name = "MMGIS_PUBLISH_SECURITY_GROUPS", value = aws_security_group.service.id }, + { name = "MMGIS_PUBLISH_CONTAINER_NAME", value = "mmgis" }, + { name = "MMGIS_SHARED_ASSET_BUCKET", value = local.asset_bucket_name }, + ] + + publish_environment = [ + { name = "MMGIS_DEPLOYMENT_MODE", value = "lean" }, + { name = "NODE_ENV", value = "production" }, + { name = "DB_SSL", value = "true" }, + { name = "DB_SSL_CERT_BASE64", value = var.rds_ca_bundle_base64 }, + { name = "AWS_REGION", value = local.region }, + { name = "MMGIS_SHARED_ASSET_BUCKET", value = local.asset_bucket_name }, + ] +} + +resource "aws_ecs_cluster" "this" { + name = local.cluster_name +} + +# ── Admin task definition ── +# The Express service (below) runs from its own primary_container, NOT from +# this task def. We register it anyway as the human-auditable source-of-truth +# the primary container mirrors, and the deploy workflow registers new +# revisions of it per release. +resource "aws_ecs_task_definition" "admin" { + family = local.admin_family + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = var.admin_cpu + memory = var.admin_memory + execution_role_arn = aws_iam_role.admin_exec.arn + task_role_arn = aws_iam_role.admin_task.arn + + runtime_platform { + cpu_architecture = "X86_64" + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([{ + name = "mmgis" + image = local.placeholder_image + essential = true + portMappings = [{ containerPort = 8888, protocol = "tcp" }] + environment = local.admin_environment + secrets = [for s in local.admin_secrets : { name = s.name, valueFrom = s.value_from }] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = local.admin_log_group + "awslogs-region" = local.region + "awslogs-stream-prefix" = "mmgis-admin" + } + } + }]) + + depends_on = [aws_cloudwatch_log_group.admin] +} + +# ── Publish task definition ── +# Genuinely load-bearing: the Deployments backend starts publish jobs with +# RunTask on the bare family name, which resolves to the latest revision. +resource "aws_ecs_task_definition" "publish" { + family = local.publish_family + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = var.publish_cpu + memory = var.publish_memory + execution_role_arn = aws_iam_role.publish_exec.arn + task_role_arn = aws_iam_role.publish_task.arn + + runtime_platform { + cpu_architecture = "X86_64" + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([{ + name = "mmgis" + image = local.placeholder_image + essential = true + command = ["node", "scripts/publish-static.js"] + environment = local.publish_environment + secrets = [for s in local.publish_secrets : { name = s.name, valueFrom = s.value_from }] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = local.publish_log_group + "awslogs-region" = local.region + "awslogs-stream-prefix" = "mmgis-publish" + } + } + }]) + + depends_on = [aws_cloudwatch_log_group.publish] +} + +# ── Admin Express Mode gateway service ── +# Express Mode owns the ALB, target groups, rollout strategy, and scaling — no +# ALB/target-group/scaling resources are defined here (D1). Passing private +# subnets yields a PRIVATE endpoint + internal ALB, so the only path in is +# CloudFront -> VPC origin -> internal ALB -> task. +resource "aws_ecs_express_gateway_service" "admin" { + service_name = local.service_name + cluster = aws_ecs_cluster.this.name + execution_role_arn = aws_iam_role.admin_exec.arn + task_role_arn = aws_iam_role.admin_task.arn + infrastructure_role_arn = aws_iam_role.express_infra.arn + cpu = var.admin_cpu + memory = var.admin_memory + health_check_path = "/api/utils/healthcheck" + + primary_container { + image = local.placeholder_image + container_port = 8888 + + aws_logs_configuration { + log_group = local.admin_log_group + log_stream_prefix = "mmgis-admin" + } + + dynamic "environment" { + for_each = local.admin_environment + content { + name = environment.value.name + value = environment.value.value + } + } + + dynamic "secret" { + for_each = local.admin_secrets + content { + name = secret.value.name + value_from = secret.value.value_from + } + } + } + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [aws_security_group.service.id] + } + + lifecycle { + # The deploy workflow rolls the service by updating the primary container's + # image out-of-band; do not let Terraform revert it to the placeholder. + ignore_changes = [primary_container[0].image] + } + + # Prevent the delete-time race the provider warns about (service stuck + # DRAINING if its policies are destroyed first). + depends_on = [ + aws_iam_role_policy.admin_exec, + aws_iam_role_policy.admin_task, + aws_iam_role_policy_attachment.express_infra, + aws_cloudwatch_log_group.admin, + ] +} diff --git a/infrastructure/terraform/modules/mmgis-environment/iam.tf b/infrastructure/terraform/modules/mmgis-environment/iam.tf new file mode 100644 index 000000000..ed1786711 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/iam.tf @@ -0,0 +1,331 @@ +# Task, execution, and infrastructure roles. Translated from +# infrastructure/iam/*.json — every statement is production-tested. Names are +# per-environment; cross-references (PassRole targets, RunTask family) resolve +# through Terraform attributes rather than literal ARNs where possible. + +locals { + ecr_repo_arn = aws_ecr_repository.this.arn + + admin_exec_secret_arns = [ + aws_secretsmanager_secret.db.arn, + aws_secretsmanager_secret.session.arn, + aws_secretsmanager_secret.seed_username.arn, + aws_secretsmanager_secret.seed_password.arn, + ] + + publish_exec_secret_arns = [ + aws_secretsmanager_secret.db.arn, + aws_secretsmanager_secret.dashboards_password.arn, + ] + + ecs_tasks_assume_role = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowEcsTasksAssume" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + Condition = { StringEquals = { "aws:SourceAccount" = local.account_id } } + }] + }) +} + +# ── Admin execution role (image pull, logs, secret injection) ── +resource "aws_iam_role" "admin_exec" { + name = "${local.name_prefix}-admin-task-execution" + description = "ECS-side role for the ${local.admin_family} task: pull image, write logs, inject admin secrets[]. Lean deployment only." + assume_role_policy = local.ecs_tasks_assume_role +} + +resource "aws_iam_role_policy" "admin_exec" { + name = "${local.name_prefix}-admin-task-execution" + role = aws_iam_role.admin_exec.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + # ecr:GetAuthorizationToken supports NO resource scoping; it authorizes + # against * by design (mirrors AmazonECSTaskExecutionRolePolicy) and + # returns only a registry auth token, no repository data access. + Sid = "EcrAuthTokenNoResourceScoping" + Effect = "Allow" + Action = ["ecr:GetAuthorizationToken"] + Resource = "*" + }, + { + Sid = "EcrPullImage" + Effect = "Allow" + Action = ["ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"] + Resource = local.ecr_repo_arn + }, + { + Sid = "CloudWatchLogsWrite" + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "${aws_cloudwatch_log_group.admin.arn}:*" + }, + { + Sid = "InjectAdminTaskSecrets" + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = local.admin_exec_secret_arns + }, + ] + }) +} + +# ── Admin task role (runtime container code) ── +resource "aws_iam_role" "admin_task" { + name = "${local.name_prefix}-admin-task" + description = "Runtime role for the ${local.admin_family} container: RunTask + PassRole of the publish roles, dashboard stack read/delete + teardown, admin asset upload. Lean deployment only." + assume_role_policy = local.ecs_tasks_assume_role +} + +resource "aws_iam_role_policy" "admin_task" { + name = "${local.name_prefix}-admin-task" + role = aws_iam_role.admin_task.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "RunPublishTask" + Effect = "Allow" + Action = ["ecs:RunTask"] + Resource = "arn:aws:ecs:${local.region}:${local.account_id}:task-definition/${local.publish_family}:*" + }, + { + # Because the admin calls RunTask and hands the publish task its two + # roles, it must PassRole both — without it RunTask fails with an + # opaque AccessDenied that never mentions PassRole. + Sid = "PassBothPublishRoles" + Effect = "Allow" + Action = ["iam:PassRole"] + Resource = [aws_iam_role.publish_exec.arn, aws_iam_role.publish_task.arn] + Condition = { + StringEquals = { "iam:PassedToService" = "ecs-tasks.amazonaws.com" } + } + }, + { + Sid = "DashboardStackReadDelete" + Effect = "Allow" + Action = ["cloudformation:DescribeStacks", "cloudformation:DeleteStack"] + Resource = "arn:aws:cloudformation:${local.region}:${local.account_id}:stack/mmgis-dashboard-*/*" + }, + { + Sid = "EmptyDashboardBuckets" + Effect = "Allow" + Action = ["s3:DeleteObject"] + Resource = "arn:aws:s3:::mmgis-dashboard-*/*" + }, + { + Sid = "ListDashboardBuckets" + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = "arn:aws:s3:::mmgis-dashboard-*" + }, + { + Sid = "TeardownDashboardBuckets" + Effect = "Allow" + Action = ["s3:DeleteBucket", "s3:DeleteBucketPolicy"] + Resource = "arn:aws:s3:::mmgis-dashboard-*" + }, + { + Sid = "TeardownDashboardDistributions" + Effect = "Allow" + Action = [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:UpdateDistribution", + "cloudfront:DeleteDistribution", + ] + Resource = "arn:aws:cloudfront::${local.account_id}:distribution/*" + }, + { + Sid = "TeardownDashboardAuthFunctions" + Effect = "Allow" + Action = ["cloudfront:DescribeFunction", "cloudfront:GetFunction", "cloudfront:DeleteFunction"] + Resource = "arn:aws:cloudfront::${local.account_id}:function/mmgis-dashboard-*" + }, + { + Sid = "TeardownDashboardOriginAccessControls" + Effect = "Allow" + Action = ["cloudfront:GetOriginAccessControl", "cloudfront:DeleteOriginAccessControl"] + Resource = "arn:aws:cloudfront::${local.account_id}:origin-access-control/*" + }, + { + Sid = "UploadAdminAssets" + Effect = "Allow" + Action = ["s3:PutObject"] + Resource = "${aws_s3_bucket.assets.arn}/*" + }, + ] + }) +} + +# ── Publish execution role ── +resource "aws_iam_role" "publish_exec" { + name = "${local.name_prefix}-publish-task-execution" + description = "ECS-side role for the ${local.publish_family} task: pull image, write logs, inject publish secrets[]. Lean deployment only." + assume_role_policy = local.ecs_tasks_assume_role +} + +resource "aws_iam_role_policy" "publish_exec" { + name = "${local.name_prefix}-publish-task-execution" + role = aws_iam_role.publish_exec.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EcrAuthTokenNoResourceScoping" + Effect = "Allow" + Action = ["ecr:GetAuthorizationToken"] + Resource = "*" + }, + { + Sid = "EcrPullImage" + Effect = "Allow" + Action = ["ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"] + Resource = local.ecr_repo_arn + }, + { + Sid = "CloudWatchLogsWrite" + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "${aws_cloudwatch_log_group.publish.arn}:*" + }, + { + Sid = "InjectPublishTaskSecrets" + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = local.publish_exec_secret_arns + }, + ] + }) +} + +# ── Publish task role (runtime container code) ── +resource "aws_iam_role" "publish_task" { + name = "${local.name_prefix}-publish-task" + description = "Runtime role for the ${local.publish_family} container (scripts/publish-static.js): create/describe/delete the mmgis-dashboard-* stacks and their S3/CloudFront resources, read the shared asset bucket. No secretsmanager (password rides the exec role's secrets[]). No rds-db:connect (password auth). Lean deployment only." + assume_role_policy = local.ecs_tasks_assume_role +} + +resource "aws_iam_role_policy" "publish_task" { + name = "${local.name_prefix}-publish-task" + role = aws_iam_role.publish_task.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DashboardStackLifecycle" + Effect = "Allow" + Action = [ + "cloudformation:CreateStack", + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackEvents", + "cloudformation:DeleteStack", + ] + Resource = "arn:aws:cloudformation:${local.region}:${local.account_id}:stack/mmgis-dashboard-*/*" + }, + { + Sid = "DashboardBucketLifecycle" + Effect = "Allow" + Action = [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:GetBucketLocation", + "s3:PutBucketPolicy", + "s3:DeleteBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutEncryptionConfiguration", + "s3:PutBucketTagging", + ] + Resource = "arn:aws:s3:::mmgis-dashboard-*" + }, + { + Sid = "DashboardBucketWriteObjects" + Effect = "Allow" + Action = ["s3:PutObject"] + Resource = "arn:aws:s3:::mmgis-dashboard-*/*" + }, + { + Sid = "DashboardDistributionLifecycle" + Effect = "Allow" + Action = [ + "cloudfront:CreateDistribution", + "cloudfront:GetDistribution", + "cloudfront:UpdateDistribution", + "cloudfront:DeleteDistribution", + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:ListTagsForResource", + "cloudfront:CreateInvalidation", + ] + Resource = "arn:aws:cloudfront::${local.account_id}:distribution/*" + }, + { + Sid = "DashboardAuthFunctionLifecycle" + Effect = "Allow" + Action = [ + "cloudfront:CreateFunction", + "cloudfront:PublishFunction", + "cloudfront:DescribeFunction", + "cloudfront:DeleteFunction", + "cloudfront:GetFunction", + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:ListTagsForResource", + ] + Resource = "arn:aws:cloudfront::${local.account_id}:function/mmgis-dashboard-*" + }, + { + Sid = "DashboardOriginAccessControlLifecycle" + Effect = "Allow" + Action = [ + "cloudfront:CreateOriginAccessControl", + "cloudfront:GetOriginAccessControl", + "cloudfront:DeleteOriginAccessControl", + ] + Resource = "arn:aws:cloudfront::${local.account_id}:origin-access-control/*" + }, + { + Sid = "ReadSharedAssetObjects" + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = "${aws_s3_bucket.assets.arn}/*" + }, + { + Sid = "ListSharedAssetBucket" + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = aws_s3_bucket.assets.arn + }, + ] + }) +} + +# ── Express infrastructure role ── +# REQUIRED by aws_ecs_express_gateway_service (infrastructure_role_arn). ECS +# assumes it to provision the service's ALB, security groups, and certificates. +# Trust-only plus the AWS managed policy — NO inline policy. Cannot be modified +# after the service is created. +resource "aws_iam_role" "express_infra" { + name = "${local.name_prefix}-express-infrastructure" + description = "Infrastructure role for the ${local.service_name} Express service. Trust-only + AWS managed policy; no inline policy. Immutable after service creation. Lean deployment only." + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowEcsServiceAssume" + Effect = "Allow" + Principal = { Service = "ecs.amazonaws.com" } + Action = "sts:AssumeRole" + Condition = { StringEquals = { "aws:SourceAccount" = local.account_id } } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "express_infra" { + role = aws_iam_role.express_infra.name + # NOTE the lowercase "for" — the camel-cased name does not exist. + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices" +} diff --git a/infrastructure/terraform/modules/mmgis-environment/iam_deploy.tf b/infrastructure/terraform/modules/mmgis-environment/iam_deploy.tf new file mode 100644 index 000000000..aedb5e2b9 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/iam_deploy.tf @@ -0,0 +1,108 @@ +# Per-environment GitHub OIDC deploy role assumed by deploy-lean.yml. Trust is +# BRANCH-scoped for now (repo::ref:refs/heads/): the +# workflow declares no GitHub Environment until #195, so a job without +# `environment:` presents a branch-ref subject. #195 tightens this to +# repo:...:environment: when it wires `environment:` into the job. +resource "aws_iam_role" "deploy" { + name = "${local.name_prefix}-github-deploy" + description = "GitHub OIDC deploy role for the ${var.environment} environment (deploy-lean.yml). Branch-scoped trust." + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "GitHubOidcBranchScoped" + Effect = "Allow" + Principal = { Federated = data.aws_iam_openid_connect_provider.github.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + "token.actions.githubusercontent.com:sub" = "repo:${var.github_repo}:ref:refs/heads/${local.deploy_branch}" + } + } + }] + }) +} + +resource "aws_iam_role_policy" "deploy" { + name = "${local.name_prefix}-github-deploy" + role = aws_iam_role.deploy.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EcrAuthToken" + Effect = "Allow" + Action = ["ecr:GetAuthorizationToken"] + Resource = "*" + }, + { + # ECR push scoped to THIS environment's repository only. + Sid = "EcrPushToEnvRepo" + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + ] + Resource = local.ecr_repo_arn + }, + { + # RegisterTaskDefinition / DescribeTaskDefinition support no + # resource-level scoping, so they authorize against *. + Sid = "RegisterAndDescribeTaskDefinitions" + Effect = "Allow" + Action = ["ecs:RegisterTaskDefinition", "ecs:DescribeTaskDefinition"] + Resource = "*" + }, + { + # The workflow resolves the service name -> ARN before rolling it. + Sid = "DescribeAdminService" + Effect = "Allow" + Action = ["ecs:DescribeServices"] + Resource = local.service_arn + }, + { + # Update + Describe only — CI rolls the EXISTING service; creating a + # service is Terraform's job under operator credentials, and a + # compromised branch token must not be able to stand up new services. + # The API authorizes Update/Describe against the SERVICE ARN (learned + # empirically), NOT the express-gateway-service/* shape, so the + # Resource lists BOTH. + Sid = "ExpressGatewayServiceDeploy" + Effect = "Allow" + Action = [ + "ecs:UpdateExpressGatewayService", + "ecs:DescribeExpressGatewayService", + ] + Resource = [ + "arn:aws:ecs:${local.region}:${local.account_id}:express-gateway-service/*", + local.service_arn, + ] + }, + { + # Pinned PassRole: registering task-def revisions passes the task/exec + # roles; an Express service update passes the infra role too. The + # PassedToService condition mirrors iam.tf's pattern. + Sid = "PassEnvTaskRoles" + Effect = "Allow" + Action = ["iam:PassRole"] + Resource = [ + aws_iam_role.admin_exec.arn, + aws_iam_role.admin_task.arn, + aws_iam_role.publish_exec.arn, + aws_iam_role.publish_task.arn, + aws_iam_role.express_infra.arn, + ] + Condition = { + StringEquals = { + "iam:PassedToService" = ["ecs-tasks.amazonaws.com", "ecs.amazonaws.com"] + } + } + }, + ] + }) +} diff --git a/infrastructure/terraform/modules/mmgis-environment/logs.tf b/infrastructure/terraform/modules/mmgis-environment/logs.tf new file mode 100644 index 000000000..6adc786bf --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/logs.tf @@ -0,0 +1,11 @@ +# Pre-created log groups. The execution roles deliberately omit +# logs:CreateLogGroup, so these must exist before a task runs. +resource "aws_cloudwatch_log_group" "admin" { + name = local.admin_log_group + retention_in_days = var.log_retention_days +} + +resource "aws_cloudwatch_log_group" "publish" { + name = local.publish_log_group + retention_in_days = var.log_retention_days +} diff --git a/infrastructure/terraform/modules/mmgis-environment/main.tf b/infrastructure/terraform/modules/mmgis-environment/main.tf new file mode 100644 index 000000000..d93c9b287 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/main.tf @@ -0,0 +1,51 @@ +data "aws_caller_identity" "current" {} + +data "aws_vpc" "this" { + id = var.vpc_id +} + +# Reference the existing GitHub OIDC provider — never create it here. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +locals { + account_id = data.aws_caller_identity.current.account_id + region = var.region + name_prefix = "mmgis-${var.environment}" + + # Task-definition family names are region-global, so they carry the + # per-environment prefix. This is what stops a production deploy from + # registering a revision that development's publish-by-family flow picks up. + admin_family = "${local.name_prefix}-admin" + publish_family = "${local.name_prefix}-publish" + + cluster_name = local.name_prefix + service_name = "${local.name_prefix}-admin" + + admin_log_group = "/ecs/${local.name_prefix}-admin" + publish_log_group = "/ecs/${local.name_prefix}-publish" + + # S3 bucket names are global; qualify with the account id. + asset_bucket_name = "${local.name_prefix}-assets-${local.account_id}" + + # Secret paths (path-style, per account convention). Shells only — values + # are set out-of-band and never pass through Terraform state. + secret_db_name = "mmgis/${var.environment}/db" + secret_session_name = "mmgis/${var.environment}/session-secret" + secret_seed_user_name = "mmgis/${var.environment}/superadmin-username" + secret_seed_pass_name = "mmgis/${var.environment}/superadmin-password" + secret_dash_pass_name = "mmgis/${var.environment}/dashboards-password" + + service_arn = "arn:aws:ecs:${local.region}:${local.account_id}:service/${local.cluster_name}/${local.service_name}" + + deploy_branch = var.deploy_role_branch != "" ? var.deploy_role_branch : var.environment + + # The CloudFront front door is created only once its two-phase inputs are known. + enable_cloudfront = var.express_internal_alb_arn != "" && var.express_onaws_endpoint != "" + + # AWS managed policy ids (region-independent), pinned by the recipes. + cache_policy_caching_disabled = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" + cache_policy_caching_optimized = "658327ea-f89d-4fab-a63d-7e88639e58f6" + origin_request_all_except_host_hdr = "b689b0a8-53d0-40ab-baf2-68738e2966ac" +} diff --git a/infrastructure/terraform/modules/mmgis-environment/network.tf b/infrastructure/terraform/modules/mmgis-environment/network.tf new file mode 100644 index 000000000..ff4136467 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/network.tf @@ -0,0 +1,67 @@ +# Three security groups govern this environment, but only two are ours to +# create. The third — the admin service's ALB security group — is created and +# owned by ECS Express Mode via the infrastructure role and CANNOT be created +# here. It does need a :443 ingress rule from the VPC CIDR so CloudFront's +# VPC-origin ENIs can reach the ALB. The SG id is only knowable once the +# service is up, so that rule is a PHASE-2 resource (bottom of this file), +# driven by var.express_alb_security_group_id — read from the same +# describe-express-gateway-service call as the ALB ARN. No hand-executed +# mutation remains. + +# Shared task security group — used by BOTH the admin service and the publish +# task (RunTask). The RDS ingress rule references this SG, so sharing it is what +# lets one rule cover both database clients. +resource "aws_security_group" "service" { + name = "${local.name_prefix}-service-sg" + description = "MMGIS ${var.environment} admin + publish tasks: ingress 8888 from the in-VPC ALB, egress all." + vpc_id = var.vpc_id + + ingress { + description = "App port from the ECS-managed ALB (in-VPC)." + from_port = 8888 + to_port = 8888 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.this.cidr_block] + } + + egress { + description = "All outbound (image pull, AWS APIs, webhooks via NAT)." + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.name_prefix}-service-sg" } +} + +# Database security group — 5432 from the shared task SG only. +resource "aws_security_group" "rds" { + name = "${local.name_prefix}-rds-sg" + description = "MMGIS ${var.environment} RDS: ingress 5432 from the task SG only." + vpc_id = var.vpc_id + + ingress { + description = "PostgreSQL from the admin/publish task SG." + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.service.id] + } + + tags = { Name = "${local.name_prefix}-rds-sg" } +} + +# Phase 2: allow CloudFront's VPC-origin ENIs (in-VPC) to reach the +# ECS-managed ALB on :443. The ALB SG is Express-Mode-owned, but adding a rule +# to it is fair game — only creation/deletion of the SG belongs to ECS. +resource "aws_vpc_security_group_ingress_rule" "express_alb_https" { + count = var.express_alb_security_group_id != "" ? 1 : 0 + + security_group_id = var.express_alb_security_group_id + description = "HTTPS from in-VPC CloudFront VPC-origin ENIs (MMGIS ${var.environment})." + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv4 = data.aws_vpc.this.cidr_block +} diff --git a/infrastructure/terraform/modules/mmgis-environment/outputs.tf b/infrastructure/terraform/modules/mmgis-environment/outputs.tf new file mode 100644 index 000000000..a6fb10b23 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/outputs.tf @@ -0,0 +1,92 @@ +# ── Workflow variable values (repo/Environment Actions variables) ── + +output "aws_region" { + description = "vars.AWS_REGION" + value = local.region +} + +output "ecr_repository_name" { + description = "vars.ECR_REPOSITORY" + value = aws_ecr_repository.this.name +} + +output "ecr_repository_url" { + description = "Full ECR repository URL (registry/name)." + value = aws_ecr_repository.this.repository_url +} + +output "ecs_cluster_name" { + description = "vars.ECS_CLUSTER" + value = aws_ecs_cluster.this.name +} + +output "ecs_service_name" { + description = "vars.ECS_SERVICE" + value = aws_ecs_express_gateway_service.admin.service_name +} + +output "admin_task_family" { + description = "vars.ADMIN_TASK_FAMILY" + value = local.admin_family +} + +output "publish_task_family" { + description = "vars.PUBLISH_TASK_FAMILY" + value = local.publish_family +} + +output "deploy_role_arn" { + description = "secrets.AWS_DEPLOY_ROLE_ARN — the GitHub OIDC deploy role for this environment." + value = aws_iam_role.deploy.arn +} + +# ── Phase-1 -> phase-2 handoff (feed these back as tfvars for the CF apply) ── + +output "express_service_arn" { + description = "The Express gateway service ARN." + value = aws_ecs_express_gateway_service.admin.service_arn +} + +output "express_ingress_paths" { + description = "The service's ingress paths. Read the on.aws endpoint here (or via `aws ecs describe-express-gateway-service`) and pass it back as express_onaws_endpoint for the phase-2 CloudFront apply." + value = aws_ecs_express_gateway_service.admin.ingress_paths +} + +# ── Data / operational references ── + +output "rds_endpoint" { + description = "RDS endpoint (host:port). Copy the host into the app DB secret's DB_HOST out-of-band." + value = aws_db_instance.this.address +} + +output "rds_managed_master_secret_arn" { + description = "ARN of the RDS-managed master-user secret ({username,password}). Copy its password into the app DB secret's DB_PASS out-of-band." + value = try(aws_db_instance.this.master_user_secret[0].secret_arn, null) +} + +output "app_db_secret_arn" { + description = "ARN of the app-shaped DB secret shell (set DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS out-of-band)." + value = aws_secretsmanager_secret.db.arn +} + +output "asset_bucket_name" { + description = "Shared admin asset bucket name." + value = aws_s3_bucket.assets.bucket +} + +output "service_security_group_id" { + description = "The shared admin/publish task security group id (value of MMGIS_PUBLISH_SECURITY_GROUPS)." + value = aws_security_group.service.id +} + +# ── Phase-2 CloudFront outputs (null until the CF apply) ── + +output "admin_distribution_id" { + description = "Admin CloudFront distribution id (phase 2)." + value = local.enable_cloudfront ? aws_cloudfront_distribution.admin[0].id : null +} + +output "admin_url" { + description = "The admin CloudFront URL (phase 2) — the authoritative place to read it (deliberately surfaced nowhere in CI)." + value = local.enable_cloudfront ? "https://${aws_cloudfront_distribution.admin[0].domain_name}" : null +} diff --git a/infrastructure/terraform/modules/mmgis-environment/rds.tf b/infrastructure/terraform/modules/mmgis-environment/rds.tf new file mode 100644 index 000000000..27863befa --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/rds.tf @@ -0,0 +1,46 @@ +resource "aws_db_subnet_group" "this" { + name = "${local.name_prefix}-db" + subnet_ids = var.private_subnet_ids +} + +resource "aws_db_instance" "this" { + identifier = "${local.name_prefix}-postgres" + engine = "postgres" + engine_version = var.db_engine_version + instance_class = var.db_instance_class + + allocated_storage = var.db_allocated_storage + storage_encrypted = true + storage_type = "gp3" + + # The master username MUST be `postgres`: scripts/init-db.js's bootstrap + # connection defaults the maintenance database name to the username, and a + # fresh RDS instance only has the `postgres` database. A non-postgres master + # user fails the very first connection. Hard-coded, never a variable. + username = "postgres" + + # RDS generates and rotates the master password in its OWN managed secret + # (master_user_secret) — nothing lands in Terraform state. This satisfies + # aws_db_instance's create-time password requirement without a + # random_password. The app reads DB_PASS from the separate app-shaped secret + # (secrets.tf), whose value is copied from this managed secret out-of-band. + manage_master_user_password = true + + # db_name intentionally unset: the app's init defaults the maintenance DB to + # the username (`postgres`), which already exists on a fresh instance. + + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + port = 5432 + + multi_az = var.db_multi_az + backup_retention_period = var.db_backup_retention_period + + # Guardrails: deletion protection is ON per the environment contract. + deletion_protection = true + skip_final_snapshot = var.db_skip_final_snapshot + final_snapshot_identifier = var.db_skip_final_snapshot ? null : "${local.name_prefix}-postgres-final" + + apply_immediately = false +} diff --git a/infrastructure/terraform/modules/mmgis-environment/s3.tf b/infrastructure/terraform/modules/mmgis-environment/s3.tf new file mode 100644 index 000000000..c2589e811 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/s3.tf @@ -0,0 +1,55 @@ +# Shared admin asset bucket: one private bucket holding admin-uploaded mission +# assets (/assets//...), served same-origin through the admin +# distribution's /assets/* behavior. The publish task same-key copies a +# mission's assets out of here into that dashboard's own bucket at publish. +resource "aws_s3_bucket" "assets" { + bucket = local.asset_bucket_name +} + +resource "aws_s3_bucket_public_access_block" "assets" { + bucket = aws_s3_bucket.assets.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" "assets" { + bucket = aws_s3_bucket.assets.id + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +# Origin Access Control for the asset-bucket origin. Independent of the +# distribution, so created in phase 1 and referenced by the phase-2 distribution. +resource "aws_cloudfront_origin_access_control" "assets" { + name = "${local.name_prefix}-assets-oac" + description = "OAC for the ${var.environment} shared asset bucket origin." + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +# Bucket policy grants ONLY the admin distribution read access (AWS:SourceArn). +# It needs the distribution ARN, so it is created in phase 2 alongside the +# distribution. Ordering: OAC -> distribution -> THIS policy. +resource "aws_s3_bucket_policy" "assets" { + count = local.enable_cloudfront ? 1 : 0 + bucket = aws_s3_bucket.assets.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowAdminCloudFrontReadOnly" + Effect = "Allow" + Principal = { Service = "cloudfront.amazonaws.com" } + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.assets.arn}/*" + Condition = { + StringEquals = { "AWS:SourceArn" = aws_cloudfront_distribution.admin[0].arn } + } + }] + }) +} diff --git a/infrastructure/terraform/modules/mmgis-environment/secrets.tf b/infrastructure/terraform/modules/mmgis-environment/secrets.tf new file mode 100644 index 000000000..29572a013 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/secrets.tf @@ -0,0 +1,43 @@ +# Secret SHELLS only. Terraform defines the secrets' existence and names; the +# VALUES are set out-of-band (see README) and never pass through Terraform +# state. There is deliberately NO aws_secretsmanager_secret_version and NO +# random_password resource anywhere in this module — either would land a +# secret value in state. + +# App-shaped DB credentials the task defs read: JSON keys +# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS. This is SEPARATE from the +# RDS-managed master-user secret (see rds.tf): RDS's managed secret has a +# {username,password} shape that does NOT match what the app reads, so both +# secrets exist on purpose. The value here is populated out-of-band, copying +# DB_PASS from the RDS-managed secret and DB_HOST from the RDS endpoint. +resource "aws_secretsmanager_secret" "db" { + name = local.secret_db_name + description = "MMGIS ${var.environment} app DB credentials (JSON: DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS). Value set out-of-band." + recovery_window_in_days = var.secret_recovery_window_days +} + +# express-session secret; injected as env SECRET (the name scripts/server.js reads). +resource "aws_secretsmanager_secret" "session" { + name = local.secret_session_name + description = "MMGIS ${var.environment} express-session secret (injected as env SECRET). Value set out-of-band." + recovery_window_in_days = var.secret_recovery_window_days +} + +resource "aws_secretsmanager_secret" "seed_username" { + name = local.secret_seed_user_name + description = "MMGIS ${var.environment} superadmin seed username (SEED_SUPERADMIN_USERNAME). Value set out-of-band." + recovery_window_in_days = var.secret_recovery_window_days +} + +resource "aws_secretsmanager_secret" "seed_password" { + name = local.secret_seed_pass_name + description = "MMGIS ${var.environment} superadmin seed password (SEED_SUPERADMIN_PASSWORD). Value set out-of-band." + recovery_window_in_days = var.secret_recovery_window_days +} + +# Shared dashboards password; injected only on the publish task (MMGIS_DASHBOARDS_PASSWORD). +resource "aws_secretsmanager_secret" "dashboards_password" { + name = local.secret_dash_pass_name + description = "MMGIS ${var.environment} shared dashboards password (MMGIS_DASHBOARDS_PASSWORD). Value set out-of-band." + recovery_window_in_days = var.secret_recovery_window_days +} diff --git a/infrastructure/terraform/modules/mmgis-environment/variables.tf b/infrastructure/terraform/modules/mmgis-environment/variables.tf new file mode 100644 index 000000000..43d29aef3 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/variables.tf @@ -0,0 +1,163 @@ +variable "environment" { + description = "Environment name. Full word matching the deploy branch (development / production). Used as the mmgis--* naming prefix for every resource." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]*$", var.environment)) + error_message = "environment must be lowercase alphanumeric/hyphen (e.g. development, production)." + } +} + +variable "region" { + description = "AWS region the environment is created in. Also the region used to build ARNs; must match the provider region." + type = string +} + +# ── Network (operator-provided; the account cannot create VPCs/subnets) ── + +variable "vpc_id" { + description = "Existing VPC id the environment runs in. Supplied via uncommitted tfvars." + type = string +} + +variable "private_subnet_ids" { + description = "Private subnet ids for the Express service and the publish task. At least two (different AZs) are required by ECS Express Mode. Private subnets keep the admin reachable only through CloudFront." + type = list(string) + + validation { + condition = length(var.private_subnet_ids) >= 2 + error_message = "ECS Express Mode requires at least two subnets in different AZs." + } +} + +# ── Compute sizing ── + +variable "admin_cpu" { + description = "CPU units for the admin Express service task." + type = string + default = "1024" +} + +variable "admin_memory" { + description = "Memory (MiB) for the admin Express service task." + type = string + default = "2048" +} + +variable "publish_cpu" { + description = "CPU units for the publish task (generous: it runs an in-task webpack static build)." + type = string + default = "2048" +} + +variable "publish_memory" { + description = "Memory (MiB) for the publish task." + type = string + default = "8192" +} + +# ── RDS ── + +variable "db_instance_class" { + description = "RDS instance class." + type = string + default = "db.t3.micro" +} + +variable "db_allocated_storage" { + description = "RDS allocated storage in GiB." + type = number + default = 20 +} + +variable "db_engine_version" { + description = "PostgreSQL major (or major.minor) version. 17 is the production-tested conservative baseline." + type = string + default = "17" +} + +variable "db_backup_retention_period" { + description = "RDS automated backup retention in days." + type = number + default = 7 +} + +variable "db_multi_az" { + description = "Whether to run RDS Multi-AZ." + type = bool + default = false +} + +variable "db_skip_final_snapshot" { + description = "Whether to skip the final snapshot on RDS deletion. Development (disposable) may set true; production should keep false." + type = bool + default = false +} + +variable "rds_ca_bundle_base64" { + description = "base64 of the per-region RDS CA bundle (truststore.pki.rds.amazonaws.com//-bundle.pem). RDS forces SSL; both task defs set DB_SSL=true + DB_SSL_CERT_BASE64. Public data, but region-specific and bulky, so it is supplied via tfvars rather than committed. Use the REGIONAL bundle — the global bundle exceeds the ECS env-var size limit." + type = string +} + +# ── Log retention ── + +variable "log_retention_days" { + description = "CloudWatch Logs retention (days) for both log groups." + type = number + default = 30 +} + +# ── ECR ── + +variable "ecr_force_delete" { + description = "Whether the ECR repository can be deleted while it still holds images." + type = bool + default = false +} + +# ── CI deploy role (GitHub OIDC) ── + +variable "github_repo" { + description = "owner/name of the GitHub repository whose Actions runs may assume the deploy role." + type = string + default = "NASA-IMPACT/MMGIS" +} + +variable "deploy_role_branch" { + description = "Git branch the deploy role's trust is scoped to. Branch-scoped for now (repo:...:ref:refs/heads/); #195 tightens both environments to GitHub-Environment-scoped trust. Defaults to the environment name." + type = string + default = "" +} + +# ── CloudFront two-phase inputs ── +# The Express service's internal ALB ARN and on.aws endpoint are NOT exposed +# as Terraform attributes of aws_ecs_express_gateway_service (only service_arn +# and ingress_paths are). The CloudFront VPC origin needs the ALB ARN and the +# distribution's admin origin needs the on.aws host name, so the CloudFront +# front door is created in a SECOND apply: phase 1 creates the service; the +# operator reads these two values (see README) and re-applies to build the +# VPC origin + distribution. + +variable "express_internal_alb_arn" { + description = "Phase 2. ARN of the internal ALB that ECS Express Mode provisioned for the admin service. Read from `aws ecs describe-express-gateway-service` after phase 1. Empty in phase 1." + type = string + default = "" +} + +variable "express_onaws_endpoint" { + description = "Phase 2. The Express service's on.aws endpoint host (mm-.ecs..on.aws) from ingress_paths / describe-express-gateway-service. Becomes the CloudFront admin origin DomainName. Empty in phase 1." + type = string + default = "" +} + +variable "express_alb_security_group_id" { + description = "Phase 2. Security-group id of the ECS-managed ALB fronting the admin service (from the same describe-express-gateway-service call as the ALB ARN). Drives the :443-from-VPC-CIDR ingress rule. Empty in phase 1." + type = string + default = "" +} + +variable "secret_recovery_window_days" { + description = "Secrets Manager recovery window on delete. Default 30 (production-safe). Development sets 0 so a destroy/re-apply cycle doesn't collide with names held in the recovery window." + type = number + default = 30 +} diff --git a/infrastructure/terraform/modules/mmgis-environment/versions.tf b/infrastructure/terraform/modules/mmgis-environment/versions.tf new file mode 100644 index 000000000..e609ebfd7 --- /dev/null +++ b/infrastructure/terraform/modules/mmgis-environment/versions.tf @@ -0,0 +1,12 @@ +terraform { + # aws_ecs_express_gateway_service and aws_cloudfront_vpc_origin are both + # required; the Express Mode resource landed in the AWS provider at 6.22.0. + required_version = ">= 1.11.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.22.0" + } + } +}