Genomgång av flöden och infrastruktur — infrakarta i Terraform

Infrastrukturen får en definition som går att läsa: infra/terraform, där
karta.tf beskriver hela systemet en gång som data — tjänster, portar,
routing, vilken tjänst som ser vilken hemlighet, dataflöden och gränser.
Resten av filerna läser därifrån i stället för att upprepa namn och
portar, och `terraform output karta` skriver ut samma innehåll i klartext
direkt ur definitionen. Filerna är numrerade i läsordning.

Genomgången hittade sex saker som är åtgärdade här:

Uppslaget mot märkesspecifika kopplingar kunde riktas inåt. Bas-URL:en
sätts av kundens administratör men anropet görs av vår server —
169.254.169.254 eller ett internt tjänstenamn hade nått molnets
metadatatjänst respektive klustrets insida, och svaret kommit tillbaka
mappat genom svarsfälten. En tenant-administratör är inte
infrastrukturens ägare. Nu stoppas IP-literaler, namn som resolvar till
privata adresser och .local/.internal innan något anrop görs, och
nätverkspolicyn undantar samma nät. TILLAT_INTERNA_UPPSLAG öppnar för
verkstäder som har OEM-servern på eget nät.

Delningsfiltret var en nekalista, alltså blev varje ny händelsetyp
automatiskt synlig i kundens delningslänk tills någon kom ihåg att neka
den — fel håll att fela åt på en integritetsgräns. Nu räknas i stället
upp vad som får delas per nivå, och ett test kräver att varje
händelsetyp i domänmodellen är klassificerad. Samma ändring i
Supabase-funktionen via ny migration. Beteendet i dag är oförändrat;
det är riktningen som vänts.

Ingressen saknade kroppsgräns och hade därmed nginx standard på 1 MB
medan tjänsten tar 4 MB — foto- och videodokumentation hade avvisats i
produktion men aldrig i testerna. Satt till 8 MB i båda vägarna.

Vidare: nätverkspolicyer som stänger namnrymden och bara öppnar de
faktiska flödena, CORS-lista via TILLATNA_URSPRUNG i stället för "*",
och säkerhetskontext + startprob på databaspodden.

Kustomize-/Argo CD-vägen finns kvar men ska inte köras mot samma kluster
som Terraform — selfHeal och prune motarbetar terraform apply. Val och
bytesväg dokumenterade.

Verifierat: 87 vitest-tester, typkontroll, eslint, OpenAPI-validering,
terraform fmt och integrationstest mot riktig Postgres inklusive den nya
spärren. terraform validate kunde inte köras här — registry.terraform.io
är blockerad av sessionens egress-policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 14:10:59 +00:00
parent 8f1aba1571
commit cbc7bf2751
21 changed files with 1982 additions and 18 deletions
+5
View File
@@ -7,6 +7,11 @@ metadata:
namespace: guidad-felsokning
annotations:
cert-manager.io/cluster-issuer: letsencrypt # ersätt med er issuer
# Teknikern dokumenterar med foto och video som följer med
# händelseloggen. Standardgränsen (1 MB) skulle avvisa
# dokumentationen vid dörren medan tjänsten själv tar 4 MB.
nginx.ingress.kubernetes.io/proxy-body-size: 8m
nginx.ingress.kubernetes.io/proxy-read-timeout: "120" # AI-svar kan ta tid
spec:
ingressClassName: nginx
tls:
+38
View File
@@ -0,0 +1,38 @@
# Namnrymden och den enda hemligheten.
resource "kubernetes_namespace_v1" "denna" {
metadata {
name = var.namnrymd
labels = local.etiketter
}
}
# 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" {
metadata {
name = "felsokning-hemligheter"
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.
resource "kubernetes_config_map_v1" "postgres_init" {
metadata {
name = "postgres-init"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
data = {
"init.sql" = file("${path.module}/../k8s/postgres-init.sql")
}
}
+164
View File
@@ -0,0 +1,164 @@
# Postgres — händelseloggen.
#
# 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.
#
# En StatefulSet med en PVC är INTE säkerhetskopiering. Innan skarp drift:
# byt mot CloudNativePG-operatorn (basbackup + WAL-arkivering + PITR +
# failover) och peka plattformens DATABASE_URL på dess tjänst.
resource "kubernetes_stateful_set_v1" "postgres" {
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.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" {
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
}
}
}
+209
View File
@@ -0,0 +1,209 @@
# Plattformstjänsten — systemets mitt.
#
# Äger: inloggning (bcrypt via pgcrypto, HS256-JWT med roll + organisation
# i anspråken), append-only händelse-API, Live Share med serverstyrd
# filtrering, organisationsinställningar, ECM-regelpaketet och de
# märkesspecifika kopplingarna.
#
# Ser tre hemligheter och inga fler: JWT-hemligheten (delas med
# orkestern), databaslösenordet och krypteringsnyckeln för kundernas
# leverantörsuppgifter.
resource "kubernetes_deployment_v1" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "plattform" })
}
spec {
replicas = var.repliker.plattform
selector {
match_labels = { app = "plattform" }
}
template {
metadata {
labels = merge(local.etiketter, { app = "plattform" })
}
spec {
automount_service_account_token = false
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "plattform"
image = local.tjanster.plattform.bild
port {
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"
}
}
}
# 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"
}
}
}
env {
name = "POSTGRES_LOSENORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "postgres-losenord"
}
}
}
env {
name = "DATABASE_URL"
value = "postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning"
}
# 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}"
}
env {
name = "REGISTRERING_OPPEN"
value = var.registrering_oppen ? "true" : "false"
}
env {
name = "TILLAT_INTERNA_UPPSLAG"
value = var.tillat_interna_uppslag ? "true" : "false"
}
resources {
requests = { cpu = "100m", memory = "128Mi" }
limits = { cpu = "1", memory = "512Mi" }
}
readiness_probe {
http_get {
path = "/halsa"
port = local.port_container
}
initial_delay_seconds = 3
period_seconds = 10
}
liveness_probe {
http_get {
path = "/halsa"
port = local.port_container
}
period_seconds = 15
}
security_context {
allow_privilege_escalation = false
read_only_root_filesystem = true
capabilities {
drop = ["ALL"]
}
}
}
}
}
}
}
resource "kubernetes_service_v1" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "plattform" })
}
spec {
selector = { app = "plattform" }
port {
port = local.port_tjanst
target_port = local.port_container
}
}
}
resource "kubernetes_horizontal_pod_autoscaler_v2" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_replicas = var.repliker.plattform
max_replicas = var.max_repliker.plattform
scale_target_ref {
api_version = "apps/v1"
kind = "Deployment"
name = kubernetes_deployment_v1.plattform.metadata[0].name
}
metric {
type = "Resource"
resource {
name = "cpu"
target {
type = "Utilization"
average_utilization = 70
}
}
}
}
}
resource "kubernetes_pod_disruption_budget_v1" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_available = 1
selector {
match_labels = { app = "plattform" }
}
}
}
+174
View File
@@ -0,0 +1,174 @@
# AI-orkestern.
#
# Egen tjänst av två skäl: Claude-nyckeln ska bara finnas på ett ställe,
# och AI-anrop har helt annan latens- och skalningsprofil än
# händelse-API:t. Den skalar därför separat och kan gå ned utan att
# felsökningen slutar fungera — metodiken är deterministisk och klarar
# sig utan modellsvar.
#
# Orkestern äger modellval, systemprompt och svarsschema. Klienten
# skickar bara uppgiftstyp och underlag. Den verifierar plattformens JWT
# med samma delade hemlighet — den litar aldrig på anroparen.
resource "kubernetes_deployment_v1" "orkester" {
metadata {
name = "ai-orkester"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "ai-orkester" })
}
spec {
replicas = var.repliker.orkester
selector {
match_labels = { app = "ai-orkester" }
}
template {
metadata {
labels = merge(local.etiketter, { app = "ai-orkester" })
}
spec {
automount_service_account_token = false
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "ai-orkester"
image = local.tjanster.orkester.bild
port {
container_port = local.port_container
}
env {
name = "ANTHROPIC_API_KEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "anthropic-api-key"
}
}
}
env {
name = "JWT_SECRET"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.hemligheter.metadata[0].name
key = "jwt-secret"
}
}
}
resources {
requests = { cpu = "100m", memory = "128Mi" }
limits = { cpu = "1", memory = "512Mi" }
}
readiness_probe {
http_get {
path = "/halsa"
port = local.port_container
}
initial_delay_seconds = 3
period_seconds = 10
}
liveness_probe {
http_get {
path = "/halsa"
port = local.port_container
}
period_seconds = 15
}
security_context {
allow_privilege_escalation = false
read_only_root_filesystem = true
capabilities {
drop = ["ALL"]
}
}
}
}
}
}
}
resource "kubernetes_service_v1" "orkester" {
metadata {
name = "ai-orkester"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "ai-orkester" })
}
spec {
selector = { app = "ai-orkester" }
port {
port = local.port_tjanst
target_port = local.port_container
}
}
}
resource "kubernetes_horizontal_pod_autoscaler_v2" "orkester" {
metadata {
name = "ai-orkester"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_replicas = var.repliker.orkester
max_replicas = var.max_repliker.orkester
scale_target_ref {
api_version = "apps/v1"
kind = "Deployment"
name = kubernetes_deployment_v1.orkester.metadata[0].name
}
metric {
type = "Resource"
resource {
name = "cpu"
target {
type = "Utilization"
average_utilization = 70
}
}
}
}
}
resource "kubernetes_pod_disruption_budget_v1" "orkester" {
metadata {
name = "ai-orkester"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_available = 1
selector {
match_labels = { app = "ai-orkester" }
}
}
}
+170
View File
@@ -0,0 +1,170 @@
# Klienten.
#
# Statisk SPA bakom oprivilegierad nginx. Innehåller ingen hemlighet —
# men API-adressen bakas in vid bygget (Vite), så bilden är miljöbunden.
# Det är därför bildtaggen måste vara samma git-SHA som byggdes mot den
# här domänen; en bild från en annan miljö pekar på fel API.
#
# nginx-unprivileged behöver skriva till sina temp-kataloger, därför
# emptyDir-monteringar i stället för skrivbart rotfilsystem.
resource "kubernetes_deployment_v1" "web" {
metadata {
name = "web"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "web" })
}
spec {
replicas = var.repliker.web
selector {
match_labels = { app = "web" }
}
template {
metadata {
labels = merge(local.etiketter, { app = "web" })
}
spec {
automount_service_account_token = false
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "web"
image = local.tjanster.web.bild
port {
container_port = local.port_container
}
volume_mount {
name = "cache"
mount_path = "/var/cache/nginx"
}
volume_mount {
name = "run"
mount_path = "/tmp"
}
resources {
requests = { cpu = "50m", memory = "64Mi" }
limits = { cpu = "250m", memory = "128Mi" }
}
readiness_probe {
http_get {
path = "/"
port = local.port_container
}
initial_delay_seconds = 3
period_seconds = 10
}
liveness_probe {
http_get {
path = "/"
port = local.port_container
}
period_seconds = 15
}
security_context {
allow_privilege_escalation = false
read_only_root_filesystem = true
capabilities {
drop = ["ALL"]
}
}
}
volume {
name = "cache"
empty_dir {}
}
volume {
name = "run"
empty_dir {}
}
}
}
}
}
resource "kubernetes_service_v1" "web" {
metadata {
name = "web"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "web" })
}
spec {
selector = { app = "web" }
port {
port = local.port_tjanst
target_port = local.port_container
}
}
}
resource "kubernetes_horizontal_pod_autoscaler_v2" "web" {
metadata {
name = "web"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_replicas = var.repliker.web
max_replicas = var.max_repliker.web
scale_target_ref {
api_version = "apps/v1"
kind = "Deployment"
name = kubernetes_deployment_v1.web.metadata[0].name
}
metric {
type = "Resource"
resource {
name = "cpu"
target {
type = "Utilization"
average_utilization = 70
}
}
}
}
}
resource "kubernetes_pod_disruption_budget_v1" "web" {
metadata {
name = "web"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_available = 1
selector {
match_labels = { app = "web" }
}
}
}
+61
View File
@@ -0,0 +1,61 @@
# Trafiken utifrån och in. En domän, fyra prefix — se local.routing.
#
# 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.
resource "kubernetes_ingress_v1" "denna" {
metadata {
name = local.namn
namespace = kubernetes_namespace_v1.denna.metadata[0].name
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"
}
}
spec {
ingress_class_name = var.ingress_klass
tls {
hosts = [var.doman]
secret_name = "${local.namn}-tls"
}
rule {
host = var.doman
http {
dynamic "path" {
for_each = local.routing
content {
path = path.value.prefix
path_type = "Prefix"
backend {
service {
name = path.value.till
port {
number = local.port_tjanst
}
}
}
}
}
}
}
}
depends_on = [
kubernetes_service_v1.web,
kubernetes_service_v1.plattform,
kubernetes_service_v1.orkester,
]
}
+241
View File
@@ -0,0 +1,241 @@
# Vem får prata med vem.
#
# Utan nätverkspolicyer är ett Kubernetes-kluster platt: vilken pod som
# helst når databasen på 5432. Här stängs allt först, och bara de
# faktiska flödena öppnas — samma flöden som står i local.dataflode.
#
# Kräver en CNI som tillämpar NetworkPolicy (Cilium, Calico, Antrea).
# Med en CNI som inte gör det är de här reglerna dokumentation, inte
# skydd — kontrollera det innan ni litar på dem.
# Grundregel: ingen inkommande trafik alls i namnrymden.
resource "kubernetes_network_policy_v1" "neka_allt_in" {
metadata {
name = "neka-allt-inkommande"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {}
policy_types = ["Ingress"]
}
}
# Databasen: bara plattformstjänsten, bara 5432.
resource "kubernetes_network_policy_v1" "postgres_in" {
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"])
metadata {
name = "${each.key}-endast-fran-ingress"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {
match_labels = { app = each.key }
}
policy_types = ["Ingress"]
ingress {
from {
namespace_selector {
match_labels = {
"kubernetes.io/metadata.name" = var.ingress_namnrymd
}
}
}
ports {
port = tostring(local.port_container)
protocol = "TCP"
}
}
}
}
# Utgående: DNS åt alla, i övrigt bara det som faktiskt behövs.
resource "kubernetes_network_policy_v1" "dns_ut" {
metadata {
name = "dns-ut"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {}
policy_types = ["Egress"]
egress {
ports {
port = "53"
protocol = "UDP"
}
ports {
port = "53"
protocol = "TCP"
}
}
}
}
# Klienten är statisk och ringer ingenting från podden.
resource "kubernetes_network_policy_v1" "web_ut" {
metadata {
name = "web-inget-utgaende"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {
match_labels = { app = "web" }
}
policy_types = ["Egress"]
# Ingen egress-regel = inget utgående (utöver DNS-policyn ovan).
}
}
# Databasen ringer heller ingenting.
resource "kubernetes_network_policy_v1" "postgres_ut" {
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
# på nätverksnivå. Två oberoende spärrar för samma sak.
resource "kubernetes_network_policy_v1" "plattform_ut" {
metadata {
name = "plattform-utgaende"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {
match_labels = { app = "plattform" }
}
policy_types = ["Egress"]
egress {
to {
pod_selector {
match_labels = { app = "postgres" }
}
}
ports {
port = "5432"
protocol = "TCP"
}
}
egress {
to {
ip_block {
cidr = "0.0.0.0/0"
except = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16", # molnets metadatatjänst
"127.0.0.0/8",
"100.64.0.0/10",
]
}
}
ports {
port = "443"
protocol = "TCP"
}
}
}
}
# Orkestern: bara HTTPS ut till Claude.
resource "kubernetes_network_policy_v1" "orkester_ut" {
metadata {
name = "ai-orkester-utgaende"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
pod_selector {
match_labels = { app = "ai-orkester" }
}
policy_types = ["Egress"]
egress {
to {
ip_block {
cidr = "0.0.0.0/0"
except = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"127.0.0.0/8",
]
}
}
ports {
port = "443"
protocol = "TCP"
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
# Infrastrukturen
Hela systemet i Terraform. Filerna är numrerade i den ordning de är
begripliga att läsa:
| Fil | Vad |
| --- | --- |
| `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` | Postgres — händelseloggen |
| `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 |
| `70-natverk.tf` | Nätverkspolicyer — vem får prata med vem |
| `outputs.tf` | Kartan utskriven |
Börja i `karta.tf`. Den beskriver systemet en gång, som data; resten av
filerna läser därifrån i stället för att upprepa portar och namn.
## Kom igång
```sh
cp terraform.tfvars.exempel terraform.tfvars # fyll i domän, register, nycklar
terraform init
terraform plan
terraform apply
terraform output karta # hela systemet i klartext
```
`terraform output karta` svarar på frågorna "vad kör var", "vem ser
vilken hemlighet", "vad pratar med vad" och "vad ingår inte" — utan att
någon behöver läsa .tf-filerna.
## Kontroll utan kluster
```sh
terraform fmt -check -diff # formatering
terraform init -backend=false # hämtar leverantörer
terraform validate # typer och referenser
```
`terraform validate` kräver att leverantörerna hämtats från
registry.terraform.io.
## Förhållandet till kustomize och Argo CD
`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver **samma system**
i kustomize, synkat av Argo CD. Det är en historisk parallell väg.
**Välj en.** Kör båda mot samma kluster och de motarbetar varandra: Argo
CD:s `selfHeal` återställer det Terraform just ändrat, och `prune` tar
bort det Terraform skapat.
Rekommendationen är Terraform, av tre skäl:
1. Nätverkspolicyer, genererade hemligheter och (senare) molnresurser
som databas, DNS och objektlagring hör hemma här.
2. Kartan blir läsbar — `karta.tf` och `terraform output` finns inte i
kustomize-varianten.
3. Kustomize-vägen saknar i dag nätverkspolicyer och kör Postgres utan
säkerhetskontext.
Byter ni: ta bort Argo CD-applikationen (`kubectl -n argocd delete
application guidad-felsokning`) **innan** första `terraform apply`, och
importera befintliga resurser med `terraform import` om ni vill undvika
omstart. Behåller ni Argo CD i stället: applicera nätverkspolicyerna och
ingressens kroppsgräns därifrån också.
Bildtaggen kommer från publiceringsflödet oavsett väg — kör
`terraform apply -var bildtagg=<git-sha>` i stället för att låta CI
skriva i git.
## Det som medvetet inte ingår
Står också i `terraform output karta` under `avgränsningar`:
- **Säkerhetskopiering.** En StatefulSet med en PVC är inte backup. Sätt
CloudNativePG (basbackup, WAL-arkivering, PITR, failover) innan skarp
drift och peka `DATABASE_URL` på dess tjänst.
- **Objektlagring.** Foton och video ligger som data-URL:er i
händelseloggen. Det gör loggen till systemets enda sanningskälla, men
också stor och tung att säkerhetskopiera.
- **Observability.** Ingen metrikexport, ingen tracing.
- **Takt-begränsning på inloggning.** Bara den publika beslutsvägen är
begränsad, och bara per pod.
- **Återkallelse av JWT.** En utfärdad token gäller sina 12 timmar ut
även om användaren tagits bort.
## Krav på klustret
- En CNI som tillämpar NetworkPolicy (Cilium, Calico, Antrea). Utan det
är `70-natverk.tf` dokumentation, inte skydd.
- ingress-nginx, med kontrollern i namnrymden `var.ingress_namnrymd`.
- cert-manager med en ClusterIssuer.
- En metrics-server för autoskalningen.
- En StorageClass som klarar `ReadWriteOnce`.
+127
View File
@@ -0,0 +1,127 @@
# Kartan — hela systemet beskrivet som data.
#
# Resursfilerna läser härifrån i stället för att upprepa portar, namn och
# gränser. Det gör den här filen till ett ställe där man kan läsa av vad
# systemet faktiskt består av, och `terraform output karta` skriver ut
# samma sak i klartext.
locals {
namn = "guidad-felsokning"
etiketter = {
"app.kubernetes.io/part-of" = local.namn
"app.kubernetes.io/managed-by" = "terraform"
"guidad-felsokning/miljo" = var.miljo
}
# Alla tjänster lyssnar internt på 8080 och exponeras som port 80 i
# klustret. Databasen är den enda som inte följer mönstret.
port_container = 8080
port_tjanst = 80
# ---- Tjänsterna -----------------------------------------------------
#
# roll vad den gör
# bild containerbilden
# hemligt vilka hemligheter den får se (allt annat är den blind för)
# utat vem den ringer
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}"
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 = ["jwt-hemlighet", "postgres-losenord", "integration-nyckel"]
utat = ["postgres:5432", "kundernas leverantörer över internet (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}"
hemligt = ["anthropic-api-nyckel", "jwt-hemlighet"]
utat = ["api.anthropic.com"]
}
postgres = {
roll = "Händelseloggen — systemets enda sanningskälla. Append-only garanteras av databastriggers, inte bara av API:t."
bild = "postgres:17-alpine"
hemligt = ["postgres-losenord"]
utat = []
}
}
# ---- Routing --------------------------------------------------------
#
# Längsta prefix vinner. Ordningen här är den ordning ingressen får
# reglerna i.
routing = [
{ prefix = "/api/ai", till = "ai-orkester", varfor = "AI-anrop går till orkestern, aldrig via plattformen" },
{ prefix = "/api", till = "plattform", varfor = "allt övrigt API" },
{ prefix = "/halsa", till = "plattform", varfor = "hälsokontroll" },
{ prefix = "/", till = "web", varfor = "klienten" },
]
# ---- Hemligheterna --------------------------------------------------
#
# 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,
)
}
databas_url = "postgresql://plattform:${local.hemligheter["postgres-losenord"]}@postgres:5432/felsokning"
# ---- Dataflöden -----------------------------------------------------
#
# Det som är värt att förstå innan man ändrar något: vad som rör sig
# var, och var gränserna går.
dataflode = [
"Tekniker → web → ingress → plattform → postgres all ärendedata, append-only",
"Tekniker → web → ingress → orkester → Claude underlag ut, klassificerade svar in",
"Kund → ingress → plattform → postgres Live Share: bara tillåtna händelsetyper",
"Plattform → kundens leverantör VIN/regnr ut, fordonsuppgifter in",
]
granser = [
"Organisationsgränsen: varje fråga mot ärendedata filtreras på organisation_id i SQL:en, inte i klienten.",
"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.",
]
# ---- Det som medvetet inte ingår ------------------------------------
avgransningar = [
"Säkerhetskopiering av databasen. StatefulSet + PVC är inte backup — sätt CloudNativePG eller motsvarande innan skarp drift.",
"Objektlagring. Foton och video ligger som data-URL:er i händelseloggen, vilket gör volymen stor och tung att säkerhetskopiera.",
"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.",
]
}
# 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
}
+70
View File
@@ -0,0 +1,70 @@
# Kartan utskriven.
#
# terraform output karta hela systemet i klartext
# terraform output -json karta samma sak maskinläsbart
output "karta" {
description = "Hela systemet: tjänster, routing, hemligheter, dataflöden och gränser."
value = {
miljo = var.miljo
domän = "https://${var.doman}"
namnrymd = var.namnrymd
bildtagg = var.bildtagg
tjanster = {
for namn, t in local.tjanster : namn => {
gör = t.roll
bild = t.bild
ser_hemlighet = length(t.hemligt) > 0 ? join(", ", t.hemligt) : "inga"
ringer = length(t.utat) > 0 ? join("; ", t.utat) : "ingenting"
}
}
routing = [
for r in local.routing : "https://${var.doman}${r.prefix}${r.till} (${r.varfor})"
]
dataflöden = local.dataflode
gränser = local.granser
databas = {
motor = "PostgreSQL 17"
volym = var.databas_storlek
säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll"
backup = "INGEN i den här definitionen — se avgränsningar"
}
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}"
}
avgränsningar = local.avgransningar
}
}
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/<kod>"
}
}
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)"
}
}
+37
View File
@@ -0,0 +1,37 @@
# Kopiera till terraform.tfvars och fyll i. Checka aldrig in riktiga
# nycklar — .gitignore utesluter *.tfvars.
doman = "app.exempel.se"
register = "ghcr.io/min-organisation"
bildtagg = "ersatt-med-git-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.
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
databas_storlek = "50Gi"
# lagringsklass = "premium-rwo"
repliker = {
web = 2
plattform = 2
orkester = 2
}
+182
View File
@@ -0,0 +1,182 @@
# 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ö.
# ---- Kluster -----------------------------------------------------------
variable "kubeconfig" {
description = "Sökväg till kubeconfig."
type = string
default = "~/.kube/config"
}
variable "kube_context" {
description = "Kubernetes-kontext att driftsätta i. Tom = kubeconfigens aktuella."
type = string
default = ""
}
variable "namnrymd" {
description = "Namnrymd för hela systemet."
type = string
default = "guidad-felsokning"
}
variable "miljo" {
description = "Miljöns namn (produktion, test, demo). 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.
TEXT
type = string
validation {
condition = var.bildtagg != ""
error_message = "Ange en bildtagg — helst git-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" {
description = "Om vem som helst får skapa en ny organisation. Stäng i produktion."
type = bool
default = false
}
variable "tillat_interna_uppslag" {
description = <<-TEXT
Tillåter märkesspecifika uppslag mot privata nät. Behövs bara när
verkstaden har en OEM-server på sitt eget nät. Öppnar samtidigt en
väg från en tenant-administratör in i klustrets interna adresser —
lämna av om ni inte vet att ni behöver det.
TEXT
type = bool
default = false
}
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.
TEXT
type = number
default = 8
}
variable "repliker" {
description = "Grundantal repliker per tjänst (autoskalning tar över uppåt)."
type = object({
web = number
plattform = number
orkester = number
})
default = {
web = 2
plattform = 2
orkester = 2
}
}
variable "max_repliker" {
description = "Tak för autoskalningen per tjänst."
type = object({
web = number
plattform = number
orkester = number
})
default = {
web = 10
plattform = 10
orkester = 10
}
}
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 = ""
}
+47
View File
@@ -0,0 +1,47 @@
# Guidad Felsökning — infrastrukturens definition.
#
# 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`
terraform {
required_version = ">= 1.6"
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.35"
}
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.
#
# backend "s3" {
# bucket = "guidad-felsokning-tfstate"
# key = "produktion/terraform.tfstate"
# region = "eu-north-1"
# encrypt = true
# use_lockfile = true
# }
}
provider "kubernetes" {
config_path = var.kubeconfig
config_context = var.kube_context
}