Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitallowed
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
103 changes: 103 additions & 0 deletions terraform/environments/aws/tests/postgres_version.tftest.hcl
Original file line number Diff line number Diff line change
@@ -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"
}
}
9 changes: 8 additions & 1 deletion terraform/environments/aws/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
# "<major>" or "<major>.<minor>"; 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 \"<major>\" or \"<major>.<minor>\" (e.g. \"16\"): the migrations use PostgreSQL 15+ features (UNIQUE NULLS NOT DISTINCT)."
}
}

variable "database_instance_class" {
Expand Down
58 changes: 58 additions & 0 deletions terraform/environments/azure/tests/postgres_version.tftest.hcl
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 3 additions & 3 deletions terraform/environments/azure/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}

Expand Down
61 changes: 61 additions & 0 deletions terraform/environments/gcp/tests/postgres_version.tftest.hcl
Original file line number Diff line number Diff line change
@@ -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"
}
}
7 changes: 6 additions & 1 deletion terraform/environments/gcp/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Loading