Files
alva/infra/terraform/20-databas.tf
T
Claude 19796bec70 Terraform blir enda vägen, och backup ett obligatoriskt val
Kustomize- och Argo CD-vägen är borttagen. Den beskrev samma system en
gång till och kunde inte köras samtidigt som Terraform utan att de
motarbetade varandra — selfHeal återställde det Terraform ändrade och
prune tog bort det Terraform skapade. Kvar utanför Terraform är bara
postgres-init.sql, som läses av både definitionen och integrationstestet
så att schemat inte kan glida isär från det som testas.

Säkerhetskopieringen var en förhoppning: en StatefulSet med en volym och
ingen kopia. Går volymen förlorad är det inte "data" som försvinner utan
varje ärendes bevisvärde — vad som kontrollerades, av vem, när, med
vilken evidens — och det går inte att återskapa i efterhand.

databas_lage är därför ett obligatoriskt val utan standardvärde:

  extern    managerad Postgres, leverantörens backup och PITR
            (rekommenderat i produktion)
  cnpg      CloudNativePG i klustret: basbackup 02:30, kontinuerlig
            WAL-arkivering till objektlagring, PITR och failover
  inbyggd   en volym, ingen backup — spärras av en precondition när
            miljön är produktion

Preconditions fångar felkonfiguration vid plan i stället för vid drift:
extern utan anslutning, cnpg utan backupmål eller nycklar, inbyggd i
produktion.

Driftsättningen är nu två åtskilda flöden. Publicera bygger och taggar
bilderna vid varje main-push; Driftsätt startas för hand med en tagg mot
en GitHub-miljö som kan kräva godkännande, kör fmt/init/validate/plan/
apply, skriver ut kartan och rökkontrollerar hälsa och API-spec. En bild
i registret är inte samma sak som en bild som kör. Rollback är att köra
Driftsätt igen med en tidigare tagg. CI kör dessutom terraform validate
på varje PR — den kontroll jag inte kunde köra själv.

Två fel hittade vid egengranskning av definitionen: schemafilen delades
på semikolon, vilket hade klippt itu plpgsql-funktionen med
append-only-triggern (nu hela filen via postInitApplicationSQLRefs), och
null-satta fält i kubernetes_manifest utelämnas nu i stället.

Verifierat: 87 vitest-tester, typkontroll, eslint, OpenAPI-validering,
terraform fmt, statisk referenskontroll av modulen och integrationstest
mot riktig Postgres. terraform validate kunde inte köras här —
registry.terraform.io är blockerad av sessionens egress-policy, därav
CI-jobbet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-03 22:16:52 +00:00

370 lines
10 KiB
Terraform

# Händelseloggen — systemets enda sanningskälla.
#
# Append-only garanteras av triggers i schemat, inte av att API:t låter
# bli att skriva. Det gör historiken oantastlig även för den som har
# databasåtkomst med rätt roll.
#
# Tre lägen, valda med var.databas_lage. Skillnaden mellan dem är
# säkerhetskopiering, och det är därför variabeln saknar standardvärde:
#
# extern managerad Postgres — leverantören sköter backup och PITR
# cnpg CloudNativePG — basbackup + WAL-arkivering + failover
# inbyggd en volym, ingen backup — prov och demo
#
# Går loggen förlorad är det inte "data" som försvinner utan varje
# ärendes bevisvärde: vad som kontrollerades, av vem, när, med vilken
# evidens. Det går inte att återskapa i efterhand.
locals {
inbyggd_databas = var.databas_lage == "inbyggd"
cnpg_databas = var.databas_lage == "cnpg"
extern_databas = var.databas_lage == "extern"
# Vart plattformstjänsten ansluter. CloudNativePG skapar en tjänst som
# alltid pekar på den skrivbara instansen (-rw), så en failover kräver
# ingen ändring här.
databas_anslutning = (
local.extern_databas
? var.databas_url
: local.cnpg_databas
? "postgresql://plattform:$(POSTGRES_LOSENORD)@felsokning-db-rw:5432/felsokning"
: "postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning"
)
}
# Fångar felkonfiguration vid plan i stället för vid drift.
resource "terraform_data" "databaskontroll" {
lifecycle {
precondition {
condition = !local.extern_databas || var.databas_url != ""
error_message = "databas_lage = \"extern\" kräver databas_url."
}
precondition {
condition = !local.cnpg_databas || var.backup.mal != ""
error_message = "databas_lage = \"cnpg\" kräver backup.mal — poängen med läget är säkerhetskopieringen."
}
precondition {
condition = !local.cnpg_databas || (var.backup_nyckel_id != "" && var.backup_nyckel != "")
error_message = "databas_lage = \"cnpg\" kräver backup_nyckel_id och backup_nyckel."
}
precondition {
condition = !local.inbyggd_databas || var.miljo != "produktion"
error_message = "databas_lage = \"inbyggd\" saknar säkerhetskopiering och kan inte användas med miljo = \"produktion\". Välj \"extern\" eller \"cnpg\"."
}
}
}
# ---- Läge: cnpg ---------------------------------------------------------
#
# Kräver att CloudNativePG-operatorn redan är installerad i klustret —
# Terraform slår upp dess CRD vid plan. Installera först:
# kubectl apply --server-side -f \
# https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml
resource "kubernetes_secret_v1" "backup" {
count = local.cnpg_databas ? 1 : 0
metadata {
name = "felsokning-backup"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
type = "Opaque"
data = {
ACCESS_KEY_ID = var.backup_nyckel_id
ACCESS_SECRET_KEY = var.backup_nyckel
}
}
# Rollen och schemat skapas av operatorn vid initiering. Samma
# schemafil som integrationstestet kör, så de kan inte glida isär.
resource "kubernetes_secret_v1" "databas_konto" {
count = local.cnpg_databas ? 1 : 0
metadata {
name = "felsokning-db-konto"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
type = "kubernetes.io/basic-auth"
data = {
username = "plattform"
password = local.hemligheter["postgres-losenord"]
}
}
resource "kubernetes_manifest" "databas" {
count = local.cnpg_databas ? 1 : 0
manifest = {
apiVersion = "postgresql.cnpg.io/v1"
kind = "Cluster"
metadata = {
name = "felsokning-db"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec = {
instances = var.databas_instanser
imageName = "ghcr.io/cloudnative-pg/postgresql:17.2"
storage = merge(
{ size = var.databas_storlek },
var.lagringsklass != "" ? { storageClass = var.lagringsklass } : {},
)
bootstrap = {
initdb = {
database = "felsokning"
owner = "plattform"
secret = { name = kubernetes_secret_v1.databas_konto[0].metadata[0].name }
# Schemat körs som HEL fil, inte som uppdelade satser: det
# innehåller en plpgsql-funktion vars kropp har egna semikolon
# ($$ ... end $$), och en naiv uppdelning skulle klippa itu den.
postInitApplicationSQLRefs = {
configMapRefs = [{
name = kubernetes_config_map_v1.postgres_init[0].metadata[0].name
key = "init.sql"
}]
}
}
}
# Basbackup varje natt plus kontinuerlig WAL-arkivering. Det är
# WAL-arkivet som ger PITR — utan det är en nattlig kopia bara en
# nattlig kopia.
backup = {
barmanObjectStore = merge(
var.backup.endpoint != "" ? { endpointURL = var.backup.endpoint } : {},
{
destinationPath = var.backup.mal
s3Credentials = {
accessKeyId = {
name = kubernetes_secret_v1.backup[0].metadata[0].name
key = "ACCESS_KEY_ID"
}
secretAccessKey = {
name = kubernetes_secret_v1.backup[0].metadata[0].name
key = "ACCESS_SECRET_KEY"
}
}
wal = { compression = "gzip" }
data = { compression = "gzip" }
},
)
retentionPolicy = "${var.backup.behall_dagar}d"
}
resources = {
requests = { cpu = "250m", memory = "512Mi" }
limits = { cpu = "2", memory = "2Gi" }
}
# Sprid instanserna så att en nod som försvinner inte tar hela
# klustret med sig.
affinity = { enablePodAntiAffinity = true, topologyKey = "kubernetes.io/hostname" }
}
}
}
resource "kubernetes_manifest" "backup_schema" {
count = local.cnpg_databas ? 1 : 0
manifest = {
apiVersion = "postgresql.cnpg.io/v1"
kind = "ScheduledBackup"
metadata = {
name = "felsokning-db-natt"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec = {
# Sekund-fältet först (CNPG använder sex fält): 02:30 varje natt.
schedule = "0 30 2 * * *"
backupOwnerReference = "self"
cluster = { name = "felsokning-db" }
}
}
depends_on = [kubernetes_manifest.databas]
}
# ---- Läge: inbyggd ------------------------------------------------------
#
# En StatefulSet med en volym. Ingen säkerhetskopiering, ingen failover.
# Precondition ovan stoppar läget i produktionsmiljö.
resource "kubernetes_stateful_set_v1" "postgres" {
count = local.inbyggd_databas ? 1 : 0
metadata {
name = "postgres"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "postgres" })
}
spec {
service_name = "postgres"
replicas = 1
selector {
match_labels = { app = "postgres" }
}
template {
metadata {
labels = merge(local.etiketter, { app = "postgres" })
}
spec {
automount_service_account_token = false
security_context {
run_as_non_root = true
run_as_user = 70 # postgres i alpine-bilden
fs_group = 70
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "postgres"
image = local.tjanster.postgres.bild
port {
container_port = 5432
}
env {
name = "POSTGRES_DB"
value = "felsokning"
}
env {
name = "POSTGRES_USER"
value = "plattform"
}
env {
name = "POSTGRES_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "postgres-losenord"
}
}
}
env {
name = "PGDATA"
value = "/var/lib/postgresql/data/pgdata"
}
volume_mount {
name = "data"
mount_path = "/var/lib/postgresql/data"
}
volume_mount {
name = "init"
mount_path = "/docker-entrypoint-initdb.d"
}
resources {
requests = { cpu = "250m", memory = "512Mi" }
limits = { cpu = "2", memory = "2Gi" }
}
readiness_probe {
exec {
command = ["pg_isready", "-U", "plattform", "-d", "felsokning"]
}
initial_delay_seconds = 5
period_seconds = 10
}
# Startprob i stället för liveness under uppstart: en stor
# återställning får ta tid utan att poden dödas i loop.
startup_probe {
exec {
command = ["pg_isready", "-U", "plattform", "-d", "felsokning"]
}
period_seconds = 10
failure_threshold = 30
}
security_context {
allow_privilege_escalation = false
capabilities {
drop = ["ALL"]
}
}
}
volume {
name = "init"
config_map {
name = kubernetes_config_map_v1.postgres_init[0].metadata[0].name
}
}
}
}
volume_claim_template {
metadata {
name = "data"
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = var.lagringsklass != "" ? var.lagringsklass : null
resources {
requests = { storage = var.databas_storlek }
}
}
}
}
}
resource "kubernetes_service_v1" "postgres" {
count = local.inbyggd_databas ? 1 : 0
metadata {
name = "postgres"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "postgres" })
}
spec {
selector = { app = "postgres" }
port {
port = 5432
target_port = 5432
}
}
}