From cd3a0a1624c39d75023532b33964195d4dfb3208 Mon Sep 17 00:00:00 2001 From: Jay Dreyer Date: Thu, 6 Aug 2026 17:52:16 -0500 Subject: [PATCH 1/2] Provision ADR-0022 AWS infrastructure --- .github/workflows/quality.yml | 28 + .gitignore | 13 + docs/architecture.md | 15 + infra/terraform/README.md | 374 ++++++++++++ infra/terraform/bootstrap/.terraform.lock.hcl | 26 + infra/terraform/bootstrap/backend.tf | 7 + infra/terraform/bootstrap/main.tf | 70 +++ infra/terraform/bootstrap/outputs.tf | 4 + infra/terraform/bootstrap/variables.tf | 16 + infra/terraform/bootstrap/versions.tf | 25 + .../environments/demo/.terraform.lock.hcl | 26 + infra/terraform/environments/demo/backend.tf | 7 + infra/terraform/environments/demo/database.tf | 69 +++ infra/terraform/environments/demo/iam.tf | 550 ++++++++++++++++++ infra/terraform/environments/demo/identity.tf | 108 ++++ infra/terraform/environments/demo/ingress.tf | 105 ++++ infra/terraform/environments/demo/locals.tf | 34 ++ infra/terraform/environments/demo/network.tf | 204 +++++++ .../environments/demo/observability.tf | 477 +++++++++++++++ infra/terraform/environments/demo/outputs.tf | 184 ++++++ infra/terraform/environments/demo/registry.tf | 48 ++ infra/terraform/environments/demo/runtime.tf | 367 ++++++++++++ infra/terraform/environments/demo/secrets.tf | 29 + .../demo/terraform.tfvars.example | 22 + .../demo/tests/architecture.tftest.hcl | 209 +++++++ .../terraform/environments/demo/variables.tf | 223 +++++++ infra/terraform/environments/demo/versions.tf | 22 + .../audit_timeline_projection_service.py | 28 +- .../services/demo_assessment_seed_service.py | 8 +- tests/integration/test_audit_timeline_api.py | 6 +- tests/unit/test_audit_timeline_projection.py | 15 +- 31 files changed, 3277 insertions(+), 42 deletions(-) create mode 100644 infra/terraform/README.md create mode 100644 infra/terraform/bootstrap/.terraform.lock.hcl create mode 100644 infra/terraform/bootstrap/backend.tf create mode 100644 infra/terraform/bootstrap/main.tf create mode 100644 infra/terraform/bootstrap/outputs.tf create mode 100644 infra/terraform/bootstrap/variables.tf create mode 100644 infra/terraform/bootstrap/versions.tf create mode 100644 infra/terraform/environments/demo/.terraform.lock.hcl create mode 100644 infra/terraform/environments/demo/backend.tf create mode 100644 infra/terraform/environments/demo/database.tf create mode 100644 infra/terraform/environments/demo/iam.tf create mode 100644 infra/terraform/environments/demo/identity.tf create mode 100644 infra/terraform/environments/demo/ingress.tf create mode 100644 infra/terraform/environments/demo/locals.tf create mode 100644 infra/terraform/environments/demo/network.tf create mode 100644 infra/terraform/environments/demo/observability.tf create mode 100644 infra/terraform/environments/demo/outputs.tf create mode 100644 infra/terraform/environments/demo/registry.tf create mode 100644 infra/terraform/environments/demo/runtime.tf create mode 100644 infra/terraform/environments/demo/secrets.tf create mode 100644 infra/terraform/environments/demo/terraform.tfvars.example create mode 100644 infra/terraform/environments/demo/tests/architecture.tftest.hcl create mode 100644 infra/terraform/environments/demo/variables.tf create mode 100644 infra/terraform/environments/demo/versions.tf diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 06bb5ce..f263730 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -18,6 +18,34 @@ env: OPENAI_API_KEY: "" jobs: + terraform: + name: terraform + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: 1.15.8 + + - name: Check Terraform formatting + run: terraform fmt -check -recursive infra/terraform + + - name: Initialize and validate bootstrap root + run: | + terraform -chdir=infra/terraform/bootstrap init -backend=false + terraform -chdir=infra/terraform/bootstrap validate + + - name: Initialize and validate demo environment + run: | + terraform -chdir=infra/terraform/environments/demo init -backend=false + terraform -chdir=infra/terraform/environments/demo validate + + - name: Test ADR-0022 invariants and failure guards + run: terraform -chdir=infra/terraform/environments/demo test + quality: name: quality runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index fe398de..b00dbc7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,16 @@ web/tsconfig.tsbuildinfo # Local runtime data *.log *.pid + +# Terraform local state, provider cache, and uncommitted variable values +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +!*.tfvars.example +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/docs/architecture.md b/docs/architecture.md index 2851bde..8642b1d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,21 @@ enterprise impact assessments, plus the read-only Enterprise Knowledge Catalog E narrow persisted Confluence Cloud document-identity boundary, and trusted origin metadata for deterministic policy dependencies. +## AWS infrastructure definition + +ADR-0022's approved AWS topology is implemented as Terraform under +[`infra/terraform`](../infra/terraform/README.md). It preserves the modular-monolith boundary: one +ECS Fargate service runs standalone Next.js and FastAPI containers in one task, only Next.js is an +ALB target, PostgreSQL remains private RDS, and a separate one-off task definition owns the Alembic +execution boundary. + +Terraform defaults to the declared off state: ECS desired count zero with no ALB or application +alias. Enabling a demo creates Cognito-authenticated TLS ingress and exactly one task. Applying +the infrastructure remains prohibited until ADR-0022's authentication, trusted-header, +database-configuration, logging, health, and sizing prerequisites are implemented and tested. +Deployment, migrations, database-role bootstrap, seed, and operational lifecycle workflows remain +separate follow-up work. + ## High-level architecture ```mermaid diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..a8e8a12 --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,374 @@ +# ChangeOps AWS infrastructure + +This directory implements GitHub issue #44 and the fixed deployment architecture in ADR-0022. +Terraform is the source of truth for managed AWS resources. It does not deploy application +releases, run Alembic, create database roles, seed data, or implement the later demo-lifecycle +workflows. + +## Hard deployment gate + +Do not apply either root until ADR-0022's application prerequisites are implemented and tested: + +1. Next.js verifies ALB and Cognito claims and fails closed. +2. The proxy replaces, rather than forwards, browser actor and role headers. +3. Backend and migration processes assemble a TLS-required database URL from separate endpoint + and injected credential fields. +4. Request IDs and secret-safe structured JSON logs are implemented. +5. Next.js exposes the configured health endpoint. +6. Production container sizing and the bounded synchronous path are verified. + +Issue #44 establishes reproducible infrastructure definitions only. The safe default is +`demo_enabled = false`: no ALB or application DNS alias and ECS desired count zero. + +## Root layout and provisioned controls + +- `bootstrap/` creates the private, encrypted, versioned S3 state bucket. It is separate because a + backend cannot create its own bucket before initialization. +- `environments/demo/` creates the single approved environment. Files are separated by concern, + with no generic modules because no actual reuse exists. + +The demo root provisions: + +- one two-AZ VPC with public runtime and isolated database subnets; +- security groups allowing only ALB-to-Next.js on 3000 and task-to-RDS on 5432, with no public + FastAPI or RDS ingress; +- immutable scan-on-push ECR repositories; +- one two-container Fargate application task, one separate migration task, and one ECS service; +- encrypted private Single-AZ PostgreSQL 17 RDS, 20 GiB gp3 storage, 50 GiB autoscaling cap, + seven-day backups, TLS enforcement, log exports, deletion protection, and an AWS-managed + break-glass master password; +- separate empty Secrets Manager containers for runtime DB, migration DB, model-provider, Jira, + and Confluence credentials—never secret values in Terraform source; +- a closed Cognito pool with required TOTP MFA and `reviewer` and `admin` groups; +- ACM and Route 53, plus Cognito-authenticated ALB ingress only when a demo is enabled; +- separate least-privilege application/migration execution roles, permissionless task roles, and + GitHub OIDC deploy/lifecycle roles restricted to the protected production environment; +- 30-day logs, a dashboard, alarms, event notifications, SNS, and a tag-filtered USD 20 budget. + +## Local verification + +From the repository root: + +```bash +terraform fmt -check -recursive infra/terraform +terraform -chdir=infra/terraform/bootstrap init -backend=false +terraform -chdir=infra/terraform/bootstrap validate +terraform -chdir=infra/terraform/environments/demo init -backend=false +terraform -chdir=infra/terraform/environments/demo validate +terraform -chdir=infra/terraform/environments/demo test +``` + +The native tests prove the default off state, declared one-task demo state, private Single-AZ +database controls, fixed task size and ALB timeout, Region guard, and exact destructive +confirmation guard. + +## Initial provisioning checklist + +Complete this only after the hard deployment gate is satisfied. + +### 1. Confirm the AWS account and operator session + +Use a dedicated portfolio AWS account or one where the USD 20 budget is appropriate. Do not +provide or commit long-lived access keys. + +1. Install AWS CLI v2 and Terraform 1.15.8 (or a compatible `versions.tf` version). +2. Configure an AWS IAM Identity Center session: + + ```bash + aws configure sso --profile changeops-admin + aws sso login --profile changeops-admin + export AWS_PROFILE=changeops-admin + export AWS_REGION=us-east-1 + ``` + +3. Verify the exact account: + + ```bash + aws sts get-caller-identity + ``` + +4. Stop if the returned 12-digit `Account` is not the intended account. +5. The initial provisioning identity needs permission for S3 state, VPC/EC2 networking, ECR, ECS, + ELBv2, RDS, Secrets Manager, Cognito, ACM, Route 53, IAM/OIDC, CloudWatch, EventBridge, SNS, and + AWS Budgets. If temporary administrator access is used for the reviewed bootstrap, remove it + immediately afterward. + +### 2. Bootstrap secure Terraform state + +The first bootstrap apply temporarily uses local state because the bucket does not yet exist. + +1. Initialize without a backend, inspect, apply, and print the bucket name: + + ```bash + terraform -chdir=infra/terraform/bootstrap init -backend=false + terraform -chdir=infra/terraform/bootstrap plan -out=bootstrap.tfplan + terraform -chdir=infra/terraform/bootstrap apply bootstrap.tfplan + terraform -chdir=infra/terraform/bootstrap output -raw state_bucket_name + ``` + +2. Migrate bootstrap state to that bucket: + + ```bash + terraform -chdir=infra/terraform/bootstrap init -migrate-state \ + -backend-config="bucket=PASTE_BUCKET_NAME" \ + -backend-config="region=us-east-1" + ``` + +3. Answer `yes` only when Terraform asks to copy the existing local state. +4. Verify remote state: + + ```bash + terraform -chdir=infra/terraform/bootstrap state list + ``` + +5. If a local state copy remains, move it to encrypted storage and securely remove the working + copy. The bucket is versioned, encrypted, public-access-blocked, TLS-only, lock-enabled, and + protected from Terraform destruction. + +### 3. Prepare the environment configuration + +1. Create the ignored variable file: + + ```bash + cp infra/terraform/environments/demo/terraform.tfvars.example \ + infra/terraform/environments/demo/terraform.tfvars + ``` + +2. Set the registered `hosted_zone_name`, the `application_hostname` subdomain, `alert_email`, + `github_repository = "jaydreyer/ChangeOps"`, `github_environment = "production"`, and + `demo_enabled = false`. +3. After independently verifying every hard-gate prerequisite at the top of this document, set + `infrastructure_prerequisites_confirmed = true`. Terraform rejects every environment plan while + it remains false. +4. If the public Route 53 zone already exists, find its ID: + + ```bash + aws route53 list-hosted-zones-by-name --dns-name example.com + ``` + + Set `existing_hosted_zone_id` to the exact ID. Do not create a duplicate zone. +5. Check for an existing GitHub OIDC provider: + + ```bash + ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" + aws iam get-open-id-connect-provider \ + --open-id-connect-provider-arn \ + "arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com" + ``` + + If it exists, set `existing_github_oidc_provider_arn` to that ARN. If AWS returns + `NoSuchEntity`, leave it unset. AWS validates GitHub with trusted root CAs; no pinned + thumbprint is stored. + +### 4. Initialize the demo root + +```bash +terraform -chdir=infra/terraform/environments/demo init \ + -backend-config="bucket=PASTE_BUCKET_NAME" \ + -backend-config="region=us-east-1" +terraform -chdir=infra/terraform/environments/demo validate +terraform -chdir=infra/terraform/environments/demo test +``` + +### 5. Delegate DNS when Terraform creates the zone + +Skip this when `existing_hosted_zone_id` is set. + +1. Create only the hosted zone and print its four nameservers: + + ```bash + terraform -chdir=infra/terraform/environments/demo apply \ + -target=aws_route53_zone.app + terraform -chdir=infra/terraform/environments/demo output hosted_zone_name_servers + ``` + +2. At the domain registrar, open the domain's DNS/nameserver settings, choose custom nameservers, + replace the current set with all four Route 53 values, and save. +3. Wait for delegation, then verify: + + ```bash + dig NS example.com +short + ``` + + Continue only when the returned set matches Terraform. Propagation can take from minutes to the + registrar's published maximum. + +### 6. Review and apply the safe off-state infrastructure + +```bash +terraform -chdir=infra/terraform/environments/demo plan -out=demo-off.tfplan +terraform -chdir=infra/terraform/environments/demo apply demo-off.tfplan +terraform -chdir=infra/terraform/environments/demo output +``` + +Before applying, confirm the saved plan has no ALB or application A alias, ECS desired count +is zero, RDS is private/Single-AZ/deletion-protected, and no NAT Gateway, Kubernetes, queue, event +bus, API Gateway, or Lambda appears. + +### 7. Finish notifications and cost visibility + +1. Open the AWS SNS email for `changeops-demo-operations` and choose **Confirm subscription**. +2. In **Billing and Cost Management** → **Cost allocation tags** → **User-defined cost allocation + tags**, select `Application` and choose **Activate**. +3. In **Budgets**, verify `changeops-demo-monthly`: USD 20, with 50%, 80%, and 100% actual-spend + notifications. Activated tag data may take up to 24 hours to appear. + +### 8. Configure the protected GitHub Environment + +Terraform trusts only `repo:jaydreyer/ChangeOps:environment:production`. GitHub must enforce the +human and branch side: + +1. Repository **Settings** → **Environments** → **New environment**. +2. Enter `production` exactly and choose **Configure environment**. +3. Enable **Required reviewers** and add the intended human approver. +4. Enable **Prevent self-review** when a different approver is available. +5. Under **Deployment branches and tags**, choose **Selected branches and tags** and add only the + protected `main` branch. +6. Save. Later workflows must declare `environment: production` and + `permissions: id-token: write, contents: read`, use the Terraform output role ARNs, and never + use stored AWS access keys. + +### 9. Populate secrets only during the later deployment bootstrap + +Terraform creates secret containers but no values. After a separately reviewed database bootstrap +creates the schema-owner and restricted runtime users, create mode-`0600` JSON files outside the +repository and use `aws secretsmanager put-secret-value --secret-string file://...`. + +Required shapes: + +```json +{"username":"restricted_runtime_user","password":"generated-password"} +``` + +```json +{"username":"schema_owner_user","password":"different-generated-password"} +``` + +```json +{"api_key":"portfolio-model-provider-key"} +``` + +Optional Jira/Confluence shape: + +```json +{"email":"dedicated-portfolio-user@example.com","api_token":"dedicated-token"} +``` + +Never use customer, Workday, Salesforce, or production-enterprise credentials. Never put values +in `.tfvars`, GitHub secrets, task definitions, shell history, or repository files. Securely +delete the local JSON files after AWS confirms the secret versions. + +### 10. Invite closed Cognito users only for a demo + +1. AWS Console → **Cognito** → **User pools** → `changeops-demo-users`. +2. **Users** → **Create user**; enter the invited email and send a temporary password. +3. Open the user → **Add user to group**; choose exactly one of `reviewer` or `admin`, never both. +4. At first sign-in the user changes the temporary password and enrolls an authenticator-app TOTP + factor. MFA is required. +5. Disable or delete temporary users after the demo when access should not persist. + +## Routine safe off state + +1. Set `demo_enabled = false`. +2. Save and review the plan: + + ```bash + terraform -chdir=infra/terraform/environments/demo plan -out=demo-off.tfplan + ``` + +3. Confirm it sets ECS desired count to zero and destroys only the ALB, listeners, the A alias, + and demo-only alarms. Apply the saved plan. +4. Verify: + + ```bash + aws ecs describe-services \ + --cluster changeops-demo-cluster \ + --services changeops-demo-service \ + --query 'services[0].{desired:desiredCount,running:runningCount,pending:pendingCount}' + aws elbv2 describe-load-balancers --names changeops-demo-alb + ``` + + ECS counts must all be zero; ELB must return `LoadBalancerNotFound`. +5. For a known break of at most seven days: + + ```bash + aws rds stop-db-instance --db-instance-identifier changeops-demo-postgres + aws rds wait db-instance-stopped --db-instance-identifier changeops-demo-postgres + ``` + +AWS automatically restarts RDS after seven stopped days. A stopped DB is not a durable long-term +off state. The later reviewed lifecycle workflow will automate these operations and billable +inventory verification; until then, never report a partial teardown as off. + +## Full environment teardown + +Full teardown is destructive, not the normal between-demo operation. + +1. Complete the routine safe off state. +2. Choose a unique final snapshot ID, such as `changeops-demo-final-20260806t230000z`. +3. Plan and apply only the deletion-protection change: + + ```bash + terraform -chdir=infra/terraform/environments/demo plan \ + -var='allow_database_destroy=true' \ + -var='database_destroy_confirmation=demo' \ + -var='database_final_snapshot_identifier=changeops-demo-final-20260806t230000z' \ + -out=disable-db-protection.tfplan + terraform -chdir=infra/terraform/environments/demo apply disable-db-protection.tfplan + ``` + + Confirm the only DB change is `deletion_protection: true → false`. +4. Create and review the destroy plan with the same exact values: + + ```bash + terraform -chdir=infra/terraform/environments/demo plan -destroy \ + -var='allow_database_destroy=true' \ + -var='database_destroy_confirmation=demo' \ + -var='database_final_snapshot_identifier=changeops-demo-final-20260806t230000z' \ + -out=destroy-demo.tfplan + ``` + +5. Confirm account, environment, every target, retained state bucket, and final snapshot ID. + Obtain human approval, then apply only the saved plan: + + ```bash + terraform -chdir=infra/terraform/environments/demo apply destroy-demo.tfplan + ``` +6. Verify the final snapshot: + + ```bash + aws rds wait db-snapshot-available \ + --db-snapshot-identifier changeops-demo-final-20260806t230000z + aws rds describe-db-snapshots \ + --db-snapshot-identifier changeops-demo-final-20260806t230000z + ``` + +7. Confirm Cost Explorer and resource inventory show no ALB, running Fargate task, public task + address, or RDS instance. A failed destroy is not success. +8. Retain the verified snapshot and its verified predecessor. Deleting an older snapshot is a + separate human-approved retention action. + +The bootstrap state bucket is intentionally separate and protected. Archive its versioned state +before proposing any removal; never bypass `prevent_destroy` casually. + +## What Codex can execute for the operator + +Codex can run the bootstrap, plans, reviewed applies, read-only verification, and GitHub +Environment configuration in a later explicitly authorized task. To do that safely, provide: + +1. the intended 12-digit AWS account ID; +2. the registered parent domain and desired ChangeOps hostname; +3. whether the Route 53 hosted zone already exists and, if so, its zone ID; +4. the operational alert email; +5. an active AWS IAM Identity Center session available as a named local AWS CLI profile with the + provisioning permissions listed above—never send raw access keys in chat; +6. repository-admin authorization if Codex should configure the protected GitHub Environment; and +7. explicit authorization for the exact saved Terraform plan to be applied. + +For a domain at an external registrar, either complete the four-nameserver delegation yourself or +explicitly authorize browser assistance while already signed in. Do not share the registrar +password. Secret values and Cognito users are not needed to provision the safe off-state +infrastructure, and Codex should not receive real secret values in chat. + +At present, ADR-0022's hard deployment gate prevents any AWS apply. This repository change +therefore performs validation and planning logic only and creates no cloud resources. diff --git a/infra/terraform/bootstrap/.terraform.lock.hcl b/infra/terraform/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..f4d549a --- /dev/null +++ b/infra/terraform/bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.58.0" + constraints = "~> 6.33" + hashes = [ + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250", + "zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f", + "zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48", + "zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba", + "zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7", + "zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56", + "zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6", + "zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80", + "zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75", + "zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a", + "zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c", + "zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae", + "zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca", + "zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056", + ] +} diff --git a/infra/terraform/bootstrap/backend.tf b/infra/terraform/bootstrap/backend.tf new file mode 100644 index 0000000..e77b898 --- /dev/null +++ b/infra/terraform/bootstrap/backend.tf @@ -0,0 +1,7 @@ +terraform { + backend "s3" { + key = "bootstrap/terraform.tfstate" + use_lockfile = true + encrypt = true + } +} diff --git a/infra/terraform/bootstrap/main.tf b/infra/terraform/bootstrap/main.tf new file mode 100644 index 0000000..447111e --- /dev/null +++ b/infra/terraform/bootstrap/main.tf @@ -0,0 +1,70 @@ +locals { + state_bucket_name = "changeops-terraform-state-${data.aws_caller_identity.current.account_id}" +} + +resource "aws_s3_bucket" "terraform_state" { + bucket = local.state_bucket_name + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + policy = data.aws_iam_policy_document.terraform_state.json + + depends_on = [aws_s3_bucket_public_access_block.terraform_state] +} + +data "aws_iam_policy_document" "terraform_state" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + actions = ["s3:*"] + resources = [ + aws_s3_bucket.terraform_state.arn, + "${aws_s3_bucket.terraform_state.arn}/*", + ] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} diff --git a/infra/terraform/bootstrap/outputs.tf b/infra/terraform/bootstrap/outputs.tf new file mode 100644 index 0000000..8a38829 --- /dev/null +++ b/infra/terraform/bootstrap/outputs.tf @@ -0,0 +1,4 @@ +output "state_bucket_name" { + description = "Bucket passed to the demo root's partial S3 backend configuration." + value = aws_s3_bucket.terraform_state.id +} diff --git a/infra/terraform/bootstrap/variables.tf b/infra/terraform/bootstrap/variables.tf new file mode 100644 index 0000000..de37c91 --- /dev/null +++ b/infra/terraform/bootstrap/variables.tf @@ -0,0 +1,16 @@ +variable "aws_region" { + description = "Region for the Terraform state bucket." + type = string + default = "us-east-1" + + validation { + condition = var.aws_region == "us-east-1" + error_message = "ADR-0022 fixes the initial deployment Region to us-east-1." + } +} + +variable "github_repository" { + description = "Repository recorded on the state bucket." + type = string + default = "jaydreyer/ChangeOps" +} diff --git a/infra/terraform/bootstrap/versions.tf b/infra/terraform/bootstrap/versions.tf new file mode 100644 index 0000000..828f516 --- /dev/null +++ b/infra/terraform/bootstrap/versions.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.10.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.33" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Application = "ChangeOps" + ManagedBy = "Terraform" + Repository = var.github_repository + Architecture = "ADR-0022" + } + } +} + +data "aws_caller_identity" "current" {} diff --git a/infra/terraform/environments/demo/.terraform.lock.hcl b/infra/terraform/environments/demo/.terraform.lock.hcl new file mode 100644 index 0000000..f4d549a --- /dev/null +++ b/infra/terraform/environments/demo/.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.58.0" + constraints = "~> 6.33" + hashes = [ + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250", + "zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f", + "zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48", + "zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba", + "zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7", + "zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56", + "zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6", + "zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80", + "zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75", + "zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a", + "zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c", + "zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae", + "zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca", + "zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056", + ] +} diff --git a/infra/terraform/environments/demo/backend.tf b/infra/terraform/environments/demo/backend.tf new file mode 100644 index 0000000..c505447 --- /dev/null +++ b/infra/terraform/environments/demo/backend.tf @@ -0,0 +1,7 @@ +terraform { + backend "s3" { + key = "environments/demo/terraform.tfstate" + use_lockfile = true + encrypt = true + } +} diff --git a/infra/terraform/environments/demo/database.tf b/infra/terraform/environments/demo/database.tf new file mode 100644 index 0000000..4c53584 --- /dev/null +++ b/infra/terraform/environments/demo/database.tf @@ -0,0 +1,69 @@ +resource "aws_db_subnet_group" "main" { + name = "${local.name_prefix}-database" + subnet_ids = aws_subnet.database[*].id + + tags = { + Name = "${local.name_prefix}-database" + } +} + +resource "aws_db_parameter_group" "postgres17" { + name = "${local.name_prefix}-postgres17" + family = "postgres17" + + parameter { + name = "rds.force_ssl" + value = "1" + } + + tags = { + Name = "${local.name_prefix}-postgres17" + } +} + +resource "aws_db_instance" "main" { + identifier = local.database_identifier + + engine = "postgres" + engine_version = "17" + instance_class = "db.t4g.micro" + multi_az = false + + allocated_storage = 20 + max_allocated_storage = 50 + storage_type = "gp3" + storage_encrypted = true + + db_name = var.database_name + username = var.database_admin_username + manage_master_user_password = true + port = 5432 + + db_subnet_group_name = aws_db_subnet_group.main.name + parameter_group_name = aws_db_parameter_group.postgres17.name + vpc_security_group_ids = [aws_security_group.database.id] + publicly_accessible = false + + backup_retention_period = 7 + backup_window = "03:00-04:00" + maintenance_window = "sun:04:00-sun:05:00" + copy_tags_to_snapshot = true + + enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] + auto_minor_version_upgrade = true + + deletion_protection = !var.allow_database_destroy + delete_automated_backups = false + skip_final_snapshot = false + final_snapshot_identifier = var.database_final_snapshot_identifier + + depends_on = [ + aws_cloudwatch_log_group.rds_postgresql, + aws_cloudwatch_log_group.rds_upgrade, + ] + + tags = { + Name = local.database_identifier + DataSensitivity = "portfolio-demo" + } +} diff --git a/infra/terraform/environments/demo/iam.tf b/infra/terraform/environments/demo/iam.tf new file mode 100644 index 0000000..d37c8f6 --- /dev/null +++ b/infra/terraform/environments/demo/iam.tf @@ -0,0 +1,550 @@ +data "aws_iam_policy_document" "ecs_tasks_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "application_execution" { + name = "${local.name_prefix}-application-execution" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json +} + +resource "aws_iam_role" "migration_execution" { + name = "${local.name_prefix}-migration-execution" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json +} + +resource "aws_iam_role" "application_task" { + name = "${local.name_prefix}-application-task" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json + description = "ChangeOps application task role; intentionally has no AWS API permissions" +} + +resource "aws_iam_role" "migration_task" { + name = "${local.name_prefix}-migration-task" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json + description = "ChangeOps migration task role; database authority comes only from its injected credential" +} + +data "aws_iam_policy_document" "application_execution" { + statement { + sid = "ECRAuthorization" + effect = "Allow" + actions = [ + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + } + + statement { + sid = "ReadApplicationImages" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = [ + aws_ecr_repository.api.arn, + aws_ecr_repository.web.arn, + ] + } + + statement { + sid = "WriteApplicationLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = [ + "${aws_cloudwatch_log_group.api.arn}:*", + "${aws_cloudwatch_log_group.web.arn}:*", + ] + } + + statement { + sid = "ReadNamedApplicationSecrets" + effect = "Allow" + actions = ["secretsmanager:GetSecretValue"] + resources = concat( + [ + aws_secretsmanager_secret.runtime_db.arn, + aws_secretsmanager_secret.model_provider.arn, + ], + var.enable_jira ? [aws_secretsmanager_secret.jira.arn] : [], + var.enable_confluence ? [aws_secretsmanager_secret.confluence.arn] : [], + ) + } +} + +resource "aws_iam_role_policy" "application_execution" { + name = "least-privilege-application-execution" + role = aws_iam_role.application_execution.id + policy = data.aws_iam_policy_document.application_execution.json +} + +data "aws_iam_policy_document" "migration_execution" { + statement { + sid = "ECRAuthorization" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + statement { + sid = "ReadBackendImage" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = [aws_ecr_repository.api.arn] + } + + statement { + sid = "WriteMigrationLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.migration.arn}:*"] + } + + statement { + sid = "ReadMigrationSecretOnly" + effect = "Allow" + actions = ["secretsmanager:GetSecretValue"] + resources = [aws_secretsmanager_secret.migration_db.arn] + } +} + +resource "aws_iam_role_policy" "migration_execution" { + name = "least-privilege-migration-execution" + role = aws_iam_role.migration_execution.id + policy = data.aws_iam_policy_document.migration_execution.json +} + +variable "existing_github_oidc_provider_arn" { + description = "Existing GitHub Actions OIDC provider ARN. Leave null to create it in this account." + type = string + default = null + nullable = true +} + +resource "aws_iam_openid_connect_provider" "github" { + count = var.existing_github_oidc_provider_arn == null ? 1 : 0 + + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] +} + +locals { + github_oidc_provider_arn = var.existing_github_oidc_provider_arn != null ? var.existing_github_oidc_provider_arn : aws_iam_openid_connect_provider.github[0].arn +} + +data "aws_iam_policy_document" "github_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = [local.github_oidc_subject] + } + } +} + +resource "aws_iam_role" "github_deploy" { + name = "${local.name_prefix}-github-deploy" + description = "OIDC role for immutable image push, gated migration, and ECS deployment" + assume_role_policy = data.aws_iam_policy_document.github_assume.json + + max_session_duration = 3600 +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + sid = "ECRAuthorization" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + statement { + sid = "PushNamedImages" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + ] + resources = [ + aws_ecr_repository.api.arn, + aws_ecr_repository.web.arn, + ] + } + + statement { + sid = "RegisterReviewedTaskDefinitions" + effect = "Allow" + actions = [ + "ecs:RegisterTaskDefinition", + "ecs:TagResource", + ] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:RequestTag/Application" + values = ["ChangeOps"] + } + } + + statement { + sid = "RunNamedTaskDefinitions" + effect = "Allow" + actions = ["ecs:RunTask"] + resources = ["arn:${data.aws_partition.current.partition}:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task-definition/${local.migration_family}:*"] + + condition { + test = "ArnEquals" + variable = "ecs:cluster" + values = [aws_ecs_cluster.main.arn] + } + } + + statement { + sid = "StopNamedClusterTasks" + effect = "Allow" + actions = ["ecs:StopTask"] + resources = ["arn:${data.aws_partition.current.partition}:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task/${local.ecs_cluster_name}/*"] + + condition { + test = "ArnEquals" + variable = "ecs:cluster" + values = [aws_ecs_cluster.main.arn] + } + } + + statement { + sid = "UpdateNamedService" + effect = "Allow" + actions = ["ecs:UpdateService"] + resources = [local.ecs_service_arn] + } + + statement { + sid = "ReadDeploymentState" + effect = "Allow" + actions = [ + "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", + "ecs:DescribeTasks", + "ecs:ListTasks", + ] + resources = ["*"] + } + + statement { + sid = "ReadNamedLogs" + effect = "Allow" + actions = [ + "logs:GetLogEvents", + "logs:FilterLogEvents", + ] + resources = [ + "${aws_cloudwatch_log_group.api.arn}:*", + "${aws_cloudwatch_log_group.web.arn}:*", + "${aws_cloudwatch_log_group.migration.arn}:*", + ] + } + + statement { + sid = "PassNamedECSRolesOnly" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [ + aws_iam_role.application_execution.arn, + aws_iam_role.application_task.arn, + aws_iam_role.migration_execution.arn, + aws_iam_role.migration_task.arn, + ] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "changeops-deployment-only" + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json +} + +resource "aws_iam_role" "github_lifecycle" { + name = "${local.name_prefix}-github-lifecycle" + description = "OIDC role for separately approved demo startup and destructive teardown workflows" + assume_role_policy = data.aws_iam_policy_document.github_assume.json + + max_session_duration = 3600 +} + +data "aws_iam_policy_document" "github_lifecycle" { + statement { + sid = "ManageNamedDatabaseLifecycle" + effect = "Allow" + actions = [ + "rds:CreateDBSnapshot", + "rds:DeleteDBInstance", + "rds:ModifyDBInstance", + "rds:StartDBInstance", + "rds:StopDBInstance", + ] + resources = [ + aws_db_instance.main.arn, + "arn:${data.aws_partition.current.partition}:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:snapshot:${local.name_prefix}-*", + ] + } + + statement { + sid = "RestoreOnlyTaggedChangeOpsDatabase" + effect = "Allow" + actions = [ + "rds:RestoreDBInstanceFromDBSnapshot", + ] + resources = [ + "arn:${data.aws_partition.current.partition}:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:db:${local.name_prefix}-*", + "arn:${data.aws_partition.current.partition}:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:snapshot:${local.name_prefix}-*", + "arn:${data.aws_partition.current.partition}:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subgrp:${aws_db_subnet_group.main.name}", + "arn:${data.aws_partition.current.partition}:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:pg:${aws_db_parameter_group.postgres17.name}", + ] + + condition { + test = "StringEquals" + variable = "aws:RequestTag/Application" + values = ["ChangeOps"] + } + + condition { + test = "StringEquals" + variable = "rds:DatabaseClass" + values = ["db.t4g.micro"] + } + + condition { + test = "Bool" + variable = "rds:PubliclyAccessible" + values = ["false"] + } + + condition { + test = "NumericLessThanEquals" + variable = "rds:StorageSize" + values = ["50"] + } + } + + statement { + sid = "TagOnlyRestoredChangeOpsDatabase" + effect = "Allow" + actions = ["rds:AddTagsToResource"] + resources = ["arn:${data.aws_partition.current.partition}:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:db:${local.name_prefix}-*"] + + condition { + test = "StringEquals" + variable = "aws:RequestTag/Application" + values = ["ChangeOps"] + } + } + + statement { + sid = "ReadDatabaseState" + effect = "Allow" + actions = [ + "rds:DescribeDBInstances", + "rds:DescribeDBSnapshots", + ] + resources = ["*"] + } + + statement { + sid = "CreateTaggedLoadBalancer" + effect = "Allow" + actions = ["elasticloadbalancing:CreateLoadBalancer"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:RequestTag/Application" + values = ["ChangeOps"] + } + } + + statement { + sid = "CreateTaggedListeners" + effect = "Allow" + actions = ["elasticloadbalancing:CreateListener"] + resources = [ + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:${var.aws_region}:${data.aws_caller_identity.current.account_id}:loadbalancer/app/${local.name_prefix}-alb/*", + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:${var.aws_region}:${data.aws_caller_identity.current.account_id}:listener/app/${local.name_prefix}-alb/*/*", + aws_lb_target_group.web.arn, + ] + + condition { + test = "StringEquals" + variable = "aws:RequestTag/Application" + values = ["ChangeOps"] + } + } + + statement { + sid = "ManageNamedIngress" + effect = "Allow" + actions = [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:ModifyListener", + ] + resources = [ + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:${var.aws_region}:${data.aws_caller_identity.current.account_id}:loadbalancer/app/${local.name_prefix}-alb/*", + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:${var.aws_region}:${data.aws_caller_identity.current.account_id}:listener/app/${local.name_prefix}-alb/*/*", + aws_lb_target_group.web.arn, + ] + + condition { + test = "StringEquals" + variable = "aws:ResourceTag/Application" + values = ["ChangeOps"] + } + } + + statement { + sid = "ReadIngress" + effect = "Allow" + actions = [ + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeTargetHealth", + ] + resources = ["*"] + } + + statement { + sid = "UpdateApplicationAlias" + effect = "Allow" + actions = ["route53:ChangeResourceRecordSets"] + resources = ["arn:${data.aws_partition.current.partition}:route53:::hostedzone/${local.hosted_zone_id}"] + + condition { + test = "ForAllValues:StringEquals" + variable = "route53:ChangeResourceRecordSetsNormalizedRecordNames" + values = [var.application_hostname] + } + + condition { + test = "ForAllValues:StringEquals" + variable = "route53:ChangeResourceRecordSetsRecordTypes" + values = ["A"] + } + + condition { + test = "ForAllValues:StringEquals" + variable = "route53:ChangeResourceRecordSetsActions" + values = ["CREATE", "UPSERT", "DELETE"] + } + } + + statement { + sid = "ReadIngressAndDNS" + effect = "Allow" + actions = [ + "route53:GetChange", + "route53:ListResourceRecordSets", + "route53:ListHostedZonesByName", + ] + resources = ["*"] + } + + statement { + sid = "RegisterTaggedApplicationTask" + effect = "Allow" + actions = [ + "ecs:RegisterTaskDefinition", + "ecs:TagResource", + ] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:RequestTag/Application" + values = ["ChangeOps"] + } + } + + statement { + sid = "UpdateNamedApplicationService" + effect = "Allow" + actions = ["ecs:UpdateService"] + resources = [local.ecs_service_arn] + } + + statement { + sid = "ReadApplicationService" + effect = "Allow" + actions = [ + "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", + ] + resources = ["*"] + } + + statement { + sid = "PassNamedApplicationRolesOnly" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [ + aws_iam_role.application_execution.arn, + aws_iam_role.application_task.arn, + ] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy" "github_lifecycle" { + name = "changeops-environment-lifecycle-only" + role = aws_iam_role.github_lifecycle.id + policy = data.aws_iam_policy_document.github_lifecycle.json +} diff --git a/infra/terraform/environments/demo/identity.tf b/infra/terraform/environments/demo/identity.tf new file mode 100644 index 0000000..b3d0e0b --- /dev/null +++ b/infra/terraform/environments/demo/identity.tf @@ -0,0 +1,108 @@ +resource "aws_route53_zone" "app" { + count = var.existing_hosted_zone_id == null ? 1 : 0 + + name = var.hosted_zone_name + comment = "Public DNS for ChangeOps ${var.environment}" +} + +resource "aws_acm_certificate" "app" { + domain_name = var.application_hostname + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = { + Name = var.application_hostname + } +} + +resource "aws_route53_record" "certificate_validation" { + allow_overwrite = true + zone_id = local.hosted_zone_id + name = one(aws_acm_certificate.app.domain_validation_options).resource_record_name + type = one(aws_acm_certificate.app.domain_validation_options).resource_record_type + ttl = 60 + records = [one(aws_acm_certificate.app.domain_validation_options).resource_record_value] +} + +resource "aws_acm_certificate_validation" "app" { + certificate_arn = aws_acm_certificate.app.arn + validation_record_fqdns = [aws_route53_record.certificate_validation.fqdn] +} + +resource "aws_cognito_user_pool" "app" { + name = "${local.name_prefix}-users" + username_attributes = ["email"] + auto_verified_attributes = ["email"] + mfa_configuration = "ON" + + admin_create_user_config { + allow_admin_create_user_only = true + } + + software_token_mfa_configuration { + enabled = true + } + + password_policy { + minimum_length = 14 + require_lowercase = true + require_numbers = true + require_symbols = true + require_uppercase = true + temporary_password_validity_days = 1 + } + + tags = { + Name = "${local.name_prefix}-users" + } +} + +resource "aws_cognito_user_pool_domain" "app" { + domain = "${local.name_prefix}-${data.aws_caller_identity.current.account_id}" + user_pool_id = aws_cognito_user_pool.app.id +} + +resource "aws_cognito_user_pool_client" "alb" { + name = "${local.name_prefix}-alb" + user_pool_id = aws_cognito_user_pool.app.id + generate_secret = true + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["openid"] + supported_identity_providers = ["COGNITO"] + callback_urls = ["https://${var.application_hostname}/oauth2/idpresponse"] + logout_urls = ["https://${var.application_hostname}/"] + prevent_user_existence_errors = "ENABLED" + + access_token_validity = 1 + id_token_validity = 1 + refresh_token_validity = 1 + + token_validity_units { + access_token = "hours" + id_token = "hours" + refresh_token = "days" + } + + explicit_auth_flows = [ + "ALLOW_REFRESH_TOKEN_AUTH", + "ALLOW_USER_SRP_AUTH", + ] +} + +resource "aws_cognito_user_group" "reviewer" { + name = "reviewer" + description = "Closed ChangeOps reviewer role" + user_pool_id = aws_cognito_user_pool.app.id + precedence = 20 +} + +resource "aws_cognito_user_group" "admin" { + name = "admin" + description = "Closed ChangeOps administrator role" + user_pool_id = aws_cognito_user_pool.app.id + precedence = 10 +} diff --git a/infra/terraform/environments/demo/ingress.tf b/infra/terraform/environments/demo/ingress.tf new file mode 100644 index 0000000..096a7ff --- /dev/null +++ b/infra/terraform/environments/demo/ingress.tf @@ -0,0 +1,105 @@ +resource "aws_lb_target_group" "web" { + name = "${local.name_prefix}-web" + port = 3000 + protocol = "HTTP" + target_type = "ip" + vpc_id = aws_vpc.main.id + + deregistration_delay = 30 + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + timeout = 5 + matcher = "200" + path = var.web_health_check_path + protocol = "HTTP" + } + + tags = { + Name = "${local.name_prefix}-web" + } +} + +resource "aws_lb" "app" { + count = var.demo_enabled ? 1 : 0 + + name = "${local.name_prefix}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + enable_deletion_protection = false + idle_timeout = 180 + drop_invalid_header_fields = true + + tags = { + Name = "${local.name_prefix}-alb" + BillingState = "demo-window" + } +} + +resource "aws_lb_listener" "http" { + count = var.demo_enabled ? 1 : 0 + + load_balancer_arn = aws_lb.app[0].arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +resource "aws_lb_listener" "https" { + count = var.demo_enabled ? 1 : 0 + + load_balancer_arn = aws_lb.app[0].arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate_validation.app.certificate_arn + + default_action { + type = "authenticate-cognito" + order = 1 + + authenticate_cognito { + user_pool_arn = aws_cognito_user_pool.app.arn + user_pool_client_id = aws_cognito_user_pool_client.alb.id + user_pool_domain = aws_cognito_user_pool_domain.app.domain + on_unauthenticated_request = "authenticate" + scope = "openid" + session_cookie_name = "ChangeOpsSession" + session_timeout = 3600 + } + } + + default_action { + type = "forward" + order = 2 + target_group_arn = aws_lb_target_group.web.arn + } +} + +resource "aws_route53_record" "app_ipv4" { + count = var.demo_enabled ? 1 : 0 + + zone_id = local.hosted_zone_id + name = var.application_hostname + type = "A" + + alias { + name = aws_lb.app[0].dns_name + zone_id = aws_lb.app[0].zone_id + evaluate_target_health = true + } +} diff --git a/infra/terraform/environments/demo/locals.tf b/infra/terraform/environments/demo/locals.tf new file mode 100644 index 0000000..4e8819d --- /dev/null +++ b/infra/terraform/environments/demo/locals.tf @@ -0,0 +1,34 @@ +locals { + name_prefix = "changeops-${var.environment}" + + common_tags = { + Application = "ChangeOps" + Environment = var.environment + ManagedBy = "Terraform" + Repository = var.github_repository + Architecture = "ADR-0022" + } + + public_subnet_cidrs = [cidrsubnet(var.vpc_cidr, 8, 0), cidrsubnet(var.vpc_cidr, 8, 1)] + database_subnet_cidrs = [cidrsubnet(var.vpc_cidr, 8, 10), cidrsubnet(var.vpc_cidr, 8, 11)] + vpc_dns_resolver_cidr = "${cidrhost(var.vpc_cidr, 2)}/32" + + hosted_zone_id = var.existing_hosted_zone_id != null ? var.existing_hosted_zone_id : aws_route53_zone.app[0].zone_id + + github_oidc_subject = "repo:${var.github_repository}:environment:${var.github_environment}" + + ecs_cluster_name = "${local.name_prefix}-cluster" + ecs_service_name = "${local.name_prefix}-service" + ecs_service_arn = "arn:${data.aws_partition.current.partition}:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:service/${local.ecs_cluster_name}/${local.ecs_service_name}" + application_family = "${local.name_prefix}-application" + migration_family = "${local.name_prefix}-migration" + database_identifier = "${local.name_prefix}-postgres" + + secret_arns = { + runtime_db = aws_secretsmanager_secret.runtime_db.arn + migration_db = aws_secretsmanager_secret.migration_db.arn + model_provider = aws_secretsmanager_secret.model_provider.arn + jira = aws_secretsmanager_secret.jira.arn + confluence = aws_secretsmanager_secret.confluence.arn + } +} diff --git a/infra/terraform/environments/demo/network.tf b/infra/terraform/environments/demo/network.tf new file mode 100644 index 0000000..887544a --- /dev/null +++ b/infra/terraform/environments/demo/network.tf @@ -0,0 +1,204 @@ +data "aws_availability_zones" "available" { + state = "available" +} + +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${local.name_prefix}-vpc" + } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-igw" + } +} + +resource "aws_subnet" "public" { + count = 2 + + vpc_id = aws_vpc.main.id + availability_zone = data.aws_availability_zones.available.names[count.index] + cidr_block = local.public_subnet_cidrs[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${local.name_prefix}-public-${count.index + 1}" + Tier = "public-runtime" + } +} + +resource "aws_subnet" "database" { + count = 2 + + vpc_id = aws_vpc.main.id + availability_zone = data.aws_availability_zones.available.names[count.index] + cidr_block = local.database_subnet_cidrs[count.index] + map_public_ip_on_launch = false + + tags = { + Name = "${local.name_prefix}-database-${count.index + 1}" + Tier = "isolated-database" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${local.name_prefix}-public" + } +} + +resource "aws_route_table_association" "public" { + count = 2 + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "database" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-database-isolated" + } +} + +resource "aws_route_table_association" "database" { + count = 2 + + subnet_id = aws_subnet.database[count.index].id + route_table_id = aws_route_table.database.id +} + +resource "aws_security_group" "alb" { + name = "${local.name_prefix}-alb" + description = "Public HTTPS ingress to the Cognito-authenticated ALB" + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-alb" + } +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + security_group_id = aws_security_group.alb.id + description = "HTTP redirect only" + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 80 + to_port = 80 +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https" { + security_group_id = aws_security_group.alb.id + description = "Authenticated HTTPS product ingress" + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 +} + +resource "aws_security_group" "task" { + name = "${local.name_prefix}-task" + description = "Fargate task: Next.js only from ALB; FastAPI has no ingress" + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-task" + } +} + +resource "aws_vpc_security_group_ingress_rule" "task_web_from_alb" { + security_group_id = aws_security_group.task.id + description = "Next.js only from the ALB" + referenced_security_group_id = aws_security_group.alb.id + ip_protocol = "tcp" + from_port = 3000 + to_port = 3000 +} + +resource "aws_vpc_security_group_egress_rule" "alb_to_web" { + security_group_id = aws_security_group.alb.id + description = "Forward authenticated requests to Next.js" + referenced_security_group_id = aws_security_group.task.id + ip_protocol = "tcp" + from_port = 3000 + to_port = 3000 +} + +resource "aws_vpc_security_group_egress_rule" "alb_https" { + security_group_id = aws_security_group.alb.id + description = "TLS to Cognito authentication endpoints" + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 +} + +resource "aws_security_group" "database" { + name = "${local.name_prefix}-database" + description = "PostgreSQL only from the ChangeOps Fargate task" + vpc_id = aws_vpc.main.id + + tags = { + Name = "${local.name_prefix}-database" + } +} + +resource "aws_vpc_security_group_ingress_rule" "database_from_task" { + security_group_id = aws_security_group.database.id + description = "TLS PostgreSQL only from the task security group" + referenced_security_group_id = aws_security_group.task.id + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 +} + +resource "aws_vpc_security_group_egress_rule" "task_to_database" { + security_group_id = aws_security_group.task.id + description = "PostgreSQL only to the database security group" + referenced_security_group_id = aws_security_group.database.id + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 +} + +resource "aws_vpc_security_group_egress_rule" "task_https" { + security_group_id = aws_security_group.task.id + description = "Outbound TLS for ECR, providers, Jira, and Confluence" + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 +} + +resource "aws_vpc_security_group_egress_rule" "task_dns_udp" { + security_group_id = aws_security_group.task.id + description = "DNS through the VPC resolver" + cidr_ipv4 = local.vpc_dns_resolver_cidr + ip_protocol = "udp" + from_port = 53 + to_port = 53 +} + +resource "aws_vpc_security_group_egress_rule" "task_dns_tcp" { + security_group_id = aws_security_group.task.id + description = "DNS fallback through the VPC resolver" + cidr_ipv4 = local.vpc_dns_resolver_cidr + ip_protocol = "tcp" + from_port = 53 + to_port = 53 +} diff --git a/infra/terraform/environments/demo/observability.tf b/infra/terraform/environments/demo/observability.tf new file mode 100644 index 0000000..00cd0b0 --- /dev/null +++ b/infra/terraform/environments/demo/observability.tf @@ -0,0 +1,477 @@ +resource "aws_sns_topic" "operations" { + name = "${local.name_prefix}-operations" +} + +resource "aws_sns_topic_subscription" "email" { + count = var.alert_email == null ? 0 : 1 + + topic_arn = aws_sns_topic.operations.arn + protocol = "email" + endpoint = var.alert_email +} + +data "aws_iam_policy_document" "operations_topic" { + statement { + sid = "AllowCloudWatchAndEventBridge" + effect = "Allow" + actions = ["sns:Publish"] + resources = [aws_sns_topic.operations.arn] + + principals { + type = "Service" + identifiers = [ + "cloudwatch.amazonaws.com", + "events.amazonaws.com", + ] + } + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [data.aws_caller_identity.current.account_id] + } + } + + statement { + sid = "AllowNamedRDSEvents" + effect = "Allow" + actions = ["sns:Publish"] + resources = [aws_sns_topic.operations.arn] + + principals { + type = "Service" + identifiers = ["events.rds.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [data.aws_caller_identity.current.account_id] + } + + condition { + test = "ArnEquals" + variable = "aws:SourceArn" + values = [aws_db_instance.main.arn] + } + } +} + +resource "aws_sns_topic_policy" "operations" { + arn = aws_sns_topic.operations.arn + policy = data.aws_iam_policy_document.operations_topic.json +} + +locals { + alarm_actions = [aws_sns_topic.operations.arn] +} + +resource "aws_cloudwatch_log_metric_filter" "application_errors" { + name = "${local.name_prefix}-application-errors" + log_group_name = aws_cloudwatch_log_group.api.name + pattern = "{ $.level = \"ERROR\" }" + + metric_transformation { + name = "ApplicationErrors" + namespace = "ChangeOps/${var.environment}" + value = "1" + default_value = "0" + } +} + +resource "aws_cloudwatch_metric_alarm" "application_errors" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-application-errors" + alarm_description = "FastAPI emitted one or more structured ERROR records during a demo window." + namespace = "ChangeOps/${var.environment}" + metric_name = "ApplicationErrors" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions +} + +resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_targets" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-alb-unhealthy-targets" + alarm_description = "The authenticated ALB has an unhealthy Next.js target." + namespace = "AWS/ApplicationELB" + metric_name = "UnHealthyHostCount" + statistic = "Maximum" + period = 60 + evaluation_periods = 2 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "breaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + LoadBalancer = aws_lb.app[0].arn_suffix + TargetGroup = aws_lb_target_group.web.arn_suffix + } +} + +resource "aws_cloudwatch_metric_alarm" "alb_5xx" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-alb-5xx" + alarm_description = "The ALB returned server errors during a demo window." + namespace = "AWS/ApplicationELB" + metric_name = "HTTPCode_ELB_5XX_Count" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + LoadBalancer = aws_lb.app[0].arn_suffix + } +} + +resource "aws_cloudwatch_metric_alarm" "target_5xx" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-target-5xx" + alarm_description = "Next.js returned server errors during a demo window." + namespace = "AWS/ApplicationELB" + metric_name = "HTTPCode_Target_5XX_Count" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + LoadBalancer = aws_lb.app[0].arn_suffix + TargetGroup = aws_lb_target_group.web.arn_suffix + } +} + +resource "aws_cloudwatch_metric_alarm" "alb_response_time" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-alb-response-time" + alarm_description = "P95 ALB response time is abnormal for the bounded synchronous path." + namespace = "AWS/ApplicationELB" + metric_name = "TargetResponseTime" + extended_statistic = "p95" + period = 300 + evaluation_periods = 2 + threshold = 120 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + LoadBalancer = aws_lb.app[0].arn_suffix + TargetGroup = aws_lb_target_group.web.arn_suffix + } +} + +resource "aws_cloudwatch_metric_alarm" "ecs_running_task" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-ecs-running-task" + alarm_description = "The declared demo window does not have its one desired task." + namespace = "ECS/ContainerInsights" + metric_name = "RunningTaskCount" + statistic = "Minimum" + period = 60 + evaluation_periods = 2 + threshold = 1 + comparison_operator = "LessThanThreshold" + treat_missing_data = "breaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.app.name + } +} + +resource "aws_cloudwatch_metric_alarm" "ecs_cpu" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-ecs-cpu" + alarm_description = "Sustained high application task CPU." + namespace = "AWS/ECS" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = 85 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.app.name + } +} + +resource "aws_cloudwatch_metric_alarm" "ecs_memory" { + count = var.demo_enabled ? 1 : 0 + + alarm_name = "${local.name_prefix}-ecs-memory" + alarm_description = "Sustained high application task memory." + namespace = "AWS/ECS" + metric_name = "MemoryUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = 85 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.app.name + } +} + +resource "aws_cloudwatch_metric_alarm" "rds_cpu" { + alarm_name = "${local.name_prefix}-rds-cpu" + alarm_description = "Sustained high PostgreSQL CPU." + namespace = "AWS/RDS" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = 85 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + DBInstanceIdentifier = aws_db_instance.main.identifier + } +} + +resource "aws_cloudwatch_metric_alarm" "rds_free_storage" { + alarm_name = "${local.name_prefix}-rds-free-storage" + alarm_description = "PostgreSQL free storage dropped below 2 GiB." + namespace = "AWS/RDS" + metric_name = "FreeStorageSpace" + statistic = "Minimum" + period = 300 + evaluation_periods = 2 + threshold = 2147483648 + comparison_operator = "LessThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + DBInstanceIdentifier = aws_db_instance.main.identifier + } +} + +resource "aws_cloudwatch_metric_alarm" "rds_connections" { + alarm_name = "${local.name_prefix}-rds-connections" + alarm_description = "PostgreSQL connections are unexpectedly high for the single-task baseline." + namespace = "AWS/RDS" + metric_name = "DatabaseConnections" + statistic = "Maximum" + period = 300 + evaluation_periods = 2 + threshold = 60 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = local.alarm_actions + ok_actions = local.alarm_actions + + dimensions = { + DBInstanceIdentifier = aws_db_instance.main.identifier + } +} + +resource "aws_db_event_subscription" "availability" { + name = "${local.name_prefix}-database-availability" + sns_topic = aws_sns_topic.operations.arn + + source_type = "db-instance" + source_ids = [aws_db_instance.main.identifier] + event_categories = [ + "availability", + "failure", + "failover", + "low storage", + "maintenance", + "notification", + "recovery", + ] + + depends_on = [aws_sns_topic_policy.operations] +} + +resource "aws_cloudwatch_event_rule" "ecs_deployment_failed" { + name = "${local.name_prefix}-ecs-deployment-failed" + description = "Report ECS deployment circuit-breaker failures." + + event_pattern = jsonencode({ + source = ["aws.ecs"] + "detail-type" = ["ECS Deployment State Change"] + detail = { + eventName = ["SERVICE_DEPLOYMENT_FAILED"] + clusterArn = [aws_ecs_cluster.main.arn] + } + }) +} + +resource "aws_cloudwatch_event_target" "ecs_deployment_failed" { + rule = aws_cloudwatch_event_rule.ecs_deployment_failed.name + target_id = "operations" + arn = aws_sns_topic.operations.arn +} + +resource "aws_cloudwatch_event_rule" "application_task_failed" { + name = "${local.name_prefix}-application-task-failed" + description = "Report unexpected application task stops while excluding normal scheduler replacement." + + event_pattern = jsonencode({ + source = ["aws.ecs"] + "detail-type" = ["ECS Task State Change"] + detail = { + clusterArn = [aws_ecs_cluster.main.arn] + lastStatus = ["STOPPED"] + taskDefinitionArn = [{ prefix = "arn:${data.aws_partition.current.partition}:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task-definition/${local.application_family}:" }] + stopCode = [{ "anything-but" = "ServiceSchedulerInitiated" }] + } + }) +} + +resource "aws_cloudwatch_event_target" "application_task_failed" { + rule = aws_cloudwatch_event_rule.application_task_failed.name + target_id = "operations" + arn = aws_sns_topic.operations.arn +} + +resource "aws_cloudwatch_event_rule" "migration_failed" { + name = "${local.name_prefix}-migration-failed" + description = "Report nonzero exits from the one-off migration task." + + event_pattern = jsonencode({ + source = ["aws.ecs"] + "detail-type" = ["ECS Task State Change"] + detail = { + clusterArn = [aws_ecs_cluster.main.arn] + lastStatus = ["STOPPED"] + taskDefinitionArn = [{ prefix = "arn:${data.aws_partition.current.partition}:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task-definition/${local.migration_family}:" }] + containers = { + exitCode = [{ "anything-but" = 0 }] + } + } + }) +} + +resource "aws_cloudwatch_event_target" "migration_failed" { + rule = aws_cloudwatch_event_rule.migration_failed.name + target_id = "operations" + arn = aws_sns_topic.operations.arn +} + +resource "aws_cloudwatch_dashboard" "main" { + dashboard_name = "${local.name_prefix}-operations" + + dashboard_body = jsonencode({ + widgets = concat( + [ + { + type = "metric" + x = 0 + y = 0 + width = 12 + height = 6 + properties = { + title = "ECS CPU and memory" + region = var.aws_region + metrics = [ + ["AWS/ECS", "CPUUtilization", "ClusterName", aws_ecs_cluster.main.name, "ServiceName", aws_ecs_service.app.name], + [".", "MemoryUtilization", ".", ".", ".", "."], + ] + } + }, + { + type = "metric" + x = 12 + y = 0 + width = 12 + height = 6 + properties = { + title = "RDS health" + region = var.aws_region + metrics = [ + ["AWS/RDS", "CPUUtilization", "DBInstanceIdentifier", aws_db_instance.main.identifier], + [".", "DatabaseConnections", ".", "."], + [".", "FreeStorageSpace", ".", ".", { yAxis = "right" }], + ] + } + }, + ], + var.demo_enabled ? [ + { + type = "metric" + x = 0 + y = 6 + width = 24 + height = 6 + properties = { + title = "Authenticated ingress" + region = var.aws_region + metrics = [ + ["AWS/ApplicationELB", "HTTPCode_ELB_5XX_Count", "LoadBalancer", aws_lb.app[0].arn_suffix], + [".", "TargetResponseTime", ".", ".", { stat = "p95" }], + [".", "UnHealthyHostCount", ".", ".", "TargetGroup", aws_lb_target_group.web.arn_suffix], + ] + } + }, + ] : [], + ) + }) +} + +resource "aws_budgets_budget" "monthly" { + name = "${local.name_prefix}-monthly" + budget_type = "COST" + limit_amount = "20" + limit_unit = "USD" + time_unit = "MONTHLY" + + cost_filter { + name = "TagKeyValue" + values = ["user:Application$ChangeOps"] + } + + dynamic "notification" { + for_each = var.alert_email == null ? [] : [50, 80, 100] + + content { + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = [var.alert_email] + } + } +} diff --git a/infra/terraform/environments/demo/outputs.tf b/infra/terraform/environments/demo/outputs.tf new file mode 100644 index 0000000..7b6a999 --- /dev/null +++ b/infra/terraform/environments/demo/outputs.tf @@ -0,0 +1,184 @@ +output "aws_account_id" { + description = "AWS account receiving the ChangeOps resources." + value = data.aws_caller_identity.current.account_id +} + +output "aws_region" { + description = "AWS Region selected by ADR-0022." + value = var.aws_region +} + +output "hosted_zone_id" { + description = "Public Route 53 hosted zone containing certificate and application records." + value = local.hosted_zone_id +} + +output "hosted_zone_name_servers" { + description = "Delegate these name servers at the registrar when Terraform created the hosted zone." + value = var.existing_hosted_zone_id == null ? aws_route53_zone.app[0].name_servers : [] +} + +output "application_url" { + description = "Public URL only when the declared demo window is enabled." + value = var.demo_enabled ? "https://${var.application_hostname}" : null +} + +output "application_hostname" { + description = "Stable public hostname used by ACM, Cognito callbacks, and the demo alias." + value = var.application_hostname +} + +output "certificate_arn" { + description = "Validated regional ACM certificate." + value = aws_acm_certificate_validation.app.certificate_arn +} + +output "vpc_id" { + description = "ChangeOps VPC." + value = aws_vpc.main.id +} + +output "public_subnet_ids" { + description = "Two public subnets used by the ALB and outbound-only public Fargate task." + value = aws_subnet.public[*].id +} + +output "database_subnet_ids" { + description = "Two isolated subnets used only by the private RDS subnet group." + value = aws_subnet.database[*].id +} + +output "task_security_group_id" { + description = "Security group used by application, migration, and bootstrap Fargate tasks." + value = aws_security_group.task.id +} + +output "database_security_group_id" { + description = "Private RDS security group accepting PostgreSQL only from the task group." + value = aws_security_group.database.id +} + +output "database_subnet_group_name" { + description = "Named isolated subnet group required for controlled snapshot restore." + value = aws_db_subnet_group.main.name +} + +output "api_repository_url" { + description = "Immutable backend ECR repository URL." + value = aws_ecr_repository.api.repository_url +} + +output "web_repository_url" { + description = "Immutable frontend ECR repository URL." + value = aws_ecr_repository.web.repository_url +} + +output "ecs_cluster_name" { + description = "Named ECS cluster used by application and one-off tasks." + value = aws_ecs_cluster.main.name +} + +output "ecs_service_name" { + description = "Named one-task application ECS service." + value = aws_ecs_service.app.name +} + +output "application_task_definition_arn" { + description = "Reviewed two-container application task definition." + value = aws_ecs_task_definition.application.arn +} + +output "migration_task_definition_arn" { + description = "Separate one-off Alembic task definition." + value = aws_ecs_task_definition.migration.arn +} + +output "database_identifier" { + description = "Private RDS instance identifier." + value = aws_db_instance.main.identifier +} + +output "database_endpoint" { + description = "Non-secret private RDS hostname." + value = aws_db_instance.main.address +} + +output "database_port" { + description = "Private PostgreSQL port." + value = aws_db_instance.main.port +} + +output "database_admin_secret_arn" { + description = "AWS-managed break-glass RDS master secret; never injected into ECS." + value = aws_db_instance.main.master_user_secret[0].secret_arn +} + +output "secret_arns" { + description = "Named empty secret containers whose values are populated outside Terraform." + value = local.secret_arns +} + +output "cognito_user_pool_id" { + description = "Closed Cognito user pool." + value = aws_cognito_user_pool.app.id +} + +output "cognito_user_pool_arn" { + description = "Closed Cognito user pool ARN used by authenticated ALB listeners." + value = aws_cognito_user_pool.app.arn +} + +output "cognito_user_pool_client_id" { + description = "Non-secret Cognito client ID used by ALB and Next.js claim validation." + value = aws_cognito_user_pool_client.alb.id +} + +output "cognito_user_pool_domain" { + description = "Cognito managed sign-in prefix used by ALB authentication." + value = aws_cognito_user_pool_domain.app.domain +} + +output "cognito_reviewer_group" { + description = "Closed reviewer group name." + value = aws_cognito_user_group.reviewer.name +} + +output "cognito_admin_group" { + description = "Closed admin group name." + value = aws_cognito_user_group.admin.name +} + +output "github_deploy_role_arn" { + description = "GitHub OIDC role scoped to image push, migration, and ECS deployment." + value = aws_iam_role.github_deploy.arn +} + +output "github_lifecycle_role_arn" { + description = "Separate GitHub OIDC role for explicitly approved startup and teardown." + value = aws_iam_role.github_lifecycle.arn +} + +output "operations_topic_arn" { + description = "SNS topic for operational alarms." + value = aws_sns_topic.operations.arn +} + +output "target_group_arn" { + description = "Retained Next.js target group used when authenticated ingress is enabled." + value = aws_lb_target_group.web.arn +} + +output "https_listener_arn" { + description = "HTTPS listener ARN used by the declared demo ingress." + value = var.demo_enabled ? aws_lb_listener.https[0].arn : null +} + +output "alb_signer_arn" { + description = "Load balancer ARN expected in the signed x-amzn-oidc-data signer claim." + value = var.demo_enabled ? aws_lb.app[0].arn : null +} + +output "cloudwatch_dashboard_name" { + description = "CloudWatch operations dashboard." + value = aws_cloudwatch_dashboard.main.dashboard_name +} diff --git a/infra/terraform/environments/demo/registry.tf b/infra/terraform/environments/demo/registry.tf new file mode 100644 index 0000000..f2d2143 --- /dev/null +++ b/infra/terraform/environments/demo/registry.tf @@ -0,0 +1,48 @@ +resource "aws_ecr_repository" "api" { + name = "${local.name_prefix}-api" + image_tag_mutability = "IMMUTABLE" + + encryption_configuration { + encryption_type = "AES256" + } + + image_scanning_configuration { + scan_on_push = true + } +} + +resource "aws_ecr_repository" "web" { + name = "${local.name_prefix}-web" + image_tag_mutability = "IMMUTABLE" + + encryption_configuration { + encryption_type = "AES256" + } + + image_scanning_configuration { + scan_on_push = true + } +} + +resource "aws_ecr_lifecycle_policy" "api" { + repository = aws_ecr_repository.api.name + policy = jsonencode({ + rules = [{ + rulePriority = 1 + description = "Retain the newest 20 immutable images" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 20 + } + action = { + type = "expire" + } + }] + }) +} + +resource "aws_ecr_lifecycle_policy" "web" { + repository = aws_ecr_repository.web.name + policy = aws_ecr_lifecycle_policy.api.policy +} diff --git a/infra/terraform/environments/demo/runtime.tf b/infra/terraform/environments/demo/runtime.tf new file mode 100644 index 0000000..be0c377 --- /dev/null +++ b/infra/terraform/environments/demo/runtime.tf @@ -0,0 +1,367 @@ +resource "aws_cloudwatch_log_group" "api" { + name = "/changeops/${var.environment}/api" + retention_in_days = 30 +} + +resource "aws_cloudwatch_log_group" "web" { + name = "/changeops/${var.environment}/web" + retention_in_days = 30 +} + +resource "aws_cloudwatch_log_group" "migration" { + name = "/changeops/${var.environment}/migration" + retention_in_days = 30 +} + +resource "aws_cloudwatch_log_group" "rds_postgresql" { + name = "/aws/rds/instance/${local.database_identifier}/postgresql" + retention_in_days = 30 +} + +resource "aws_cloudwatch_log_group" "rds_upgrade" { + name = "/aws/rds/instance/${local.database_identifier}/upgrade" + retention_in_days = 30 +} + +resource "aws_ecs_cluster" "main" { + name = local.ecs_cluster_name + + setting { + name = "containerInsights" + value = "enabled" + } +} + +locals { + api_environment = [ + { + name = "APP_NAME" + value = "ChangeOps" + }, + { + name = "AWS_REGION" + value = var.aws_region + }, + { + name = "DB_HOST" + value = aws_db_instance.main.address + }, + { + name = "DB_PORT" + value = tostring(aws_db_instance.main.port) + }, + { + name = "DB_NAME" + value = var.database_name + }, + { + name = "DB_SSLMODE" + value = "require" + }, + { + name = "EXTRACTION_MODEL_PROVIDER" + value = "openai" + }, + { + name = "EXTRACTION_MODEL" + value = "gpt-5.6-luna" + }, + { + name = "EXTRACTION_MODEL_TIMEOUT_SECONDS" + value = "120" + }, + { + name = "EXTRACTION_MODEL_MAX_RETRIES" + value = "0" + }, + { + name = "INTERPRETATION_MODEL_PROVIDER" + value = "openai" + }, + { + name = "INTERPRETATION_MODEL" + value = "gpt-5.6-luna" + }, + { + name = "INTERPRETATION_MODEL_TIMEOUT_SECONDS" + value = "120" + }, + { + name = "INTERPRETATION_MODEL_MAX_RETRIES" + value = "0" + }, + { + name = "JIRA_BASE_URL" + value = var.enable_jira ? var.jira_base_url : "" + }, + { + name = "JIRA_PROJECT_ID_OR_KEY" + value = var.enable_jira ? var.jira_project_id_or_key : "" + }, + { + name = "JIRA_ISSUE_TYPE_ID" + value = var.enable_jira ? var.jira_issue_type_id : "" + }, + { + name = "CONFLUENCE_BASE_URL" + value = var.enable_confluence ? var.confluence_base_url : "" + }, + { + name = "CONFLUENCE_MANAGER_TRAVEL_APPROVAL_GUIDE_PAGE_ID" + value = var.enable_confluence ? var.confluence_manager_guide_page_id : "" + }, + ] + + api_secrets = concat( + [ + { + name = "DB_USERNAME" + valueFrom = "${aws_secretsmanager_secret.runtime_db.arn}:username::" + }, + { + name = "DB_PASSWORD" + valueFrom = "${aws_secretsmanager_secret.runtime_db.arn}:password::" + }, + { + name = "OPENAI_API_KEY" + valueFrom = "${aws_secretsmanager_secret.model_provider.arn}:api_key::" + }, + ], + var.enable_jira ? [ + { + name = "JIRA_EMAIL" + valueFrom = "${aws_secretsmanager_secret.jira.arn}:email::" + }, + { + name = "JIRA_API_TOKEN" + valueFrom = "${aws_secretsmanager_secret.jira.arn}:api_token::" + }, + ] : [], + var.enable_confluence ? [ + { + name = "CONFLUENCE_EMAIL" + valueFrom = "${aws_secretsmanager_secret.confluence.arn}:email::" + }, + { + name = "CONFLUENCE_API_TOKEN" + valueFrom = "${aws_secretsmanager_secret.confluence.arn}:api_token::" + }, + ] : [], + ) + + web_environment = [ + { + name = "CHANGEOPS_API_BASE_URL" + value = "http://127.0.0.1:8000" + }, + { + name = "AWS_REGION" + value = var.aws_region + }, + { + name = "ALB_EXPECTED_SIGNER_ARN" + value = try(aws_lb.app[0].arn, "disabled") + }, + { + name = "COGNITO_USER_POOL_ID" + value = aws_cognito_user_pool.app.id + }, + { + name = "COGNITO_CLIENT_ID" + value = aws_cognito_user_pool_client.alb.id + }, + ] + + migration_secrets = [ + { + name = "DB_USERNAME" + valueFrom = "${aws_secretsmanager_secret.migration_db.arn}:username::" + }, + { + name = "DB_PASSWORD" + valueFrom = "${aws_secretsmanager_secret.migration_db.arn}:password::" + }, + ] +} + +resource "aws_ecs_task_definition" "application" { + family = local.application_family + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = 1024 + memory = 2048 + execution_role_arn = aws_iam_role.application_execution.arn + task_role_arn = aws_iam_role.application_task.arn + + container_definitions = jsonencode([ + { + name = "api" + image = "${aws_ecr_repository.api.repository_url}:${var.container_image_tag}" + essential = true + cpu = 768 + memoryReservation = 1280 + portMappings = [{ + name = "api" + containerPort = 8000 + hostPort = 8000 + protocol = "tcp" + appProtocol = "http" + }] + environment = local.api_environment + secrets = local.api_secrets + healthCheck = { + command = ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 30 + } + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.api.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "api" + } + } + }, + { + name = "web" + image = "${aws_ecr_repository.web.repository_url}:${var.container_image_tag}" + essential = true + cpu = 256 + memoryReservation = 512 + portMappings = [{ + name = "web" + containerPort = 3000 + hostPort = 3000 + protocol = "tcp" + appProtocol = "http" + }] + dependsOn = [{ + containerName = "api" + condition = "HEALTHY" + }] + environment = local.web_environment + healthCheck = { + command = ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000${var.web_health_check_path} || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 30 + } + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.web.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "web" + } + } + }, + ]) + + runtime_platform { + cpu_architecture = "X86_64" + operating_system_family = "LINUX" + } + + tags = { + Name = local.application_family + } +} + +resource "aws_ecs_task_definition" "migration" { + family = local.migration_family + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = 512 + memory = 1024 + execution_role_arn = aws_iam_role.migration_execution.arn + task_role_arn = aws_iam_role.migration_task.arn + + container_definitions = jsonencode([ + { + name = "migration" + image = "${aws_ecr_repository.api.repository_url}:${var.container_image_tag}" + essential = true + command = ["alembic", "upgrade", "head"] + environment = [ + { + name = "DB_HOST" + value = aws_db_instance.main.address + }, + { + name = "DB_PORT" + value = tostring(aws_db_instance.main.port) + }, + { + name = "DB_NAME" + value = var.database_name + }, + { + name = "DB_SSLMODE" + value = "require" + }, + ] + secrets = local.migration_secrets + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.migration.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "migration" + } + } + }, + ]) + + runtime_platform { + cpu_architecture = "X86_64" + operating_system_family = "LINUX" + } + + tags = { + Name = local.migration_family + } +} + +resource "aws_ecs_service" "app" { + name = local.ecs_service_name + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.application.arn + desired_count = var.demo_enabled ? 1 : 0 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 100 + deployment_maximum_percent = 200 + health_check_grace_period_seconds = var.demo_enabled ? 60 : null + enable_execute_command = false + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = aws_subnet.public[*].id + security_groups = [aws_security_group.task.id] + assign_public_ip = true + } + + dynamic "load_balancer" { + for_each = var.demo_enabled ? [1] : [] + + content { + target_group_arn = aws_lb_target_group.web.arn + container_name = "web" + container_port = 3000 + } + } + + depends_on = [aws_lb_listener.https] + + tags = { + Name = local.ecs_service_name + } +} diff --git a/infra/terraform/environments/demo/secrets.tf b/infra/terraform/environments/demo/secrets.tf new file mode 100644 index 0000000..0073726 --- /dev/null +++ b/infra/terraform/environments/demo/secrets.tf @@ -0,0 +1,29 @@ +resource "aws_secretsmanager_secret" "runtime_db" { + name = "${local.name_prefix}/database/runtime" + description = "Least-privilege ChangeOps runtime database username and password" + recovery_window_in_days = 7 +} + +resource "aws_secretsmanager_secret" "migration_db" { + name = "${local.name_prefix}/database/migration" + description = "Schema-owner username and password for the one-off Alembic task" + recovery_window_in_days = 7 +} + +resource "aws_secretsmanager_secret" "model_provider" { + name = "${local.name_prefix}/provider/model" + description = "Model-provider API key used only by the FastAPI container" + recovery_window_in_days = 7 +} + +resource "aws_secretsmanager_secret" "jira" { + name = "${local.name_prefix}/provider/jira" + description = "Dedicated Jira email and API token; Jira remains disabled until populated" + recovery_window_in_days = 7 +} + +resource "aws_secretsmanager_secret" "confluence" { + name = "${local.name_prefix}/provider/confluence" + description = "Dedicated Confluence email and API token; refresh remains disabled until populated" + recovery_window_in_days = 7 +} diff --git a/infra/terraform/environments/demo/terraform.tfvars.example b/infra/terraform/environments/demo/terraform.tfvars.example new file mode 100644 index 0000000..accaf22 --- /dev/null +++ b/infra/terraform/environments/demo/terraform.tfvars.example @@ -0,0 +1,22 @@ +hosted_zone_name = "example.com" +application_hostname = "changeops.example.com" + +# Use an existing public Route 53 zone instead of creating one: +# existing_hosted_zone_id = "Z0123456789EXAMPLE" + +github_repository = "jaydreyer/ChangeOps" +github_environment = "production" +alert_email = "operator@example.com" + +# The safe normal state: no ALB, no application alias, and ECS desired count zero. +demo_enabled = false + +# Set true only after every application prerequisite in ADR-0022 is implemented and tested. +infrastructure_prerequisites_confirmed = false + +# Use a full immutable Git commit SHA after images have been pushed by a later deployment workflow. +container_image_tag = "bootstrap" + +# Optional integrations remain disabled unless their dedicated secrets and all non-secret values exist. +enable_jira = false +enable_confluence = false diff --git a/infra/terraform/environments/demo/tests/architecture.tftest.hcl b/infra/terraform/environments/demo/tests/architecture.tftest.hcl new file mode 100644 index 0000000..1f819c1 --- /dev/null +++ b/infra/terraform/environments/demo/tests/architecture.tftest.hcl @@ -0,0 +1,209 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + arn = "arn:aws:iam::123456789012:user/terraform-test" + user_id = "terraform-test" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + dns_suffix = "amazonaws.com" + } + } + + mock_data "aws_region" { + defaults = { + name = "us-east-1" + } + } + + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b"] + } + } + +} + +override_resource { + target = aws_acm_certificate.app + values = { + arn = "arn:aws:acm:us-east-1:123456789012:certificate/00000000-0000-0000-0000-000000000000" + domain_validation_options = [{ + domain_name = "changeops.example.com" + resource_record_name = "_validation.changeops.example.com" + resource_record_type = "CNAME" + resource_record_value = "_validation.acm-validations.aws" + }] + } +} + +override_resource { + target = aws_secretsmanager_secret.runtime_db + override_during = plan + values = { + arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:runtime-db" + } +} + +override_resource { + target = aws_secretsmanager_secret.migration_db + override_during = plan + values = { + arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:migration-db" + } +} + +override_resource { + target = aws_secretsmanager_secret.model_provider + override_during = plan + values = { + arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:model-provider" + } +} + +override_resource { + target = aws_lb.app + override_during = plan + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/changeops-demo-alb/0000000000000000" + } +} + +variables { + hosted_zone_name = "example.com" + application_hostname = "changeops.example.com" + alert_email = "operator@example.com" + infrastructure_prerequisites_confirmed = true +} + +run "adr_0022_off_state" { + command = plan + + assert { + condition = aws_ecs_service.app.desired_count == 0 + error_message = "The normal off state must run zero ECS tasks." + } + + assert { + condition = length(aws_lb.app) == 0 + error_message = "The normal off state must not retain the billable ALB." + } + + assert { + condition = length(aws_route53_record.app_ipv4) == 0 + error_message = "The normal off state must not publish the application alias." + } + + assert { + condition = aws_db_instance.main.publicly_accessible == false + error_message = "RDS must never be publicly accessible." + } + + assert { + condition = aws_db_instance.main.multi_az == false && aws_db_instance.main.instance_class == "db.t4g.micro" + error_message = "The database must retain the ADR's low-cost Single-AZ baseline." + } + + assert { + condition = aws_db_instance.main.deletion_protection == true + error_message = "RDS deletion protection must be enabled by default." + } + + assert { + condition = aws_ecs_task_definition.application.cpu == "1024" && aws_ecs_task_definition.application.memory == "2048" + error_message = "The application task must retain the approved 1 vCPU / 2 GiB size." + } + + assert { + condition = alltrue([ + for secret in local.api_secrets : + !strcontains(secret.valueFrom, aws_secretsmanager_secret.migration_db.arn) + ]) + error_message = "The long-running application must never receive the migration credential." + } + + assert { + condition = alltrue([ + for secret in local.migration_secrets : + strcontains(secret.valueFrom, aws_secretsmanager_secret.migration_db.arn) + ]) + error_message = "The migration task must receive only the migration database secret." + } +} + +run "declared_demo_window" { + command = plan + + variables { + demo_enabled = true + } + + assert { + condition = aws_ecs_service.app.desired_count == 1 + error_message = "A declared demo window must run exactly one ECS task." + } + + assert { + condition = length(aws_lb.app) == 1 + error_message = "A declared demo window must create exactly one public ALB." + } + + assert { + condition = length(aws_route53_record.app_ipv4) == 1 + error_message = "A declared demo window must publish the application alias." + } + + assert { + condition = aws_lb.app[0].idle_timeout == 180 + error_message = "The ALB idle timeout must exceed the bounded 120-second model timeout." + } + + assert { + condition = one([ + for item in local.web_environment : + item.value if item.name == "ALB_EXPECTED_SIGNER_ARN" + ]) == aws_lb.app[0].arn + error_message = "Next.js must verify the signed OIDC signer against the load balancer ARN." + } +} + +run "rejects_wrong_region" { + command = plan + + variables { + aws_region = "us-west-2" + } + + expect_failures = [var.aws_region] +} + +run "rejects_unconfirmed_database_destruction" { + command = plan + + variables { + allow_database_destroy = true + database_destroy_confirmation = "wrong-environment" + } + + expect_failures = [var.database_destroy_confirmation] +} + +run "rejects_unconfirmed_infrastructure_prerequisites" { + command = plan + + variables { + infrastructure_prerequisites_confirmed = false + } + + expect_failures = [var.infrastructure_prerequisites_confirmed] +} diff --git a/infra/terraform/environments/demo/variables.tf b/infra/terraform/environments/demo/variables.tf new file mode 100644 index 0000000..ba07205 --- /dev/null +++ b/infra/terraform/environments/demo/variables.tf @@ -0,0 +1,223 @@ +variable "aws_region" { + description = "AWS Region selected by ADR-0022." + type = string + default = "us-east-1" + + validation { + condition = var.aws_region == "us-east-1" + error_message = "ADR-0022 fixes the initial deployment Region to us-east-1." + } +} + +variable "environment" { + description = "Stable environment identifier used in names and authorization tags." + type = string + default = "demo" + + validation { + condition = can(regex("^[a-z][a-z0-9-]{1,15}$", var.environment)) + error_message = "environment must be 2-16 lowercase alphanumeric or hyphen characters." + } +} + +variable "hosted_zone_name" { + description = "Public DNS zone name, for example example.com." + type = string + + validation { + condition = can(regex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$", var.hosted_zone_name)) + error_message = "hosted_zone_name must be a lowercase public DNS name." + } +} + +variable "application_hostname" { + description = "Public ChangeOps hostname inside hosted_zone_name." + type = string + + validation { + condition = endswith(var.application_hostname, ".${var.hosted_zone_name}") && var.application_hostname != var.hosted_zone_name + error_message = "application_hostname must be a subdomain of hosted_zone_name." + } +} + +variable "existing_hosted_zone_id" { + description = "Existing public Route 53 hosted zone ID. Leave null to let this root create the zone." + type = string + default = null + nullable = true +} + +variable "github_repository" { + description = "GitHub owner/repository allowed to assume the AWS roles." + type = string + default = "jaydreyer/ChangeOps" + + validation { + condition = can(regex("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", var.github_repository)) + error_message = "github_repository must use owner/repository form." + } +} + +variable "github_environment" { + description = "Protected GitHub Environment required by the OIDC trust policies." + type = string + default = "production" + + validation { + condition = length(trimspace(var.github_environment)) > 0 + error_message = "github_environment must not be empty." + } +} + +variable "demo_enabled" { + description = "Create billable ingress and run one ECS task for a declared demo window." + type = bool + default = false +} + +variable "infrastructure_prerequisites_confirmed" { + description = "Must be true only after every ADR-0022 application prerequisite is implemented and tested." + type = bool + + validation { + condition = var.infrastructure_prerequisites_confirmed + error_message = "ADR-0022 prohibits AWS provisioning until its application prerequisites are implemented and tested." + } +} + +variable "alert_email" { + description = "Email for operational and budget notifications. Leave null to create no subscriptions." + type = string + default = null + nullable = true + + validation { + condition = var.alert_email == null || can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.alert_email)) + error_message = "alert_email must be null or a syntactically valid email address." + } +} + +variable "container_image_tag" { + description = "Immutable commit SHA image tag used for both repositories." + type = string + default = "bootstrap" + + validation { + condition = var.container_image_tag == "bootstrap" || can(regex("^[0-9a-f]{40}$", var.container_image_tag)) + error_message = "container_image_tag must be bootstrap or a full 40-character Git commit SHA." + } +} + +variable "vpc_cidr" { + description = "CIDR for the two-AZ ChangeOps VPC." + type = string + default = "10.44.0.0/16" + + validation { + condition = can(cidrhost(var.vpc_cidr, 0)) && split("/", var.vpc_cidr)[1] == "16" + error_message = "vpc_cidr must be a valid IPv4 /16." + } +} + +variable "database_name" { + description = "Initial PostgreSQL database name." + type = string + default = "changeops" + + validation { + condition = can(regex("^[a-z][a-z0-9_]{0,62}$", var.database_name)) + error_message = "database_name must be a valid lowercase PostgreSQL identifier." + } +} + +variable "database_admin_username" { + description = "Break-glass RDS master username. Its password is generated and managed by RDS." + type = string + default = "changeops_admin" + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9_]{0,62}$", var.database_admin_username)) + error_message = "database_admin_username must be a valid PostgreSQL identifier." + } +} + +variable "allow_database_destroy" { + description = "Explicitly disable RDS deletion protection for a separately reviewed teardown." + type = bool + default = false +} + +variable "database_destroy_confirmation" { + description = "Must exactly match environment when allow_database_destroy is true." + type = string + default = "" + + validation { + condition = !var.allow_database_destroy || var.database_destroy_confirmation == var.environment + error_message = "database_destroy_confirmation must exactly match environment when database destruction is allowed." + } +} + +variable "database_final_snapshot_identifier" { + description = "Final RDS snapshot identifier used only during intentional deletion; set a new unique value for each teardown." + type = string + default = "changeops-demo-final" + + validation { + condition = can(regex("^[a-zA-Z](?:[a-zA-Z0-9-]{0,253}[a-zA-Z0-9])?$", var.database_final_snapshot_identifier)) && !strcontains(var.database_final_snapshot_identifier, "--") + error_message = "database_final_snapshot_identifier must be a valid RDS snapshot identifier." + } +} + +variable "enable_jira" { + description = "Inject the Jira secret only after its complete dedicated value is populated." + type = bool + default = false +} + +variable "enable_confluence" { + description = "Inject the Confluence secret only after its complete dedicated value is populated." + type = bool + default = false +} + +variable "jira_base_url" { + description = "Non-secret Jira base URL." + type = string + default = "" +} + +variable "jira_project_id_or_key" { + description = "Non-secret Jira project identifier." + type = string + default = "" +} + +variable "jira_issue_type_id" { + description = "Non-secret Jira Task issue-type identifier." + type = string + default = "" +} + +variable "confluence_base_url" { + description = "Non-secret Confluence base URL." + type = string + default = "" +} + +variable "confluence_manager_guide_page_id" { + description = "Non-secret ID of the one permitted Confluence page." + type = string + default = "" +} + +variable "web_health_check_path" { + description = "Next.js health endpoint required before deployment." + type = string + default = "/healthz" + + validation { + condition = startswith(var.web_health_check_path, "/") + error_message = "web_health_check_path must begin with /." + } +} diff --git a/infra/terraform/environments/demo/versions.tf b/infra/terraform/environments/demo/versions.tf new file mode 100644 index 0000000..579e1c1 --- /dev/null +++ b/infra/terraform/environments/demo/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.10.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.33" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = local.common_tags + } +} + +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} +data "aws_region" "current" {} diff --git a/src/changeops/services/audit_timeline_projection_service.py b/src/changeops/services/audit_timeline_projection_service.py index 88c72f5..3a6de7c 100644 --- a/src/changeops/services/audit_timeline_projection_service.py +++ b/src/changeops/services/audit_timeline_projection_service.py @@ -104,9 +104,7 @@ def get_audit_timeline( ) ) change_plans = list( - session.scalars( - select(ChangePlan).where(ChangePlan.policy_analysis_run_id == run.id) - ) + session.scalars(select(ChangePlan).where(ChangePlan.policy_analysis_run_id == run.id)) ) attempt_ids = [attempt.id for attempt in attempts] comparisons = ( @@ -145,9 +143,7 @@ def get_audit_timeline( jira_issues: list[JiraIssue] = [] if assessment is not None: reviews = list( - session.scalars( - select(ActionReview).where(ActionReview.assessment_id == assessment.id) - ) + session.scalars(select(ActionReview).where(ActionReview.assessment_id == assessment.id)) ) review_ids = [review.id for review in reviews] if review_ids: @@ -161,9 +157,7 @@ def get_audit_timeline( if approval is not None: commands = list( session.scalars( - select(ExecutionCommand).where( - ExecutionCommand.approval_run_id == approval.id - ) + select(ExecutionCommand).where(ExecutionCommand.approval_run_id == approval.id) ) ) command_ids = [command.id for command in commands] @@ -277,11 +271,7 @@ def map_extraction_attempts( ) -> list[AuditTimelineEntry]: entries: list[AuditTimelineEntry] = [] for attempt in attempts: - href = ( - _anchor(run_href, "extraction-heading") - if linked_attempt_id == attempt.id - else None - ) + href = _anchor(run_href, "extraction-heading") if linked_attempt_id == attempt.id else None metadata = _compact_metadata( model_provider=attempt.model_provider, model_identifier=attempt.model_identifier, @@ -424,11 +414,7 @@ def map_interpretation_attempts( entries: list[AuditTimelineEntry] = [] linked_ids = linked_attempt_ids or set() for attempt in attempts: - href = ( - _anchor(run_href, "interpretation-heading") - if attempt.id in linked_ids - else None - ) + href = _anchor(run_href, "interpretation-heading") if attempt.id in linked_ids else None entries.append( _entry( key=f"policy_interpretation_attempt:{attempt.id}:attempted", @@ -632,9 +618,7 @@ def map_approval_run( key=f"action_approval_run:{approval.id}:{approval.status}", occurred_at=approval.completed_at or approval.updated_at, actor=AuditActorCategory.DETERMINISTIC_SYSTEM, - event=( - AuditEventType.APPROVAL_FAILED if failed else AuditEventType.APPROVAL_COMPLETED - ), + event=(AuditEventType.APPROVAL_FAILED if failed else AuditEventType.APPROVAL_COMPLETED), title="Approval failed" if failed else "Approval completed", description=( approval.failure_message diff --git a/src/changeops/services/demo_assessment_seed_service.py b/src/changeops/services/demo_assessment_seed_service.py index 003ef9a..767597e 100644 --- a/src/changeops/services/demo_assessment_seed_service.py +++ b/src/changeops/services/demo_assessment_seed_service.py @@ -22,9 +22,7 @@ DEMO_EXTRACTION_ATTEMPT_ID = uuid.UUID("8f4f647d-7f2d-4da8-8e02-77d5301f2003") DEMO_PROPOSED_POLICY_ANALYSIS_RUN_ID = uuid.UUID("8f4f647d-7f2d-4da8-8e02-77d5301f2011") DEMO_PROPOSED_ASSESSMENT_ID = uuid.UUID("8f4f647d-7f2d-4da8-8e02-77d5301f2012") -DEMO_PROPOSED_EXTRACTION_ATTEMPT_ID = uuid.UUID( - "8f4f647d-7f2d-4da8-8e02-77d5301f2013" -) +DEMO_PROPOSED_EXTRACTION_ATTEMPT_ID = uuid.UUID("8f4f647d-7f2d-4da8-8e02-77d5301f2013") def seed_demo_assessment(session: Session) -> uuid.UUID: @@ -183,9 +181,7 @@ def _provenance(policy: PolicyChange) -> list[dict[str, object]]: proposed = policy.id == PROPOSED_POLICY_CHANGE_ID effective = "Effective October 1, 2026" if proposed else "Effective September 1, 2026" workers = "employees" if proposed else "employees and contractors" - excluded = ( - "United States, Canada, and Mexico" if proposed else "United States and Canada" - ) + excluded = "United States, Canada, and Mexico" if proposed else "United States and Canada" exemption = ( "Travel booked before October 1, 2026 is exempt" if proposed diff --git a/tests/integration/test_audit_timeline_api.py b/tests/integration/test_audit_timeline_api.py index 6f071af..2e3cc3d 100644 --- a/tests/integration/test_audit_timeline_api.py +++ b/tests/integration/test_audit_timeline_api.py @@ -45,9 +45,9 @@ def test_completed_journey_returns_stable_ordered_read_projection(client) -> Non ) assert all(entry["artifact"]["artifact_id"] for entry in body["entries"]) extraction_entries = [ - entry for entry in body["entries"] if entry["artifact"]["artifact_type"] == ( - "policy_extraction_attempt" - ) + entry + for entry in body["entries"] + if entry["artifact"]["artifact_type"] == ("policy_extraction_attempt") ] assert {entry["actor_category"] for entry in extraction_entries} == { "ai_assisted", diff --git a/tests/unit/test_audit_timeline_projection.py b/tests/unit/test_audit_timeline_projection.py index 8cdd06e..a111b2c 100644 --- a/tests/unit/test_audit_timeline_projection.py +++ b/tests/unit/test_audit_timeline_projection.py @@ -169,9 +169,7 @@ def test_clarification_interpretation_and_change_plan_mappings_preserve_attempts assert answered.actor_category == AuditActorCategory.HUMAN assert any(entry.event_type == AuditEventType.INTERPRETATION_FAILED for entry in entries) assert any(entry.event_type == AuditEventType.CHANGE_PLAN_CREATED for entry in entries) - failed_entries = [ - entry for entry in entries if entry.artifact.artifact_id == str(failed.id) - ] + failed_entries = [entry for entry in entries if entry.artifact.artifact_id == str(failed.id)] assert all(entry.artifact.href is None for entry in failed_entries) @@ -270,9 +268,7 @@ def test_review_approval_command_execution_and_side_effect_mappings_are_attribut assert by_type[AuditEventType.EXECUTION_COMMAND_PREPARED].actor_category == ( AuditActorCategory.DETERMINISTIC_SYSTEM ) - assert by_type[AuditEventType.EXECUTION_REPLAY_PREVENTED].outcome == ( - AuditOutcome.PREVENTED - ) + assert by_type[AuditEventType.EXECUTION_REPLAY_PREVENTED].outcome == (AuditOutcome.PREVENTED) assert by_type[AuditEventType.EXECUTION_FAILED].outcome == AuditOutcome.FAILED assert by_type[AuditEventType.SIMULATED_SIDE_EFFECT_CREATED].actor_category == ( AuditActorCategory.EXTERNAL_SYSTEM @@ -282,8 +278,7 @@ def test_review_approval_command_execution_and_side_effect_mappings_are_attribut def test_decision_outcomes_distinguish_rejection_deferral_and_revision_request() -> None: reviews = [ - record(id=uuid.uuid4(), proposed_action_id=uuid.uuid4(), created_at=NOW) - for _ in range(4) + record(id=uuid.uuid4(), proposed_action_id=uuid.uuid4(), created_at=NOW) for _ in range(4) ] decisions = [ record( @@ -337,9 +332,7 @@ def test_comparison_and_delta_mappings_use_authoritative_lineage_and_detail_rout AuditEventType.COMPARISON_CREATED, AuditEventType.IMPACT_DELTA_CREATED, ] - assert all( - entry.artifact.href == f"/policy-comparisons/{comparison.id}" for entry in entries - ) + assert all(entry.artifact.href == f"/policy-comparisons/{comparison.id}" for entry in entries) def test_equal_timestamp_order_uses_causal_precedence_then_artifact_identity() -> None: From f5fa3519ee67deecbea2a7047f77932d3d885397 Mon Sep 17 00:00:00 2001 From: Jay Dreyer Date: Thu, 6 Aug 2026 17:55:17 -0500 Subject: [PATCH 2/2] Allow manual quality verification --- .github/workflows/quality.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f263730..4cc2f37 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,6 +1,7 @@ name: Quality on: + workflow_dispatch: pull_request: push: branches: