diff --git a/felsokning/infra/terraform/05-aws.tf b/felsokning/infra/terraform/05-aws.tf new file mode 100644 index 0000000..e012792 --- /dev/null +++ b/felsokning/infra/terraform/05-aws.tf @@ -0,0 +1,35 @@ +# Kopplingen till AWS-basen. +# +# Allt som basen redan bestämt läses här i stället för att upprepas: +# klustrets namn och certifikat, domänen, certifikatet, registret, +# rollerna, hinken och hemligheternas namn. Ändras något i basen slår +# det igenom här utan att en enda rad ändras. + +data "terraform_remote_state" "bas" { + backend = "s3" + + config = { + bucket = var.tillstandshink + key = "aws/terraform.tfstate" + region = var.region + } +} + +locals { + aws = data.terraform_remote_state.bas.outputs.for_arbetslast + + # På AWS är valen redan gjorda: databasen är Aurora med + # säkerhetskopiering och PITR, bilagorna ligger i S3. De inbyggda + # lägena finns kvar i koden för lokal provkörning, men här är de + # aldrig aktuella. + databas_lage = "extern" + bilage_lage = "s3" + + inbyggd_databas = false + cnpg_databas = false + extern_databas = true + + # Plattformstjänsten läser DATABASE_URL. Värdet kommer ur den + # synkade hemligheten, inte härifrån — se 30-plattform.tf. + databas_anslutning = "" # sätts via secretKeyRef +} diff --git a/felsokning/infra/terraform/10-namnrymd.tf b/felsokning/infra/terraform/10-namnrymd.tf index 0bdd395..05d6425 100644 --- a/felsokning/infra/terraform/10-namnrymd.tf +++ b/felsokning/infra/terraform/10-namnrymd.tf @@ -1,4 +1,12 @@ -# Namnrymden och den enda hemligheten. +# Namnrymden, tjänstekontot och hemligheterna. +# +# Hemligheterna bor i AWS Secrets Manager, inte här. External Secrets +# speglar dem in i klustret så att podarna kan läsa dem som vanliga +# miljövariabler — men sanningskällan är Secrets Manager, med rotation, +# revisionsspår per läsning och kryptering med egen KMS-nyckel. +# +# Terraform ser aldrig värdena, och det är själva poängen: en hemlighet +# som passerar Terraform hamnar i tillståndsfilen. resource "kubernetes_namespace_v1" "denna" { metadata { @@ -7,37 +15,101 @@ resource "kubernetes_namespace_v1" "denna" { } } -# En Secret, men varje tjänst monterar bara sina egna nycklar — se -# hemligt-fältet per tjänst i karta.tf. -# -# Vill ni hellre ha hemligheterna utanför Terraform-tillståndet: byt den -# här resursen mot ett ExternalSecret och peka manifesten på samma namn. -resource "kubernetes_secret_v1" "hemligheter" { +# Tjänstekontot plattformstjänsten kör som. IRSA-annoteringen binder det +# till rollen basen skapade — och rollen är i sin tur bunden till exakt +# det här kontot i den här namnrymden. Ingen annan pod kan anta den. +resource "kubernetes_service_account_v1" "plattform" { metadata { - name = "felsokning-hemligheter" + name = "plattform" namespace = kubernetes_namespace_v1.denna.metadata[0].name labels = local.etiketter - } - type = "Opaque" - data = local.hemligheter -} - -# Databasschemat körs vid databasens första start. Samma fil används av -# integrationstestet, så schemat kan aldrig glida isär från det som testas. -# I inbyggt läge körs schemat av postgres-bilden vid första starten, i -# cnpg-läget av operatorn via postInitApplicationSQLRefs. I externt läge -# ansvarar ni för att köra filen mot er databas. -resource "kubernetes_config_map_v1" "postgres_init" { - count = local.extern_databas ? 0 : 1 - - metadata { - name = "postgres-init" - namespace = kubernetes_namespace_v1.denna.metadata[0].name - labels = local.etiketter - } - - data = { - "init.sql" = file("${path.module}/../postgres-init.sql") + annotations = { + "eks.amazonaws.com/role-arn" = local.aws.plattform_roll + } } } + +# Var hemligheterna hämtas ifrån. Autentiseringen sker med +# tjänstekontots roll — inga nycklar att distribuera. +resource "kubernetes_manifest" "hemlighetskalla" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "SecretStore" + + metadata = { + name = "aws" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + } + + spec = { + provider = { + aws = { + service = "SecretsManager" + region = local.aws.region + + auth = { + jwt = { + serviceAccountRef = { + name = kubernetes_service_account_v1.plattform.metadata[0].name + } + } + } + } + } + } + } + + depends_on = [helm_release.external_secrets] +} + +# Speglingen. Namnen till vänster är miljövariablerna tjänsterna läser; +# till höger nycklarna i Secrets Manager. +resource "kubernetes_manifest" "hemligheter" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "ExternalSecret" + + metadata = { + name = "felsokning-hemligheter" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + } + + spec = { + # Roteras en hemlighet i Secrets Manager följer klustret efter inom + # en timme, utan att någon behöver göra något. + refreshInterval = "1h" + + secretStoreRef = { + name = "aws" + kind = "SecretStore" + } + + target = { + name = "felsokning-hemligheter" + creationPolicy = "Owner" + } + + data = [ + { + secretKey = "DATABASE_URL" + remoteRef = { key = local.aws.hemlighet_databas, property = "url" } + }, + { + secretKey = "ANTHROPIC_API_KEY" + remoteRef = { key = local.aws.hemlighet_app, property = "anthropic_api_key" } + }, + { + secretKey = "JWT_SECRET" + remoteRef = { key = local.aws.hemlighet_app, property = "jwt_secret" } + }, + { + secretKey = "INTEGRATION_NYCKEL" + remoteRef = { key = local.aws.hemlighet_app, property = "integration_nyckel" } + }, + ] + } + } + + depends_on = [kubernetes_manifest.hemlighetskalla] +} diff --git a/felsokning/infra/terraform/15-plattformstjanster.tf b/felsokning/infra/terraform/15-plattformstjanster.tf new file mode 100644 index 0000000..663d3e0 --- /dev/null +++ b/felsokning/infra/terraform/15-plattformstjanster.tf @@ -0,0 +1,80 @@ +# Det klustret behöver innan vår egen arbetslast är meningsfull. +# +# Tre stycken, och alla tre finns av ett skäl som är synligt i systemet: +# ALB-kontrollern gör Ingress till en riktig lastbalanserare +# External Secrets hämtar hemligheter ur Secrets Manager +# metrics-server autoskalningen har inget att skala på utan den + +resource "helm_release" "lastbalanserare" { + name = "aws-load-balancer-controller" + repository = "https://aws.github.io/eks-charts" + chart = "aws-load-balancer-controller" + version = "1.10.1" + namespace = "kube-system" + + set { + name = "clusterName" + value = local.aws.kluster + } + + set { + name = "serviceAccount.create" + value = "true" + } + + set { + name = "serviceAccount.name" + value = "aws-load-balancer-controller" + } + + # IRSA: kontrollern antar rollen basen skapade. Rollen är bunden till + # exakt det här tjänstekontot i den här namnrymden. + set { + name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn" + value = local.aws.lastbalanserar_roll + } + + set { + name = "region" + value = local.aws.region + } +} + +resource "helm_release" "external_secrets" { + name = "external-secrets" + repository = "https://charts.external-secrets.io" + chart = "external-secrets" + version = "0.10.7" + namespace = "external-secrets" + create_namespace = true + + set { + name = "installCRDs" + value = "true" + } +} + +resource "helm_release" "metrics" { + name = "metrics-server" + repository = "https://kubernetes-sigs.github.io/metrics-server/" + chart = "metrics-server" + version = "3.12.2" + namespace = "kube-system" +} + +# CloudWatch-agenten: containerloggar och Container Insights. Det är +# den som gör larmet på nodantal i basen meningsfullt — utan agenten +# finns mätvärdet inte. +resource "helm_release" "cloudwatch" { + name = "aws-cloudwatch-metrics" + repository = "https://aws.github.io/eks-charts" + chart = "aws-cloudwatch-metrics" + version = "0.0.11" + namespace = "amazon-cloudwatch" + create_namespace = true + + set { + name = "clusterName" + value = local.aws.kluster + } +} diff --git a/felsokning/infra/terraform/20-databas.tf b/felsokning/infra/terraform/20-databas.tf deleted file mode 100644 index dbd0708..0000000 --- a/felsokning/infra/terraform/20-databas.tf +++ /dev/null @@ -1,374 +0,0 @@ -# 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 - } - } -} diff --git a/felsokning/infra/terraform/30-plattform.tf b/felsokning/infra/terraform/30-plattform.tf index a11e6ec..2964d41 100644 --- a/felsokning/infra/terraform/30-plattform.tf +++ b/felsokning/infra/terraform/30-plattform.tf @@ -29,7 +29,8 @@ resource "kubernetes_deployment_v1" "plattform" { } spec { - automount_service_account_token = false + # Behöver token: IRSA hämtar AWS-uppgifter genom det. + service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name security_context { run_as_non_root = true @@ -47,58 +48,20 @@ resource "kubernetes_deployment_v1" "plattform" { container_port = local.port_container } - env { - name = "JWT_SECRET" - - value_from { - secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = "jwt-secret" - } + # Alla hemligheter kommer ur den spegling External Secrets + # gör från Secrets Manager. Ingen av dem finns i Terraforms + # tillstånd eller i något manifest. + env_from { + secret_ref { + name = "felsokning-hemligheter" } } - # Krypterar kundernas leverantörsuppgifter i vila. Saknas den - # sparas ingenting alls — tjänsten failar closed i stället för - # att lagra i klartext. - env { - name = "INTEGRATION_NYCKEL" - - value_from { - secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = "integration-nyckel" - } - } - } - - # Lösenordet expanderas in i DATABASE_URL nedan. I externt läge - # står hela anslutningen i variabeln och behövs inte här. - dynamic "env" { - for_each = local.extern_databas ? [] : [1] - - content { - name = "POSTGRES_LOSENORD" - - value_from { - secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = "postgres-losenord" - } - } - } - } - - env { - name = "DATABASE_URL" - value = local.databas_anslutning - } - # Klienten serveras från samma domän som API:t, så CORS behöver # inte vara öppet. env { name = "TILLATNA_URSPRUNG" - value = "https://${var.doman}" + value = "https://${local.aws.doman}" } env { @@ -111,44 +74,29 @@ resource "kubernetes_deployment_v1" "plattform" { value = var.tillat_interna_uppslag ? "true" : "false" } - # Bilagornas innehåll: i databasen eller i objektlagring. + # Bilagorna ligger i S3. Inga nycklar behövs — tjänsten + # signerar med tjänstekontots roll via IRSA. env { name = "BILAGE_LAGE" - value = var.bilage_lage + value = "s3" } - dynamic "env" { - for_each = var.bilage_lage == "s3" ? { - S3_ENDPOINT = var.s3.endpoint - S3_HINK = var.s3.hink - S3_REGION = var.s3.region - S3_PREFIX = var.s3.prefix - } : {} - - content { - name = env.key - value = env.value - } + env { + name = "S3_HINK" + value = local.aws.bilage_hink } - dynamic "env" { - for_each = var.bilage_lage == "s3" ? { - S3_NYCKEL_ID = "s3-nyckel-id" - S3_NYCKEL = "s3-nyckel" - } : {} - - content { - name = env.key - - value_from { - secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = env.value - } - } - } + env { + name = "S3_REGION" + value = local.aws.region } + env { + name = "S3_ENDPOINT" + value = "https://s3.${local.aws.region}.amazonaws.com" + } + + resources { requests = { cpu = "100m", memory = "128Mi" } limits = { cpu = "1", memory = "512Mi" } diff --git a/felsokning/infra/terraform/40-orkester.tf b/felsokning/infra/terraform/40-orkester.tf index 943bfe6..36dd98d 100644 --- a/felsokning/infra/terraform/40-orkester.tf +++ b/felsokning/infra/terraform/40-orkester.tf @@ -48,13 +48,17 @@ resource "kubernetes_deployment_v1" "orkester" { container_port = local.port_container } + # Claude-nyckeln och JWT-hemligheten kommer ur speglingen + # från Secrets Manager — samma secret som plattformen läser, + # men orkestern får bara de två nycklar den behöver genom att + # den inte känner till de andra namnen. env { name = "ANTHROPIC_API_KEY" value_from { secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = "anthropic-api-key" + name = "felsokning-hemligheter" + key = "ANTHROPIC_API_KEY" } } } @@ -64,8 +68,8 @@ resource "kubernetes_deployment_v1" "orkester" { value_from { secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = "jwt-secret" + name = "felsokning-hemligheter" + key = "JWT_SECRET" } } } diff --git a/felsokning/infra/terraform/60-ingress.tf b/felsokning/infra/terraform/60-ingress.tf index 094bc13..ed8c776 100644 --- a/felsokning/infra/terraform/60-ingress.tf +++ b/felsokning/infra/terraform/60-ingress.tf @@ -1,9 +1,11 @@ -# Trafiken utifrån och in. En domän, fyra prefix — se local.routing. +# Trafiken utifrån och in. +# +# En ALB, skapad av lastbalanserar-kontrollern ur det här objektet. +# Certifikatet kommer från ACM och terminerar på lastbalanseraren; all +# trafik mot :80 skickas vidare till :443. # # Kroppsgränsen är inte kosmetisk: teknikern dokumenterar med foto och -# video som följer med händelseloggen. Ingressens standardgräns (1 MB) -# skulle avvisa dokumentationen redan vid dörren, medan tjänsten själv -# accepterar 4 MB. Gränsen sätts därför uttryckligen och med marginal. +# video. En för snäv gräns avvisar dokumentationen redan vid dörren. resource "kubernetes_ingress_v1" "denna" { metadata { @@ -12,23 +14,33 @@ resource "kubernetes_ingress_v1" "denna" { labels = local.etiketter annotations = { - "cert-manager.io/cluster-issuer" = var.cert_issuer - "nginx.ingress.kubernetes.io/proxy-body-size" = "${var.max_kropp_mb}m" - "nginx.ingress.kubernetes.io/proxy-read-timeout" = "120" # AI-svar kan ta tid - "nginx.ingress.kubernetes.io/ssl-redirect" = "true" + "alb.ingress.kubernetes.io/scheme" = "internet-facing" + "alb.ingress.kubernetes.io/target-type" = "ip" + "alb.ingress.kubernetes.io/certificate-arn" = local.aws.certifikat_arn + "alb.ingress.kubernetes.io/listen-ports" = jsonencode([{ HTTP = 80 }, { HTTPS = 443 }]) + "alb.ingress.kubernetes.io/ssl-redirect" = "443" + "alb.ingress.kubernetes.io/ssl-policy" = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + # Hälsokontrollen går mot plattformen — den vet om databasen svarar. + "alb.ingress.kubernetes.io/healthcheck-path" = "/halsa" + + # AI-svar kan ta tid; standardgränsen på 60 s räcker inte. + "alb.ingress.kubernetes.io/load-balancer-attributes" = join(",", [ + "idle_timeout.timeout_seconds=180", + "routing.http.drop_invalid_header_fields.enabled=true", + "access_logs.s3.enabled=false", + ]) + + # Alla tre tjänsterna delar en lastbalanserare. + "alb.ingress.kubernetes.io/group.name" = local.namn } } spec { - ingress_class_name = var.ingress_klass - - tls { - hosts = [var.doman] - secret_name = "${local.namn}-tls" - } + ingress_class_name = "alb" rule { - host = var.doman + host = local.aws.doman http { dynamic "path" { @@ -54,8 +66,33 @@ resource "kubernetes_ingress_v1" "denna" { } depends_on = [ + helm_release.lastbalanserare, kubernetes_service_v1.web, kubernetes_service_v1.plattform, kubernetes_service_v1.orkester, ] } + +# DNS pekar på lastbalanseraren. Alias i stället för CNAME så att +# domänens rot kan användas om ni vill det senare. +resource "aws_route53_record" "denna" { + zone_id = local.aws.zon_id + name = local.aws.doman + type = "A" + + alias { + name = kubernetes_ingress_v1.denna.status[0].load_balancer[0].ingress[0].hostname + zone_id = data.aws_lb.denna.zone_id + evaluate_target_health = true + } +} + +data "aws_lb" "denna" { + tags = { + "elbv2.k8s.aws/cluster" = local.aws.kluster + "ingress.k8s.aws/stack" = local.namn + "ingress.k8s.aws/resource" = "LoadBalancer" + } + + depends_on = [kubernetes_ingress_v1.denna] +} diff --git a/felsokning/infra/terraform/70-natverk.tf b/felsokning/infra/terraform/70-natverk.tf index cb8a0e7..1d1e6b8 100644 --- a/felsokning/infra/terraform/70-natverk.tf +++ b/felsokning/infra/terraform/70-natverk.tf @@ -22,40 +22,6 @@ resource "kubernetes_network_policy_v1" "neka_allt_in" { } } -# Databasen: bara plattformstjänsten, bara 5432. Gäller det inbyggda -# läget; CloudNativePG-podarna märks av operatorn och täcks av regeln -# längre ned. -resource "kubernetes_network_policy_v1" "postgres_in" { - count = local.inbyggd_databas ? 1 : 0 - - metadata { - name = "postgres-endast-fran-plattform" - namespace = kubernetes_namespace_v1.denna.metadata[0].name - labels = local.etiketter - } - - spec { - pod_selector { - match_labels = { app = "postgres" } - } - - policy_types = ["Ingress"] - - ingress { - from { - pod_selector { - match_labels = { app = "plattform" } - } - } - - ports { - port = "5432" - protocol = "TCP" - } - } - } -} - # De tre webbtjänsterna: bara från ingress-kontrollern. resource "kubernetes_network_policy_v1" "tjanster_in" { for_each = toset(["web", "plattform", "ai-orkester"]) @@ -75,10 +41,10 @@ resource "kubernetes_network_policy_v1" "tjanster_in" { ingress { from { - namespace_selector { - match_labels = { - "kubernetes.io/metadata.name" = var.ingress_namnrymd - } + # ALB når podarna direkt på deras IP (target-type: ip), så + # trafiken kommer från VPC:ns adressrymd och inte från en pod. + ip_block { + cidr = "0.0.0.0/0" } } @@ -134,25 +100,6 @@ resource "kubernetes_network_policy_v1" "web_ut" { } } -# Databasen ringer heller ingenting. -resource "kubernetes_network_policy_v1" "postgres_ut" { - count = local.inbyggd_databas ? 1 : 0 - - metadata { - name = "postgres-inget-utgaende" - namespace = kubernetes_namespace_v1.denna.metadata[0].name - labels = local.etiketter - } - - spec { - pod_selector { - match_labels = { app = "postgres" } - } - - policy_types = ["Egress"] - } -} - # Plattformen: databasen internt, och HTTPS ut till kundernas # leverantörer. Klustrets och molnets interna adresser är undantagna — # samma gräns som koden själv upprätthåller (pekarInat), här en gång till @@ -171,23 +118,11 @@ resource "kubernetes_network_policy_v1" "plattform_ut" { policy_types = ["Egress"] - # Databasen i klustret — inbyggt eller CloudNativePG. I externt läge - # finns ingen databaspod att peka på; anslutningen går ut som vanlig - # utgående trafik nedan. - dynamic "egress" { - for_each = local.extern_databas ? [] : [1] - - content { - to { - pod_selector { - match_labels = local.inbyggd_databas ? { app = "postgres" } : { "cnpg.io/cluster" = "felsokning-db" } - } - } - - ports { - port = "5432" - protocol = "TCP" - } + # Aurora ligger utanför klustret, i ett eget subnätlager. + egress { + ports { + port = "5432" + protocol = "TCP" } } @@ -213,17 +148,6 @@ resource "kubernetes_network_policy_v1" "plattform_ut" { } } - # Managerad databas utanför klustret: 5432 mot leverantören. - dynamic "egress" { - for_each = local.extern_databas ? [1] : [] - - content { - ports { - port = "5432" - protocol = "TCP" - } - } - } } } diff --git a/felsokning/infra/terraform/90-gitea.tf b/felsokning/infra/terraform/90-gitea.tf new file mode 100644 index 0000000..9d2a577 --- /dev/null +++ b/felsokning/infra/terraform/90-gitea.tf @@ -0,0 +1,189 @@ +# Självhostad git med egna byggrunners. +# +# Med den här på finns inget externt beroende kvar i kedjan: källkoden, +# bygget, registret och driftsättningen ligger allt i vår egen +# AWS-miljö. +# +# Gitea Actions använder samma workflow-syntax som GitHub Actions, så +# .gitea/workflows/felsokning.yml är samma fil som tidigare låg under +# .github — bara flyttad och pekad på egna runners. +# +# Runnern kör byggen. Den har därför sitt eget tjänstekonto med en roll +# som bara får publicera till ECR, och ett separat konto för +# driftsättning som bara får röra klustret. Ett komprometterat bygge kan +# inte driftsätta. + +locals { + gitea_pa = var.gitea.aktiv +} + +resource "kubernetes_namespace_v1" "gitea" { + count = local.gitea_pa ? 1 : 0 + + metadata { + name = "gitea" + labels = local.etiketter + } +} + +# Byggkontot. Rollen skapades i AWS-basen och är bunden till exakt +# gitea:runner — inget annat tjänstekonto kan anta den. +resource "kubernetes_service_account_v1" "runner" { + count = local.gitea_pa ? 1 : 0 + + metadata { + name = "runner" + namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name + labels = local.etiketter + + annotations = { + "eks.amazonaws.com/role-arn" = data.terraform_remote_state.bas.outputs.karta.roller.bygg + } + } +} + +resource "kubernetes_service_account_v1" "drift" { + count = local.gitea_pa ? 1 : 0 + + metadata { + name = "drift" + namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name + labels = local.etiketter + + annotations = { + "eks.amazonaws.com/role-arn" = data.terraform_remote_state.bas.outputs.karta.roller.drift + } + } +} + +# Registreringstoken för runnern. Genereras här och delas mellan Gitea +# och runnern — de behöver komma överens utan att någon skriver in den +# för hand. +resource "random_password" "runner_token" { + count = local.gitea_pa ? 1 : 0 + + length = 40 + special = false +} + +resource "kubernetes_secret_v1" "runner" { + count = local.gitea_pa ? 1 : 0 + + metadata { + name = "runner-registrering" + namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name + } + + data = { + token = random_password.runner_token[0].result + } +} + +resource "helm_release" "gitea" { + count = local.gitea_pa ? 1 : 0 + + name = "gitea" + repository = "https://dl.gitea.com/charts/" + chart = "gitea" + version = "10.6.0" + namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name + + # Gitea kan använda en extern databas. Vi kör den inbyggda SQLite på en + # volym i stället: en verkstadsinstallation har en handfull utvecklare, + # och en extra Aurora-databas att säkerhetskopiera och övervaka är + # kostnad utan motsvarande nytta. Volymen ligger på EBS med + # ögonblicksbilder. + values = [yamlencode({ + redis-cluster = { enabled = false } + postgresql-ha = { enabled = false } + + persistence = { + enabled = true + size = var.gitea.lagring + storageClass = "gp2" + } + + gitea = { + config = { + server = { + DOMAIN = var.gitea.doman + ROOT_URL = "https://${var.gitea.doman}/" + SSH_DOMAIN = var.gitea.doman + } + database = { DB_TYPE = "sqlite3" } + # Ingen självregistrering: konton skapas av administratören. + service = { + DISABLE_REGISTRATION = true + REQUIRE_SIGNIN_VIEW = true + } + actions = { ENABLED = true } + # Bygg-loggarna hamnar i CloudWatch via containerloggarna. + log = { LEVEL = "Info" } + } + } + + # Runnern kör i samma namnrymd med byggkontot. + actions = { + enabled = true + statefulset = { + replicas = var.gitea.runners + serviceAccountName = kubernetes_service_account_v1.runner[0].metadata[0].name + } + existingSecret = kubernetes_secret_v1.runner[0].metadata[0].name + existingSecretKey = "token" + } + })] + + depends_on = [ + helm_release.external_secrets, + kubernetes_secret_v1.runner, + ] +} + +# Gitea får sin egen väg in genom samma lastbalanserare — grupp-namnet +# är detsamma, så ALB:n delas i stället för att en till skapas. +resource "kubernetes_ingress_v1" "gitea" { + count = local.gitea_pa && var.gitea.doman != "" ? 1 : 0 + + metadata { + name = "gitea" + namespace = kubernetes_namespace_v1.gitea[0].metadata[0].name + labels = local.etiketter + + annotations = { + "alb.ingress.kubernetes.io/scheme" = "internet-facing" + "alb.ingress.kubernetes.io/target-type" = "ip" + "alb.ingress.kubernetes.io/certificate-arn" = local.aws.certifikat_arn + "alb.ingress.kubernetes.io/listen-ports" = jsonencode([{ HTTP = 80 }, { HTTPS = 443 }]) + "alb.ingress.kubernetes.io/ssl-redirect" = "443" + "alb.ingress.kubernetes.io/group.name" = local.namn + } + } + + spec { + ingress_class_name = "alb" + + rule { + host = var.gitea.doman + + http { + path { + path = "/" + path_type = "Prefix" + + backend { + service { + name = "gitea-http" + + port { + number = 3000 + } + } + } + } + } + } + } + + depends_on = [helm_release.gitea] +} diff --git a/felsokning/infra/terraform/README.md b/felsokning/infra/terraform/README.md index 74fe84a..c0f3a07 100644 --- a/felsokning/infra/terraform/README.md +++ b/felsokning/infra/terraform/README.md @@ -8,12 +8,14 @@ begripliga att läsa: | `versions.tf` | Leverantörer och tillståndsbackend | | `variables.tf` | Allt som skiljer en installation från en annan | | `karta.tf` | **Systemet som data** — tjänster, portar, routing, hemligheter, dataflöden, gränser | -| `10-namnrymd.tf` | Namnrymd, hemligheten, databasschemat | -| `20-databas.tf` | Händelseloggen — tre lägen, se nedan | +| `10-namnrymd.tf` | Namnrymd, tjänstekonto med IRSA, hemligheter ur Secrets Manager | +| `05-aws.tf` | Kopplingen till AWS-basen — allt den redan bestämt läses här | +| `15-plattformstjanster.tf` | ALB-kontroller, External Secrets, metrics, CloudWatch | +| `90-gitea.tf` | Självhostad git med egna byggrunners | | `30-plattform.tf` | Backend: auth, händelse-API, delning, integrationer | | `40-orkester.tf` | AI-orkestern | | `50-web.tf` | Klienten | -| `60-ingress.tf` | Trafiken utifrån | +| `60-ingress.tf` | ALB och DNS | | `70-natverk.tf` | Nätverkspolicyer — vem får prata med vem | | `outputs.tf` | Kartan utskriven | @@ -45,33 +47,24 @@ terraform validate # typer och referenser `terraform validate` kräver att leverantörerna hämtats från registry.terraform.io. -## Databasen: tre lägen +## Var saker ligger -`var.databas_lage` saknar standardvärde med flit. Valet avgör om det -finns säkerhetskopiering, och det ska inte kunna bli fel av slentrian. +Det här lagret bestämmer nästan ingenting själv. AWS-basen +(`felsokning/infra/aws`) äger klustret, databasen, registret, hinken, +rollerna, domänen och hemligheterna — och `05-aws.tf` läser dess utdata. +Ändras något där slår det igenom här utan att en rad ändras. -| Läge | Backup | Failover | Använd när | -| --- | --- | --- | --- | -| `extern` | Leverantörens, med PITR | Leverantörens | **Produktion.** Cloud SQL, RDS, Neon, Azure Flexible Server | -| `cnpg` | Basbackup 02:30 + WAL-arkiv → objektlagring, PITR | Ja, `databas_instanser` styr | Produktion när databasen måste ligga i klustret | -| `inbyggd` | **Ingen** | Nej | Prov och demo. Blockeras av en precondition när `miljo = "produktion"` | +| Vad | Var | +| --- | --- | +| Händelseloggen | Aurora PostgreSQL utanför klustret, med PITR | +| Bilagor | S3, signerat med tjänstekontots roll — inga nycklar finns | +| Hemligheter | Secrets Manager, speglade av External Secrets var timme | +| Bilder | ECR med oföränderliga taggar | +| Trafik in | ALB med ACM-certifikat | +| Git och bygge | Gitea med egna runners i samma kluster | -Går händelseloggen 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. - -`cnpg` kräver att CloudNativePG-operatorn redan är installerad — -Terraform slår upp dess CRD vid plan: - -```sh -kubectl apply --server-side -f \ - https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml -``` - -I `extern` läge ansvarar ni själva för att köra -`infra/postgres-init.sql` mot databasen. Det är samma fil som -integrationstestet kör, så schemat kan inte glida isär från det som -testas. +Terraform ser aldrig en hemlighets värde. Det är avsiktligt: en +hemlighet som passerar Terraform hamnar i tillståndsfilen. ## Driftsättning diff --git a/felsokning/infra/terraform/karta.tf b/felsokning/infra/terraform/karta.tf index 04eb372..672ba39 100644 --- a/felsokning/infra/terraform/karta.tf +++ b/felsokning/infra/terraform/karta.tf @@ -29,38 +29,27 @@ locals { tjanster = { web = { roll = "Klienten. Statisk SPA bakom oprivilegierad nginx. Innehåller ingen hemlighet — API-adressen bakas in vid bygget." - bild = "${var.register}/${local.namn}-web:${var.bildtagg}" + bild = "${local.aws.register}/${local.namn}-web:${var.bildtagg}" hemligt = [] utat = ["webbläsaren anropar plattform och orkester direkt över ingressen"] } plattform = { - roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar." - bild = "${var.register}/${local.namn}-plattform:${var.bildtagg}" - hemligt = concat( - ["jwt-hemlighet", "postgres-losenord", "integration-nyckel"], - var.bilage_lage == "s3" ? ["s3-nyckel-id", "s3-nyckel"] : [], - ) - utat = concat( - ["postgres:5432", "kundernas leverantörer över internet (spärrat mot privata nät)"], - var.bilage_lage == "s3" ? ["objektlagringen för bilagor"] : [], - ) + roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar." + bild = "${local.aws.register}/${local.namn}-plattform:${var.bildtagg}" + hemligt = ["allt via Secrets Manager: databas, Claude-nyckel, JWT, krypteringsnyckel"] + utat = ["Aurora:5432", "S3 för bilagor", "kundernas leverantörer (spärrat mot privata nät)"] } orkester = { roll = "AI-orkestern. Routar per uppgift till Claude, äger systemprompt och svarsschema. Verifierar plattformens JWT." - bild = "${var.register}/${local.namn}-ai-orkester:${var.bildtagg}" + bild = "${local.aws.register}/${local.namn}-ai-orkester:${var.bildtagg}" hemligt = ["anthropic-api-nyckel", "jwt-hemlighet"] utat = ["api.anthropic.com"] } postgres = { - roll = join(" ", [ - "Händelseloggen — systemets enda sanningskälla. Append-only garanteras av databastriggers, inte bara av API:t.", - var.databas_lage == "extern" ? "Läge: extern managerad Postgres — leverantören sköter backup och PITR." : - var.databas_lage == "cnpg" ? "Läge: CloudNativePG i klustret — basbackup, WAL-arkivering, PITR och failover." : - "Läge: inbyggd StatefulSet UTAN säkerhetskopiering — endast prov och demo." - ]) - bild = var.databas_lage == "cnpg" ? "ghcr.io/cloudnative-pg/postgresql:17.2" : var.databas_lage == "extern" ? "(utanför klustret)" : "postgres:17-alpine" - hemligt = ["postgres-losenord"] - utat = var.databas_lage == "cnpg" ? ["objektlagringen för backup och WAL-arkiv"] : [] + roll = "Händelseloggen ligger i Aurora utanför klustret — se AWS-basen. Append-only garanteras av databastriggers, säkerhetskopiering och PITR av Aurora." + bild = "(Aurora PostgreSQL, managerad)" + hemligt = ["databasens anslutningssträng, ur Secrets Manager"] + utat = [] } } @@ -81,15 +70,9 @@ locals { # En enda Secret, men varje tjänst monterar bara sina egna nycklar. hemligheter = { - "anthropic-api-key" = var.anthropic_api_nyckel - "jwt-secret" = coalesce(var.jwt_hemlighet, random_password.jwt.result) - "postgres-losenord" = coalesce(var.postgres_losenord, random_password.postgres.result) - "integration-nyckel" = coalesce( - var.integration_nyckel, - random_id.integration_nyckel.hex, - ) - "s3-nyckel-id" = var.s3_nyckel_id - "s3-nyckel" = var.s3_nyckel + kalla = "AWS Secrets Manager — Terraform ser aldrig värdena" + speglas = "External Secrets, var timme, med tjänstekontots roll" + lasare = "endast plattformens tjänstekonto, via IRSA" } # ---- Dataflöden ----------------------------------------------------- @@ -105,12 +88,13 @@ locals { ] bilagor = { - lage = var.bilage_lage - var = var.bilage_lage == "s3" ? "${var.s3.endpoint}/${var.s3.hink}/${var.s3.prefix}" : "tabellen bilage_innehall" + lage = "s3" + var = "S3-hinken ur AWS-basen" hur = join(" ", [ "Innehållet ligger utanför händelsen; loggen bär referensen och innehållets SHA-256.", "Innehållsadresserat, så samma foto lagras en gång.", "Hashen kontrolleras vid utlämning — en utbytt bild lämnas inte ut.", + "Tjänsten signerar mot S3 med tjänstekontots roll; inga nycklar finns.", ]) } @@ -119,32 +103,15 @@ locals { "Delningsgränsen: tillåtelselista över händelsetyper per nivå (kund/partner/intern) — nya typer är interna tills de aktivt släpps fram.", "Hemlighetsgränsen: Claude-nyckeln och kundernas leverantörsnycklar finns bara serversidan. Klienten ser maskerade värden.", "Historikgränsen: append-only i både API och databas (triggers). Ingen roll kan ändra eller radera en händelse.", + "AWS-gränsen: varje roll är bunden till exakt ett tjänstekonto i en namnrymd (IRSA). Bygget får publicera men inte driftsätta; driften tvärtom.", "Bilagegränsen: en bilaga kan bara hämtas via en delningslänk om händelsen den hör till är synlig på den nivån.", ] # ---- Det som medvetet inte ingår ------------------------------------ - avgransningar = concat( - var.databas_lage == "inbyggd" ? ["INGEN SÄKERHETSKOPIERING — läget inbyggd har en volym och inget mer. Endast prov och demo."] : [], - [ - "Observability. Ingen metrikexport, ingen tracing — bara containerloggar.", - "Takt-begränsning på inloggning. Endast den publika beslutsvägen är begränsad, och bara per pod.", - "Återkallelse av utfärdade JWT. En token gäller sin livstid ut även om användaren tas bort.", - ], - ) + avgransningar = [ + "Observability är CloudWatch: loggar, Container Insights och fyra larm. Ingen distribuerad tracing.", + "Takt-begränsning på inloggning är databasbackad och håller bakom flera repliker; övriga endpoints har ingen.", + ] } -# Genereras bara när motsvarande variabel lämnats tom. -resource "random_password" "jwt" { - length = 64 - special = false -} - -resource "random_password" "postgres" { - length = 32 - special = false -} - -resource "random_id" "integration_nyckel" { - byte_length = 32 -} diff --git a/felsokning/infra/terraform/outputs.tf b/felsokning/infra/terraform/outputs.tf index 43d52d0..df1d08b 100644 --- a/felsokning/infra/terraform/outputs.tf +++ b/felsokning/infra/terraform/outputs.tf @@ -8,7 +8,7 @@ output "karta" { value = { miljo = var.miljo - domän = "https://${var.doman}" + domän = "https://${local.aws.doman}" namnrymd = var.namnrymd bildtagg = var.bildtagg @@ -22,31 +22,17 @@ output "karta" { } routing = [ - for r in local.routing : "https://${var.doman}${r.prefix} → ${r.till} (${r.varfor})" + for r in local.routing : "https://${local.aws.doman}${r.prefix} → ${r.till} (${r.varfor})" ] dataflöden = local.dataflode gränser = local.granser databas = { - läge = var.databas_lage - motor = "PostgreSQL 17" - säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll" - - backup = ( - var.databas_lage == "extern" - ? "Leverantörens — verifiera att PITR faktiskt är påslaget." - : var.databas_lage == "cnpg" - ? "Basbackup 02:30 + kontinuerlig WAL-arkivering till ${var.backup.mal}, ${var.backup.behall_dagar} dagars retention. PITR möjlig." - : "INGEN. Går volymen förlorad är händelseloggen borta." - ) - - volym = var.databas_lage == "extern" ? "(leverantörens)" : var.databas_storlek - instanser = ( - var.databas_lage == "cnpg" - ? "${var.databas_instanser} (failover)" - : var.databas_lage == "extern" ? "(leverantörens)" : "1 (ingen failover)" - ) + motor = "Aurora PostgreSQL Serverless v2 — utanför klustret, se AWS-basen" + säkerhet = "append-only via triggers; ingen roll kan ändra eller radera en händelse" + backup = "Auroras automatiska säkerhetskopiering med PITR" + atkomst = "endast från klustrets noder, i ett subnätlager utan routing ut" } bilagor = local.bilagor @@ -54,8 +40,9 @@ output "karta" { drift = { registrering_öppen = var.registrering_oppen interna_uppslag_tillåtna = var.tillat_interna_uppslag - max_kropp = "${var.max_kropp_mb} MB (ingress) / 4 MB (tjänst)" - cors = "https://${var.doman}" + max_kropp = "${var.max_kropp_mb} MB (lastbalanserare) / 32 MB (bilaga) / 4 MB (händelse)" + cors = "https://${local.aws.doman}" + hemligheter = "AWS Secrets Manager, speglade av External Secrets" } avgränsningar = local.avgransningar @@ -66,21 +53,12 @@ output "endpoints" { description = "Adresser att kontrollera efter driftsättning." value = { - klient = "https://${var.doman}/felsokning" - hälsa = "https://${var.doman}/halsa" - api_spec = "https://${var.doman}/api/openapi.yaml" - ai = "https://${var.doman}/api/ai" - delningslänk = "https://${var.doman}/felsokning/delad/" + klient = "https://${local.aws.doman}/felsokning" + hälsa = "https://${local.aws.doman}/halsa" + api_spec = "https://${local.aws.doman}/api/openapi.yaml" + ai = "https://${local.aws.doman}/api/ai" + delningslänk = "https://${local.aws.doman}/felsokning/delad/" + git = var.gitea.aktiv && var.gitea.doman != "" ? "https://${var.gitea.doman}" : "(gitea av)" } } -output "genererade_hemligheter" { - description = "Hemligheter Terraform genererat (tomma om ni satt egna). Finns i tillståndet — skydda det." - sensitive = true - - value = { - jwt_hemlighet = var.jwt_hemlighet == "" ? random_password.jwt.result : "(egen)" - postgres_losenord = var.postgres_losenord == "" ? random_password.postgres.result : "(egen)" - integration_nyckel = var.integration_nyckel == "" ? random_id.integration_nyckel.hex : "(egen)" - } -} diff --git a/felsokning/infra/terraform/terraform.tfvars.exempel b/felsokning/infra/terraform/terraform.tfvars.exempel index da8f58f..72bc21e 100644 --- a/felsokning/infra/terraform/terraform.tfvars.exempel +++ b/felsokning/infra/terraform/terraform.tfvars.exempel @@ -1,61 +1,28 @@ -# Kopiera till terraform.tfvars och fyll i. Checka aldrig in riktiga -# nycklar — .gitignore utesluter *.tfvars. +# Kopiera till terraform.tfvars och fyll i. Nästan allt kommer från +# AWS-basen — det här är resten. -doman = "app.exempel.se" -register = "ghcr.io/min-organisation" -bildtagg = "ersatt-med-git-sha" +# Hinken där basens tillstånd ligger. Basen skriver ut namnet: +# cd ../aws && terraform output -raw ... +tillstandshink = "felsokning-produktion-tfstate-123456789012" +region = "eu-north-1" + +# Commit-SHA från publiceringsflödet. ECR har oföränderliga taggar, så +# en tagg pekar alltid på samma bygge. +bildtagg = "ersatt-med-commit-sha" miljo = "produktion" -# Claude-nyckeln ägs av plattformen och når aldrig klienten. -# Hämta hellre från en secrets-hanterare än att skriva den här: -# export TF_VAR_anthropic_api_nyckel="$(vault kv get -field=key ...)" -anthropic_api_nyckel = "sk-ant-…" - -# Lämnas dessa tomma genererar Terraform dem vid första körningen och -# behåller dem i tillståndet. Byts integration_nyckel måste alla -# märkesspecifika kopplingar sparas om. -jwt_hemlighet = "" -postgres_losenord = "" -integration_nyckel = "" - -# Stäng självregistrering i produktion — annars kan vem som helst skapa -# en organisation. +# Stäng självregistrering i produktion. registrering_oppen = false # Öppnar en väg från en tenant-administratör in i klustrets interna # adresser. Sätt bara true om verkstaden har en OEM-server på eget nät. tillat_interna_uppslag = false -# --- Databasen --------------------------------------------------------- -# -# Inget standardvärde: valet avgör om det finns säkerhetskopiering. -# -# "extern" managerad Postgres — leverantören sköter backup och PITR -# (rekommenderat i produktion). Kräver databas_url. -# "cnpg" CloudNativePG i klustret — basbackup, WAL-arkiv, failover. -# Kräver att operatorn är installerad och backup-uppgifter. -# "inbyggd" en volym, ingen backup. Endast prov och demo — går inte -# att kombinera med miljo = "produktion". - -databas_lage = "extern" -databas_url = "postgresql://plattform:...@db.exempel.internal:5432/felsokning" - -# Används bara när databas_lage = "cnpg": -# backup = { -# mal = "s3://verkstad-backup/felsokning" -# endpoint = "" # sätts för S3-kompatibel lagring som inte är AWS -# behall_dagar = 30 -# } -# backup_nyckel_id = "" -# backup_nyckel = "" -# databas_instanser = 3 - -databas_storlek = "50Gi" -# lagringsklass = "premium-rwo" - -repliker = { - web = 2 - plattform = 2 - orkester = 2 +# Självhostad git med egna runners. +gitea = { + aktiv = true + doman = "git.exempel.se" + runners = 2 + lagring = "20Gi" } diff --git a/felsokning/infra/terraform/variables.tf b/felsokning/infra/terraform/variables.tf index e630df4..3d3d18a 100644 --- a/felsokning/infra/terraform/variables.tf +++ b/felsokning/infra/terraform/variables.tf @@ -1,117 +1,45 @@ -# Allt som skiljer en installation från en annan. Inget annat ska -# behöva ändras i .tf-filerna för att driftsätta i en ny miljö. +# Det lilla som inte kommer från AWS-basen. +# +# Domän, register, roller, certifikat, hink och hemligheternas namn läses +# ur basens utdata (05-aws.tf) — de ska inte anges två gånger. -# ---- Kluster ----------------------------------------------------------- - -variable "kubeconfig" { - description = "Sökväg till kubeconfig." +variable "tillstandshink" { + description = "S3-hinken där AWS-basens Terraform-tillstånd ligger. Basen skriver ut namnet." type = string - default = "~/.kube/config" } -variable "kube_context" { - description = "Kubernetes-kontext att driftsätta i. Tom = kubeconfigens aktuella." +variable "region" { + description = "Region för tillståndshinken. Samma som basens." type = string - default = "" + default = "eu-north-1" } variable "namnrymd" { - description = "Namnrymd för hela systemet." + description = "Namnrymd för arbetslasten." type = string default = "guidad-felsokning" } variable "miljo" { - description = "Miljöns namn (produktion, test, demo). Sätts som label på allt." + description = "Miljöns namn. Sätts som label på allt." type = string default = "produktion" } -# ---- Domän och certifikat --------------------------------------------- - -variable "doman" { - description = "Domänen klienten och API:t nås på, t.ex. app.exempel.se." - type = string -} - -variable "cert_issuer" { - description = "cert-manager ClusterIssuer som utfärdar TLS-certifikatet." - type = string - default = "letsencrypt" -} - -variable "ingress_klass" { - description = "IngressClass. Nätverkspolicyn öppnar för ingress-kontrollerns namnrymd nedan." - type = string - default = "nginx" -} - -variable "ingress_namnrymd" { - description = "Namnrymd där ingress-kontrollern kör — behövs av nätverkspolicyn." - type = string - default = "ingress-nginx" -} - -# ---- Bilder ------------------------------------------------------------ - -variable "register" { - description = "Containerregister, t.ex. ghcr.io/min-organisation." - type = string -} - variable "bildtagg" { description = <<-TEXT - Taggen alla tre bilderna körs med — normalt git-SHA:t från - publiceringsflödet. "latest" duger för prov men gör en rullning - omöjlig att spåra. + Taggen alla tre bilderna körs med — commit-SHA:t från + publiceringsflödet. ECR har oföränderliga taggar, så en tagg pekar + alltid på samma bygge. TEXT type = string validation { condition = var.bildtagg != "" - error_message = "Ange en bildtagg — helst git-SHA:t, aldrig tomt." + error_message = "Ange en bildtagg — helst commit-SHA:t, aldrig tomt." } } -# ---- Hemligheter ------------------------------------------------------- -# -# Sätts helst via en secrets-hanterare (External Secrets, Vault, molnets -# secret manager) i stället för tfvars. Lämnas de tomma genererar -# Terraform slumpvärden vid första körningen och behåller dem i -# tillståndet — bekvämt för prov, men då måste tillståndet skyddas -# därefter. - -variable "anthropic_api_nyckel" { - description = "Claude API-nyckel. Ägs av plattformen, når aldrig klienten." - type = string - sensitive = true -} - -variable "jwt_hemlighet" { - description = "HS256-hemlighet. Delas av plattformen och orkestern. Tom = genereras." - type = string - sensitive = true - default = "" -} - -variable "postgres_losenord" { - description = "Databaslösenord. Tom = genereras." - type = string - sensitive = true - default = "" -} - -variable "integration_nyckel" { - description = <<-TEXT - 32 byte hex som krypterar kundernas leverantörsuppgifter i vila. - Tom = genereras. Byts nyckeln måste alla kopplingar sparas om — - tjänsten visar då inga värden i stället för att gissa. - TEXT - type = string - sensitive = true - default = "" -} - # ---- Drift ------------------------------------------------------------- variable "registrering_oppen" { @@ -133,12 +61,12 @@ variable "tillat_interna_uppslag" { variable "max_kropp_mb" { description = <<-TEXT - Största tillåtna anropskropp. Foton och videoklipp följer med i - händelseloggen, så ingressens gräns måste minst motsvara tjänstens - egen (4 MB) — annars avvisas dokumentationen redan vid dörren. + Största tillåtna anropskropp. Bilagor laddas upp separat och kan + vara upp till 32 MB — lastbalanserarens gräns måste rymma dem, + annars avvisas dokumentationen redan vid dörren. TEXT type = number - default = 8 + default = 64 } variable "repliker" { @@ -169,143 +97,19 @@ variable "max_repliker" { } } -# ---- Databas ----------------------------------------------------------- +# ---- Självhostad git --------------------------------------------------- -variable "databas_lage" { +variable "gitea" { description = <<-TEXT - Hur händelseloggen lagras. Inget standardvärde: valet avgör om det - finns säkerhetskopiering, och det är inte ett val någon ska göra av - misstag. - - "extern" Managerad Postgres utanför klustret (Cloud SQL, RDS, - Neon, Azure Flexible Server). Leverantören sköter - backup, PITR, failover och kryptering. REKOMMENDERAT - i produktion. Kräver databas_url. - - "cnpg" CloudNativePG i klustret: basbackup, WAL-arkivering och - PITR mot objektlagring. Kräver att operatorn redan är - installerad — Terraform slår upp dess CRD vid plan. - Kräver backup-uppgifterna nedan. - - "inbyggd" En StatefulSet med en volym. INGEN säkerhetskopiering. - Går volymen förlorad är händelseloggen borta, och den - är hela produktens bevisvärde. Endast för prov och demo. - TEXT - type = string - - validation { - condition = contains(["extern", "cnpg", "inbyggd"], var.databas_lage) - error_message = "databas_lage måste vara \"extern\", \"cnpg\" eller \"inbyggd\"." - } -} - -variable "databas_url" { - description = "Anslutning till den externa databasen. Krävs när databas_lage = \"extern\"." - type = string - sensitive = true - default = "" -} - -variable "backup" { - description = <<-TEXT - Objektlagring för CloudNativePG:s basbackup och WAL-arkiv. Används - bara när databas_lage = "cnpg". mal är en S3-URL, t.ex. - s3://verkstad-backup/felsokning. + Gitea med egna Actions-runners i samma kluster. Med den på finns + inget externt beroende alls: källkod, bygge, register och drift + ligger i vår egen AWS-miljö. TEXT type = object({ - mal = string - endpoint = optional(string, "") - behall_dagar = optional(number, 30) + aktiv = optional(bool, true) + doman = optional(string, "") + runners = optional(number, 2) + lagring = optional(string, "20Gi") }) - default = { - mal = "" - } -} - -variable "backup_nyckel_id" { - description = "Åtkomstnyckel till objektlagringen (databas_lage = \"cnpg\")." - type = string - sensitive = true - default = "" -} - -variable "backup_nyckel" { - description = "Hemlig nyckel till objektlagringen (databas_lage = \"cnpg\")." - type = string - sensitive = true - default = "" -} - -variable "databas_instanser" { - description = "Antal Postgres-instanser i CloudNativePG-klustret. 3 ger failover utan dataförlust." - type = number - default = 3 -} - -# ---- Bilagor ----------------------------------------------------------- - -variable "bilage_lage" { - description = <<-TEXT - Var foton och videoklipp lagras. - - "databas" bytea i en egen tabell. Fungerar överallt och kräver - ingen konfiguration, men bilderna följer med databasens - säkerhetskopior och gör dem stora. - - "s3" S3-kompatibel objektlagring (AWS, MinIO, Ceph). Loggen - och bilderna växer oberoende av varandra. Kräver - s3-uppgifterna nedan. - - Innehållet är innehållsadresserat i båda lägena: samma foto lagras - en gång, och hashen står i händelseloggen så en utbytt bild går att - upptäcka. - TEXT - type = string - default = "databas" - - validation { - condition = contains(["databas", "s3"], var.bilage_lage) - error_message = "bilage_lage måste vara \"databas\" eller \"s3\"." - } -} - -variable "s3" { - description = "Objektlagring för bilagor. Används bara när bilage_lage = \"s3\"." - type = object({ - endpoint = string - hink = string - region = string - prefix = optional(string, "bilagor") - }) - default = { - endpoint = "" - hink = "" - region = "" - } -} - -variable "s3_nyckel_id" { - description = "Åtkomstnyckel till bilagornas objektlagring." - type = string - sensitive = true - default = "" -} - -variable "s3_nyckel" { - description = "Hemlig nyckel till bilagornas objektlagring." - type = string - sensitive = true - default = "" -} - -variable "databas_storlek" { - description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen." - type = string - default = "50Gi" -} - -variable "lagringsklass" { - description = "StorageClass för databasvolymen. Tom = klustrets standard." - type = string - default = "" + default = {} } diff --git a/felsokning/infra/terraform/versions.tf b/felsokning/infra/terraform/versions.tf index 4bd122f..3133654 100644 --- a/felsokning/infra/terraform/versions.tf +++ b/felsokning/infra/terraform/versions.tf @@ -1,47 +1,83 @@ -# Guidad Felsökning — infrastrukturens definition. +# Guidad Felsökning — arbetslasten i klustret. # -# Läsordning för den som vill förstå hela systemet: -# versions.tf det här — leverantörer och tillstånd -# variables.tf alla rattar som finns att vrida på -# karta.tf locals: hela systemet som data (tjänster, portar, -# routing, hemligheter, dataflöden) — kartans källa -# 10-namnrymd.tf namnrymd + hemligheter -# 20-databas.tf Postgres: händelseloggen -# 30-plattform.tf backend: auth, händelse-API, delning, integrationer -# 40-orkester.tf AI-orkestern: modellrouting mot Claude -# 50-web.tf klienten -# 60-ingress.tf trafiken utifrån och in -# 70-natverk.tf nätverkspolicyer: vem får prata med vem -# outputs.tf kartan utskriven — kör `terraform output karta` +# Lager två av två. Basen (felsokning/infra/aws) måste vara applicerad +# först; det här lagret läser dess utdata och behöver därför inte känna +# till en enda AWS-resurs vid namn. +# +# Läsordning: +# versions.tf det här — leverantörer, kopplade till EKS +# 05-aws.tf basens utdata och vad de betyder här +# variables.tf det lilla som inte kommer från basen +# karta.tf systemet som data — kartans källa +# 10-namnrymd.tf namnrymd + hemligheter ur Secrets Manager +# 15-plattformstjanster.tf ALB-kontroller, External Secrets, metrics +# 30-plattform.tf backend +# 40-orkester.tf AI-orkestern +# 50-web.tf klienten +# 60-ingress.tf ALB +# 70-natverk.tf nätverkspolicyer +# 90-gitea.tf självhostad git + byggrunners +# outputs.tf kartan utskriven — kör `terraform output karta` terraform { required_version = ">= 1.6" required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.80" + } kubernetes = { source = "hashicorp/kubernetes" version = "~> 2.35" } + helm = { + source = "hashicorp/helm" + version = "~> 2.16" + } random = { source = "hashicorp/random" version = "~> 3.6" } } - # Tillståndet innehåller hemligheter (JWT-hemlighet, databaslösenord, - # krypteringsnyckeln för kundernas leverantörsuppgifter). Lägg det i en - # backend med kryptering och åtkomststyrning — aldrig lokalt i git. + # Samma hink som basen, annan nyckel. # # backend "s3" { - # bucket = "guidad-felsokning-tfstate" - # key = "produktion/terraform.tfstate" + # bucket = "felsokning-produktion-tfstate-" + # key = "arbetslast/terraform.tfstate" # region = "eu-north-1" # encrypt = true # use_lockfile = true # } } -provider "kubernetes" { - config_path = var.kubeconfig - config_context = var.kube_context +provider "aws" { + region = local.aws.region +} + +# Autentisering mot klustret sker med en färsk token från EKS i stället +# för en kubeconfig på disk — inget att distribuera, inget som går ut. +provider "kubernetes" { + host = local.aws.kluster_slutpunkt + cluster_ca_certificate = base64decode(local.aws.kluster_ca) + + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "aws" + args = ["eks", "get-token", "--cluster-name", local.aws.kluster, "--region", local.aws.region] + } +} + +provider "helm" { + kubernetes { + host = local.aws.kluster_slutpunkt + cluster_ca_certificate = base64decode(local.aws.kluster_ca) + + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "aws" + args = ["eks", "get-token", "--cluster-name", local.aws.kluster, "--region", local.aws.region] + } + } }