diff --git a/terraform/README.md b/terraform/README.md index b6d4d37..62fc69a 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -79,6 +79,17 @@ and back it up somewhere private. `terraform.tfvars` is likewise gitignored; onl `terraform.tfvars.example` (dummy values) is committed. `.terraform.lock.hcl` **is** committed so provider versions are pinned across machines. +**Terraform now originates, not just carries, some of these secrets** +(`postgres_password`/`auth_secret`/`metrics_token`/`indexnow_key` — see `secrets.tf`): +left unset, each is generated by the `random` provider and its value lives *only* in +`terraform.tfstate` — there is no other copy until it's rendered to a host's +`/etc/thermograph.env`. More generated secrets landing in state makes the local-state +posture above matter more, not less: **never commit or sync `terraform.tfstate` in +cleartext**, and before this config is used from a shared machine, CI, or a remote +backend, stand up an encrypted backend (or SOPS-wrap the state) first — see the hop-1 +cutover runbook's hazard #15 and Track B step 7. Until then, treat `terraform.tfstate` +with the same care as the secrets it contains. + ## WARNING — applying against live prod `terraform apply` runs `remote-exec` **on the server**: it resets the checkout to the diff --git a/terraform/main.tf b/terraform/main.tf index 858dde1..d1dd85c 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -3,6 +3,32 @@ locals { # files here so a compose change re-triggers the remote deploy, and this is the # tree the host's checkout mirrors over git. repo_root = abspath("${path.root}/..") + + # Reusable size presets — a host can reference one by name (hosts..size) + # instead of hand-picking workers/app_cpus/db_cpus/db_memory separately. Mirrors + # the target Proxmox sizing-tier model (architecture doc §6: nano/small/medium/ + # large -> {cores, mem}) ahead of actually provisioning VMs — Proxmox itself is + # deferred; today these tiers just size the container caps on the existing + # SSH-managed VPS hosts. "large" matches the current 48 GB prod box's numbers. + sizes = { + nano = { workers = 1, app_cpus = 1, db_cpus = 1, db_memory = "1g" } + small = { workers = 4, app_cpus = 4, db_cpus = 2, db_memory = "8g" } + medium = { workers = 6, app_cpus = 6, db_cpus = 3, db_memory = "12g" } + large = { workers = 8, app_cpus = 8, db_cpus = 4, db_memory = "16g" } + } + + # Per-host resolved sizing: a named tier (host.size) wins when set; otherwise + # the host's own workers/app_cpus/db_cpus/db_memory fields (each individually + # defaulted in variables.tf) — so existing tfvars with explicit numbers are + # unaffected, and a tier is purely an opt-in shortcut. + host_sizing = { + for name, h in var.hosts : name => h.size != null ? local.sizes[h.size] : { + workers = h.workers + app_cpus = h.app_cpus + db_cpus = h.db_cpus + db_memory = h.db_memory + } + } } # One module instance per host. The module is entirely SSH-provisioner driven — it @@ -21,10 +47,10 @@ module "host" { domain = each.value.domain compose_files = each.value.compose_files app_dir = each.value.app_dir - workers = each.value.workers - app_cpus = each.value.app_cpus - db_cpus = each.value.db_cpus - db_memory = each.value.db_memory + workers = local.host_sizing[each.key].workers + app_cpus = local.host_sizing[each.key].app_cpus + db_cpus = local.host_sizing[each.key].db_cpus + db_memory = local.host_sizing[each.key].db_memory timescaledb_tag = each.value.timescaledb_tag openmeteo = each.value.openmeteo om_data_dir = each.value.om_data_dir @@ -39,9 +65,13 @@ module "host" { om_rclone_conf = var.om_rclone_conf om_vfs_cache_max = var.om_vfs_cache_max - # Shared secrets -> /etc/thermograph.env - postgres_password = var.postgres_password - auth_secret = var.auth_secret + # Shared secrets -> /etc/thermograph.env. postgres_password/auth_secret/ + # metrics_token/indexnow_key resolve through secrets.tf's locals (explicit + # var wins if supplied, else Terraform-generated) rather than the raw vars. + postgres_password = local.postgres_password + auth_secret = local.auth_secret + metrics_token = local.metrics_token + indexnow_key = local.indexnow_key vapid_private_key = var.vapid_private_key vapid_public_key = var.vapid_public_key vapid_contact = var.vapid_contact diff --git a/terraform/modules/thermograph-host/main.tf b/terraform/modules/thermograph-host/main.tf index 0973d68..8eedd29 100644 --- a/terraform/modules/thermograph-host/main.tf +++ b/terraform/modules/thermograph-host/main.tf @@ -26,6 +26,8 @@ locals { postgres_password = var.postgres_password database_url = local.database_url auth_secret = var.auth_secret + metrics_token = var.metrics_token + indexnow_key = var.indexnow_key workers = var.workers app_cpus = var.app_cpus db_cpus = var.db_cpus diff --git a/terraform/modules/thermograph-host/templates/thermograph.env.tftpl b/terraform/modules/thermograph-host/templates/thermograph.env.tftpl index c88de7e..076591c 100644 --- a/terraform/modules/thermograph-host/templates/thermograph.env.tftpl +++ b/terraform/modules/thermograph-host/templates/thermograph.env.tftpl @@ -39,6 +39,14 @@ THERMOGRAPH_COOKIE_SECURE=${cookie_secure} # Signs email-confirm / password-reset tokens; pinned so links survive restarts. THERMOGRAPH_AUTH_SECRET=${auth_secret} +# --- Ops metrics + IndexNow (Terraform-generated; see ../../secrets.tf) --------- +# Lets the ops dashboard authenticate remotely (over an SSH tunnel or otherwise) +# instead of requiring direct loopback access. +THERMOGRAPH_METRICS_TOKEN=${metrics_token} +# The IndexNow key, published at /{key}.txt as proof of ownership. Freely +# rotatable — nothing external depends on this specific value staying put. +THERMOGRAPH_INDEXNOW_KEY=${indexnow_key} + # --- Web Push (VAPID) ----------------------------------------------------------- # Pinned so existing browser subscriptions keep receiving across restarts. THERMOGRAPH_VAPID_PRIVATE_KEY=${vapid_private_key} diff --git a/terraform/modules/thermograph-host/variables.tf b/terraform/modules/thermograph-host/variables.tf index 15dd659..66b804f 100644 --- a/terraform/modules/thermograph-host/variables.tf +++ b/terraform/modules/thermograph-host/variables.tf @@ -129,6 +129,18 @@ variable "auth_secret" { sensitive = true } +variable "metrics_token" { + type = string + sensitive = true + default = "" +} + +variable "indexnow_key" { + type = string + sensitive = true + default = "" +} + variable "vapid_private_key" { type = string sensitive = true diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..29bf998 --- /dev/null +++ b/terraform/secrets.tf @@ -0,0 +1,65 @@ +# Terraform-generated internal secrets — values with no meaning outside this +# deployment, so Terraform owns generating them instead of requiring the operator +# to hand-generate and paste each one into terraform.tfvars. +# +# Every resource here is pinned with a static `keepers` value so `terraform +# apply` never regenerates a value already in use — regeneration would be a real +# incident (every session invalidated, the app<->DB password mismatched, a +# bookmarked dashboard token broken). Rotation is a deliberate edit to a +# resource's `keepers` value here, never a side effect of anything else +# changing. See docs/architecture/repo-topology-and-infrastructure.md §6. +# +# The corresponding variable (variables.tf) defaults to "" (Terraform +# generates); supply an explicit value only to seed an EXISTING live secret +# during a migration onto Terraform (hop-1 cutover runbook Stage 0) — +# `terraform plan` must show no change to the local below once the value +# matches what's already deployed. +# +# VAPID is deliberately NOT here: it's an EC keypair (var.vapid_private_key / +# vapid_public_key), generated once out-of-band and held as a stable REQUIRED +# input. Unlike an opaque token, there is no safe "Terraform regenerates it" +# story — regeneration breaks every existing push subscription outright. +# +# Generated values land in tfstate. tfstate must never be committed or synced in +# cleartext — stand up an encrypted backend (or keep these out of Terraform and +# supply them as pre-created Swarm secrets instead, per the hop-1 runbook's +# hazard #15) before relying on this in a shared or remote-state setup. + +resource "random_password" "postgres_password" { + length = 32 + special = false # lands raw (no URL-encoding) in THERMOGRAPH_DATABASE_URL + keepers = { + rotation = "1" + } +} + +resource "random_id" "auth_secret" { + byte_length = 32 # matches users.py's own secrets.token_urlsafe(32) fallback shape + keepers = { + rotation = "1" + } +} + +resource "random_password" "metrics_token" { + length = 32 + special = false # may travel as a query param (?token=...); stay URL-safe + keepers = { + rotation = "1" + } +} + +resource "random_id" "indexnow_key" { + byte_length = 16 # 32 hex chars, matching indexnow.py's own secrets.token_hex(16) + keepers = { + rotation = "1" + } +} + +locals { + # var.X wins when explicitly supplied (migration: seed the existing live + # value); otherwise the Terraform-generated one — see the file header. + postgres_password = var.postgres_password != "" ? var.postgres_password : random_password.postgres_password.result + auth_secret = var.auth_secret != "" ? var.auth_secret : random_id.auth_secret.b64_url + metrics_token = var.metrics_token != "" ? var.metrics_token : random_password.metrics_token.result + indexnow_key = var.indexnow_key != "" ? var.indexnow_key : random_id.indexnow_key.hex +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index f430468..b926535 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -19,12 +19,11 @@ hosts = { domain = "thermograph.org" # Caddy TLS in front, app on loopback compose_files = ["docker-compose.yml"] app_dir = "/opt/thermograph" - # Sized up for the big box — tune freely. The Postgres internal budget scales from - # db_memory automatically (deploy/db/init/20-tuning.sh); no separate tuning edit. - workers = 8 - app_cpus = 8 - db_cpus = 4 - db_memory = "16g" + # "large" is the named size tier for this box (locals.sizes in main.tf) — same + # numbers as hand-picking workers=8/app_cpus=8/db_cpus=4/db_memory="16g" below, + # via the shortcut. The Postgres internal budget scales from db_memory + # automatically (deploy/db/init/20-tuning.sh); no separate tuning edit. + size = "large" # Self-host the ERA5 archive here: layers docker-compose.openmeteo.yml and # provisions the rclone mount of the object-storage bucket (om_* vars below). openmeteo = true @@ -45,11 +44,28 @@ hosts = { domain = "" compose_files = ["docker-compose.yml"] app_dir = "/opt/thermograph" + # Explicit numbers, not a size tier — both styles work on any host; a tier is + # purely an opt-in shortcut (see prod's `size = "large"` above). workers = 4 app_cpus = 4 db_cpus = 2 db_memory = "8g" } + + # UAT: an ephemeral, single-node environment — same images/topology shape as + # prod, not prod's scale (design doc §6/§9). Uncomment once a UAT box exists; + # not managed until then. "nano" keeps it cheap since it's destroyed when idle. + # uat = { + # host = "REPLACE_WITH_UAT_VM_IP" + # ssh_user = "deploy" + # ssh_private_key_path = "~/.ssh/id_ed25519" + # role = "uat" + # git_branch = "release" + # domain = "" + # compose_files = ["docker-compose.yml"] + # app_dir = "/opt/thermograph" + # size = "nano" + # } } # Optional overrides (shown with their defaults): @@ -74,11 +90,20 @@ om_rclone_conf = <<-RCLONE RCLONE # --------------------------------------------------------------------------------- -# Shared secrets (DUMMY values — replace, and keep this file out of git) +# Shared secrets (keep this file out of git) # --------------------------------------------------------------------------------- -postgres_password = "REPLACE_WITH_A_STRONG_DB_PASSWORD" -# python -c "import secrets; print(secrets.token_urlsafe(48))" -auth_secret = "REPLACE_WITH_A_LONG_RANDOM_STRING" +# postgres_password / auth_secret / metrics_token / indexnow_key are OPTIONAL — +# left unset (or ""), Terraform generates and owns each one (see secrets.tf), +# pinned so `apply` never regenerates a value already in use. Uncomment and set +# one only to seed an EXISTING live secret when migrating onto Terraform (hop-1 +# cutover runbook Stage 0) — `terraform plan` must then show no change to it. +# postgres_password = "REPLACE_WITH_THE_EXISTING_LIVE_DB_PASSWORD" +# auth_secret = "REPLACE_WITH_THE_EXISTING_LIVE_AUTH_SECRET" +# metrics_token = "REPLACE_WITH_THE_EXISTING_LIVE_METRICS_TOKEN" +# indexnow_key = "REPLACE_WITH_THE_EXISTING_LIVE_INDEXNOW_KEY" + +# VAPID is NOT Terraform-generated — an EC keypair, generate once out-of-band and +# hold it as a stable input; regeneration breaks every existing push subscription. # cd backend && ../.venv/bin/python -c "import push,json;k=push._generate();print(k['private_key']);print(k['public_key'])" vapid_private_key = "REPLACE_WITH_VAPID_PRIVATE_KEY" vapid_public_key = "REPLACE_WITH_VAPID_PUBLIC_KEY" diff --git a/terraform/variables.tf b/terraform/variables.tf index e5e3182..79f853b 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -31,6 +31,11 @@ variable "hosts" { app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS) db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS) db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY) + # A named size tier (see locals.sizes in main.tf: nano/small/medium/large) — + # when set, overrides the four fields above with the tier's preset. Leave + # null (default) to keep hand-picking workers/app_cpus/db_cpus/db_memory, + # as prod/beta already do below. + size = optional(string, null) # The floating tag matches today's behavior everywhere until you pin it. Pin to # an exact minor (SELECT extversion FROM pg_extension WHERE extname='timescaledb' # on the live DB) before any host of this stack could ever replicate with @@ -88,16 +93,37 @@ variable "om_vfs_cache_max" { # These live in terraform.tfvars (gitignored) and land in local state — never commit # real values. Every host in this config shares the same secret set; split the config # if prod and dev must diverge on a credential. +# +# postgres_password / auth_secret / metrics_token / indexnow_key default to "" — +# Terraform generates and owns each one (see secrets.tf), pinned so `apply` never +# regenerates a live value. Supply an explicit value only to seed an EXISTING live +# secret during a migration onto Terraform (hop-1 cutover runbook Stage 0). variable "postgres_password" { - description = "PostgreSQL password (initializes the db container AND builds THERMOGRAPH_DATABASE_URL)." + description = "PostgreSQL password (initializes the db container AND builds THERMOGRAPH_DATABASE_URL). \"\" (default) => Terraform-generated." type = string sensitive = true + default = "" } variable "auth_secret" { - description = "THERMOGRAPH_AUTH_SECRET — signs email-confirm / password-reset tokens. Pin it." + description = "THERMOGRAPH_AUTH_SECRET — signs email-confirm / password-reset tokens. \"\" (default) => Terraform-generated." type = string sensitive = true + default = "" +} + +variable "metrics_token" { + description = "THERMOGRAPH_METRICS_TOKEN — optional remote-access token for the ops metrics endpoint. \"\" (default) => Terraform-generated." + type = string + sensitive = true + default = "" +} + +variable "indexnow_key" { + description = "THERMOGRAPH_INDEXNOW_KEY — IndexNow key, published at /{key}.txt. \"\" (default) => Terraform-generated." + type = string + sensitive = true + default = "" } variable "vapid_private_key" {