Merge main: Guidad Felsökning flyttar ur roten till felsokning/

Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.

För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:

  felsokning/app        webbklienten (Vite, egen package.json och
                        eslint-/vitest-konfiguration)
  felsokning/services   plattformstjänsten och AI-orkestern
  felsokning/infra      Terraform och databasschemat
  felsokning/docs       vision, moduler, drift
  felsokning/supabase   edge-funktion och migrationer

Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.

Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.

CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.

Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-04 12:22:49 +00:00
298 changed files with 23491 additions and 6140 deletions
+146
View File
@@ -0,0 +1,146 @@
-- Guidad Felsökning: schema för den självhostade plattformen.
-- Multi-tenant: varje organisation är en egen tenant — all ärendedata är
-- organisationsknuten och API:t släpper aldrig data över organisationsgränsen.
-- Append-only garanteras med triggers: historik kan inte ändras eller
-- raderas oavsett vilken roll som ansluter.
create extension if not exists pgcrypto;
create table if not exists organisationer (
id uuid primary key default gen_random_uuid(),
namn text not null,
-- Vad som visas när ett ärende startas (objekttyper,
-- identifieringsmetoder) — sätts av systemadministratören.
installningar jsonb not null default '{}'::jsonb,
skapad timestamptz not null default now()
);
alter table organisationer add column if not exists installningar jsonb not null default '{}'::jsonb;
-- Märkesspecifika kopplingar per organisation. Uppgifterna lagras
-- krypterade (AES-256-GCM, nyckel ur INTEGRATION_NYCKEL) och lämnar
-- aldrig servern i klartext — klienten ser bara maskerade värden.
create table if not exists integrationer (
organisation_id uuid not null references organisationer(id),
leverantor text not null,
uppgifter_krypt text not null,
aktiv boolean not null default true,
skapad timestamptz not null default now(),
uppdaterad timestamptz not null default now(),
senast_testad timestamptz,
senaste_status text,
primary key (organisation_id, leverantor)
);
create table if not exists anvandare (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
epost text unique not null,
losen_hash text not null,
namn text not null,
roll text not null default 'tekniker'
check (roll in ('tekniker', 'arbetsledare', 'admin')),
-- En avaktiverad användare kan varken logga in eller använda en token
-- som redan är utfärdad.
aktiv boolean not null default true,
-- Räknare för återkallelse. Utfärdade tokens bär sitt värde; höjs det
-- slutar alla tidigare utfärdade att gälla omedelbart. Loggen rörs
-- inte — historiken är fortfarande knuten till personen.
token_version integer not null default 0,
skapad timestamptz not null default now()
);
alter table anvandare add column if not exists aktiv boolean not null default true;
alter table anvandare add column if not exists token_version integer not null default 0;
-- Inloggningsförsök: underlag för takt-begränsning som fungerar bakom
-- flera repliker (den i minnet gör det inte). Inget lösenord lagras —
-- bara att ett försök skedde och om det lyckades.
create table if not exists inloggningsforsok (
id bigserial primary key,
epost text not null,
kalla text not null default '',
lyckades boolean not null,
tidpunkt timestamptz not null default now()
);
create index if not exists inloggningsforsok_epost_idx
on inloggningsforsok (epost, tidpunkt desc);
create index if not exists inloggningsforsok_kalla_idx
on inloggningsforsok (kalla, tidpunkt desc);
create table if not exists felsokning_arenden (
id text primary key,
organisation_id uuid not null references organisationer(id),
nummer integer not null,
skapad timestamptz not null,
delningskod text unique,
metodik_id text,
skapad_av uuid references anvandare(id),
insatt timestamptz not null default now()
);
create index if not exists felsokning_arenden_org_idx
on felsokning_arenden (organisation_id, skapad desc);
-- Bilagor: foton, video och instrumentbilder. Innehållet ligger utanför
-- händelsen; loggen bär en referens och innehållets SHA-256. Hashen står
-- i den append-only-skyddade loggen, så en utbytt bild går att upptäcka —
-- starkare bevisvärde än när bilden låg inbäddad och måste tros på.
create table if not exists bilagor (
id text primary key,
organisation_id uuid not null references organisationer(id),
arende_id text not null,
hash text not null,
mediatyp text not null,
storlek integer not null,
laddad_av uuid references anvandare(id),
skapad timestamptz not null default now()
);
create index if not exists bilagor_arende_idx on bilagor (arende_id);
create index if not exists bilagor_hash_idx on bilagor (hash);
-- Själva bytesen när BILAGE_LAGE=databas. Innehållsadresserat: samma
-- foto som dokumenteras två gånger lagras en gång. I s3-läget är den
-- här tabellen tom och innehållet ligger i objektlagringen.
create table if not exists bilage_innehall (
hash text primary key,
data bytea not null,
skapad timestamptz not null default now()
);
create table if not exists felsokning_handelser (
id text primary key,
arende_id text not null references felsokning_arenden(id),
tidpunkt timestamptz not null,
anvandare text not null,
handelse jsonb not null,
insatt timestamptz not null default now()
);
create index if not exists felsokning_handelser_arende_idx
on felsokning_handelser (arende_id, tidpunkt);
create or replace function forbjud_andring() returns trigger
language plpgsql as $$
begin
raise exception 'Loggen är append-only — historik kan inte ändras eller raderas';
end $$;
drop trigger if exists handelser_append_only on felsokning_handelser;
create trigger handelser_append_only
before update or delete on felsokning_handelser
for each row execute function forbjud_andring();
drop trigger if exists arenden_append_only on felsokning_arenden;
create trigger arenden_append_only
before update or delete on felsokning_arenden
for each row execute function forbjud_andring();
-- Live Share-delningar: återkallbara länkar med behörighetsnivå.
-- (Åtkomststyrning, inte journal — därför ingen append-only-trigger:
-- återkallelse sätter aterkallad-tidpunkten.)
create table if not exists delningar (
kod text primary key,
arende_id text not null references felsokning_arenden(id),
niva text not null check (niva in ('kund', 'partner', 'intern')),
skapad_av uuid references anvandare(id),
skapad timestamptz not null default now(),
aterkallad timestamptz
);
create index if not exists delningar_arende_idx on delningar (arende_id);
+43
View File
@@ -0,0 +1,43 @@
# 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.
# 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")
}
}
+374
View File
@@ -0,0 +1,374 @@
# 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
}
}
}
+253
View File
@@ -0,0 +1,253 @@
# 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"
}
}
}
# 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}"
}
env {
name = "REGISTRERING_OPPEN"
value = var.registrering_oppen ? "true" : "false"
}
env {
name = "TILLAT_INTERNA_UPPSLAG"
value = var.tillat_interna_uppslag ? "true" : "false"
}
# Bilagornas innehåll: i databasen eller i objektlagring.
env {
name = "BILAGE_LAGE"
value = var.bilage_lage
}
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
}
}
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
}
}
}
}
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,
]
}
+266
View File
@@ -0,0 +1,266 @@
# 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. 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"])
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" {
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
# 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"]
# 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"
}
}
}
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"
}
}
# Managerad databas utanför klustret: 5432 mot leverantören.
dynamic "egress" {
for_each = local.extern_databas ? [1] : []
content {
ports {
port = "5432"
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"
}
}
}
}
+109
View File
@@ -0,0 +1,109 @@
# 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` | Händelseloggen — tre lägen, se nedan |
| `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.
## Databasen: tre lägen
`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.
| 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"` |
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.
## Driftsättning
Bilderna byggs av `publicera.yml` vid varje push till main och taggas med
git-SHA:t. Driftsättningen är ett eget flöde, `driftsatt.yml`, som
startas för hand med en tagg och kör mot GitHub-miljön `produktion` — den
kan kräva godkännande. Rollback är att köra flödet igen med en tidigare
tagg.
Kustomize- och Argo CD-vägen är borttagen. Den beskrev samma system en
gång till och kunde inte köras samtidigt som Terraform utan att de
motarbetade varandra (`selfHeal` återställde det Terraform ändrade,
`prune` tog bort det Terraform skapade).
## Det som medvetet inte ingår
Står också i `terraform output karta` under `avgränsningar`:
- **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`.
+150
View File
@@ -0,0 +1,150 @@
# 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 = 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"] : [],
)
}
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 = 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"] : []
}
}
# ---- 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,
)
"s3-nyckel-id" = var.s3_nyckel_id
"s3-nyckel" = var.s3_nyckel
}
# ---- 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",
]
bilagor = {
lage = var.bilage_lage
var = var.bilage_lage == "s3" ? "${var.s3.endpoint}/${var.s3.hink}/${var.s3.prefix}" : "tabellen bilage_innehall"
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.",
])
}
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.",
"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.",
],
)
}
# 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
}
+86
View File
@@ -0,0 +1,86 @@
# 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 = {
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)"
)
}
bilagor = local.bilagor
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)"
}
}
@@ -0,0 +1,61 @@
# 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
# --- 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
}
+311
View File
@@ -0,0 +1,311 @@
# 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
}
}
# ---- Databas -----------------------------------------------------------
variable "databas_lage" {
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.
TEXT
type = object({
mal = string
endpoint = optional(string, "")
behall_dagar = optional(number, 30)
})
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 = ""
}
+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
}