301c477e25
Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.
För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:
felsokning/app webbklienten (Vite, egen package.json och
eslint-/vitest-konfiguration)
felsokning/services plattformstjänsten och AI-orkestern
felsokning/infra Terraform och databasschemat
felsokning/docs vision, moduler, drift
felsokning/supabase edge-funktion och migrationer
Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.
Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.
CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.
Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
375 lines
10 KiB
Terraform
375 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 = var.bilage_lage != "s3" || (var.s3.endpoint != "" && var.s3.hink != "" && var.s3.region != "" && var.s3_nyckel_id != "" && var.s3_nyckel != "")
|
|
error_message = "bilage_lage = \"s3\" kräver s3.endpoint, s3.hink, s3.region, s3_nyckel_id och s3_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
|
|
}
|
|
}
|
|
}
|