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:
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user