From 2308259b129f15a534091d17933b2d26bcbb1249 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 04:22:51 +0000 Subject: [PATCH 1/2] fix(iac): require PostgreSQL 15+ in every environment's version input The migrations need PostgreSQL 15 (000134 uses UNIQUE NULLS NOT DISTINCT), but the environment inputs still accepted older majors: Azure's postgres_version allowed 11-16, and AWS database_engine_version and GCP database_version had no validation. A custom deployment on an older engine would plan and apply, then fail at migration time. Validate all three at plan time: Azure "15"/"16", GCP POSTGRES_15/POSTGRES_16, and AWS "" or "." with a major of 15 or more (try() makes a malformed value a validation failure rather than an evaluation error). Every shipped tfvars pins 16, so no deployed environment changes. Add a mocked tftest per environment (old major rejected, 15 and 16 accepted), run by a new "Test environment inputs" step in the terraform-validate CI job, and state the minimum in docs/DEPLOYMENT.md. Closes #1233 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++ docs/DEPLOYMENT.md | 5 + .../aws/tests/postgres_version.tftest.hcl | 103 ++++++++++++++++++ terraform/environments/aws/variables.tf | 9 +- .../azure/tests/postgres_version.tftest.hcl | 58 ++++++++++ terraform/environments/azure/variables.tf | 6 +- .../gcp/tests/postgres_version.tftest.hcl | 61 +++++++++++ terraform/environments/gcp/variables.tf | 7 +- 8 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 terraform/environments/aws/tests/postgres_version.tftest.hcl create mode 100644 terraform/environments/azure/tests/postgres_version.tftest.hcl create mode 100644 terraform/environments/gcp/tests/postgres_version.tftest.hcl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8a230650..680b3b4ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -743,6 +743,16 @@ jobs: cd terraform/environments/${{ matrix.cloud }} terraform validate + # Environment input validation, e.g. the PostgreSQL 15+ floor the + # migrations need (#1233). Every provider is mocked. Skips a cloud + # until it has a tests dir. + - name: Test environment inputs + run: | + cd terraform/environments/${{ matrix.cloud }} + if [ -d tests ]; then + terraform test + fi + - name: Validate environment tfvars files run: | cd terraform/environments/${{ matrix.cloud }} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 446733524..5baffce56 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -58,6 +58,11 @@ Terraform automatically handles: Docker image build/push (via build module), fro - Terraform >= 1.6.0 - Go 1.26.9+ - Cloud CLI configured: `aws`, `az`, or `gcloud` +- PostgreSQL 15 or later. The migrations use PostgreSQL 15 features + (`UNIQUE NULLS NOT DISTINCT`), so each environment's version input rejects + an older major at plan time: `database_engine_version` (AWS, `"15"` or + later), `postgres_version` (Azure, `"15"` or `"16"`) and `database_version` + (GCP, `POSTGRES_15` or `POSTGRES_16`). Every shipped tfvars file pins 16. --- diff --git a/terraform/environments/aws/tests/postgres_version.tftest.hcl b/terraform/environments/aws/tests/postgres_version.tftest.hcl new file mode 100644 index 000000000..a24b73358 --- /dev/null +++ b/terraform/environments/aws/tests/postgres_version.tftest.hcl @@ -0,0 +1,103 @@ +# The migrations need PostgreSQL 15+ (000134 uses UNIQUE NULLS NOT DISTINCT), +# so an older engine must fail at plan time, not at migration time (#1233). +# +# Every provider is mocked, so nothing here reaches AWS. Each run targets +# module.database, the input's only consumer: the targeted graph still +# evaluates the variable's validation, without planning the whole +# environment against mock data. +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { account_id = "123456789012" } + } + + # The networking module indexes one subnet per availability zone. + mock_data "aws_availability_zones" { + defaults = { names = ["us-east-1a", "us-east-1b", "us-east-1c"] } + } +} + +mock_provider "random" {} +mock_provider "http" {} +mock_provider "external" {} + +variables { + admin_email = "admin@example.com" +} + +run "rejects_postgres_14" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_engine_version = "14" + } + + expect_failures = [var.database_engine_version] +} + +run "rejects_postgres_14_minor" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_engine_version = "14.12" + } + + expect_failures = [var.database_engine_version] +} + +run "rejects_malformed_version" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_engine_version = "sixteen" + } + + expect_failures = [var.database_engine_version] +} + +run "accepts_postgres_15" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_engine_version = "15" + } +} + +run "accepts_postgres_16" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_engine_version = "16" + } +} + +run "accepts_postgres_16_minor" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_engine_version = "16.6" + } +} diff --git a/terraform/environments/aws/variables.tf b/terraform/environments/aws/variables.tf index 1367045f8..0c0382aad 100644 --- a/terraform/environments/aws/variables.tf +++ b/terraform/environments/aws/variables.tf @@ -122,9 +122,16 @@ variable "database_username" { } variable "database_engine_version" { - description = "PostgreSQL major engine version (major-only pin, e.g. \"16\"). The AWS provider resolves to the latest minor release and stores it in engine_version_actual, suppressing drift when RDS auto-upgrades the minor version. Pinning a full minor version (e.g. \"16.6\") causes downgrade failures if RDS auto-upgrades beyond it (incident: 2026-07-16, #1372)." + description = "PostgreSQL major engine version (major-only pin, e.g. \"16\"). The AWS provider resolves to the latest minor release and stores it in engine_version_actual, suppressing drift when RDS auto-upgrades the minor version. Pinning a full minor version (e.g. \"16.6\") causes downgrade failures if RDS auto-upgrades beyond it (incident: 2026-07-16, #1372). The migrations need 15 or later (UNIQUE NULLS NOT DISTINCT, #1233)." type = string default = "16" + + validation { + # "" or "."; try() turns a malformed value into a + # validation failure instead of a tonumber() evaluation error. + condition = try(tonumber(regex("^([0-9]+)(\\.[0-9]+)?$", var.database_engine_version)[0]) >= 15, false) + error_message = "database_engine_version must be PostgreSQL 15 or later, as \"\" or \".\" (e.g. \"16\"): the migrations use PostgreSQL 15+ features (UNIQUE NULLS NOT DISTINCT)." + } } variable "database_instance_class" { diff --git a/terraform/environments/azure/tests/postgres_version.tftest.hcl b/terraform/environments/azure/tests/postgres_version.tftest.hcl new file mode 100644 index 000000000..4c31154c2 --- /dev/null +++ b/terraform/environments/azure/tests/postgres_version.tftest.hcl @@ -0,0 +1,58 @@ +# The migrations need PostgreSQL 15+ (000134 uses UNIQUE NULLS NOT DISTINCT), +# so an older engine must fail at plan time, not at migration time (#1233). +# +# Every provider is mocked, so nothing here reaches Azure. Each run targets +# module.database, the input's only consumer: the targeted graph still +# evaluates the variable's validation, without planning the whole +# environment against mock data. +mock_provider "azurerm" {} +mock_provider "azuread" {} +mock_provider "random" {} +mock_provider "kubernetes" {} +mock_provider "helm" {} +mock_provider "http" {} +mock_provider "null" {} +mock_provider "external" {} + +variables { + subscription_id = "00000000-0000-0000-0000-000000000000" + admin_email = "admin@example.com" +} + +run "rejects_postgres_14" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + postgres_version = "14" + } + + expect_failures = [var.postgres_version] +} + +run "accepts_postgres_15" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + postgres_version = "15" + } +} + +run "accepts_postgres_16" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + postgres_version = "16" + } +} diff --git a/terraform/environments/azure/variables.tf b/terraform/environments/azure/variables.tf index 1f199cd6d..d236aaa6b 100644 --- a/terraform/environments/azure/variables.tf +++ b/terraform/environments/azure/variables.tf @@ -206,13 +206,13 @@ variable "credential_encryption_key" { # ============================================== variable "postgres_version" { - description = "PostgreSQL version" + description = "PostgreSQL major version. The migrations need 15 or later (UNIQUE NULLS NOT DISTINCT, #1233)." type = string default = "16" validation { - condition = contains(["11", "12", "13", "14", "15", "16"], var.postgres_version) - error_message = "PostgreSQL version must be 11, 12, 13, 14, 15, or 16." + condition = contains(["15", "16"], var.postgres_version) + error_message = "PostgreSQL version must be 15 or 16: the migrations use PostgreSQL 15+ features (UNIQUE NULLS NOT DISTINCT)." } } diff --git a/terraform/environments/gcp/tests/postgres_version.tftest.hcl b/terraform/environments/gcp/tests/postgres_version.tftest.hcl new file mode 100644 index 000000000..b5463c156 --- /dev/null +++ b/terraform/environments/gcp/tests/postgres_version.tftest.hcl @@ -0,0 +1,61 @@ +# The migrations need PostgreSQL 15+ (000134 uses UNIQUE NULLS NOT DISTINCT), +# so an older engine must fail at plan time, not at migration time (#1233). +# +# Every provider is mocked, so nothing here reaches GCP. Each run targets +# module.database, the input's only consumer: the targeted graph still +# evaluates the variable's validation, without planning the whole +# environment against mock data. +mock_provider "google" {} +mock_provider "google-beta" {} +mock_provider "kubernetes" {} +mock_provider "helm" {} +mock_provider "http" {} +mock_provider "external" {} +mock_provider "random" {} + +variables { + project_id = "cudly-test-project" + admin_email = "admin@example.com" + + # A fixed placeholder key: the auto-generated one is unknown at plan time, + # and the secrets module counts on it. + credential_encryption_key = "0000000000000000000000000000000000000000000000000000000000000000" +} + +run "rejects_postgres_14" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_version = "POSTGRES_14" + } + + expect_failures = [var.database_version] +} + +run "accepts_postgres_15" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_version = "POSTGRES_15" + } +} + +run "accepts_postgres_16" { + command = plan + + plan_options { + target = [module.database] + } + + variables { + database_version = "POSTGRES_16" + } +} diff --git a/terraform/environments/gcp/variables.tf b/terraform/environments/gcp/variables.tf index 34ff88fec..6920f6371 100644 --- a/terraform/environments/gcp/variables.tf +++ b/terraform/environments/gcp/variables.tf @@ -109,9 +109,14 @@ variable "database_username" { } variable "database_version" { - description = "PostgreSQL version" + description = "Cloud SQL PostgreSQL version. The migrations need 15 or later (UNIQUE NULLS NOT DISTINCT, #1233)." type = string default = "POSTGRES_16" + + validation { + condition = contains(["POSTGRES_15", "POSTGRES_16"], var.database_version) + error_message = "database_version must be POSTGRES_15 or POSTGRES_16: the migrations use PostgreSQL 15+ features (UNIQUE NULLS NOT DISTINCT)." + } } variable "database_tier" { From e1638b7f4372811cd547b4a3f7604008b01cc1e5 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 06:02:00 +0000 Subject: [PATCH 2/2] test(iac): allowlist the mocked caller-identity account ID in the PG15 tftest The combined-tree git-secrets run flagged terraform/environments/aws/tests/postgres_version.tftest.hcl:10, a bare account_id = "123456789012" in the mocked aws_caller_identity. The repo allows that placeholder only on AccountID lines in _test.go files, so add an anchored whole-line entry for this one mock line (bracketed digits so the entry does not match itself), like the other terraform/ entries. Refs #1233 Co-Authored-By: Claude Opus 5.5 --- .gitallowed | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitallowed b/.gitallowed index c1aec0906..78b4c2c1a 100644 --- a/.gitallowed +++ b/.gitallowed @@ -94,6 +94,7 @@ ^terraform/modules/secrets/gcp/main\.tf:[0-9]+:[[:space:]]*secret_data[[:space:]]+=[[:space:]]+var\.sendgrid_api_ke[y][[:space:]]+!=[[:space:]]+null[[:space:]]+\?[[:space:]]+var\.sendgrid_api_ke[y][[:space:]]+:[[:space:]]+"PLACEHOLDER_REPLACE_ME"[[:space:]]*$ ^terraform/profiles/README\.md:[0-9]+:[[:space:]]*export[[:space:]]+TF_VAR_database_passwor[d]="secret123"[[:space:]]*$ ^terraform/profiles/README\.md:[0-9]+:[[:space:]]*database_passwor[d][[:space:]]+=[[:space:]]+"secret123"[[:space:]]*$ +^terraform/environments/aws/tests/postgres_version\.tftest\.hcl:[0-9]+:[[:space:]]*defaults[[:space:]]+=[[:space:]]+\{[[:space:]]+account_id[[:space:]]+=[[:space:]]+"123[4]567[8]901[2]"[[:space:]]+\}[[:space:]]*$ # One-off fixture lines a real scan flags (a synthetic placeholder account ID, # a throwaway test password, a credential-less test DSN). Each entry pins the