Terraform blir enda vägen, och backup ett obligatoriskt val

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 och
prune tog bort det Terraform skapade. Kvar utanför Terraform är bara
postgres-init.sql, som läses av både definitionen och integrationstestet
så att schemat inte kan glida isär från det som testas.

Säkerhetskopieringen var en förhoppning: en StatefulSet med en volym och
ingen kopia. Går volymen förlorad är det inte "data" som försvinner utan
varje ärendes bevisvärde — vad som kontrollerades, av vem, när, med
vilken evidens — och det går inte att återskapa i efterhand.

databas_lage är därför ett obligatoriskt val utan standardvärde:

  extern    managerad Postgres, leverantörens backup och PITR
            (rekommenderat i produktion)
  cnpg      CloudNativePG i klustret: basbackup 02:30, kontinuerlig
            WAL-arkivering till objektlagring, PITR och failover
  inbyggd   en volym, ingen backup — spärras av en precondition när
            miljön är produktion

Preconditions fångar felkonfiguration vid plan i stället för vid drift:
extern utan anslutning, cnpg utan backupmål eller nycklar, inbyggd i
produktion.

Driftsättningen är nu två åtskilda flöden. Publicera bygger och taggar
bilderna vid varje main-push; Driftsätt startas för hand med en tagg mot
en GitHub-miljö som kan kräva godkännande, kör fmt/init/validate/plan/
apply, skriver ut kartan och rökkontrollerar hälsa och API-spec. En bild
i registret är inte samma sak som en bild som kör. Rollback är att köra
Driftsätt igen med en tidigare tagg. CI kör dessutom terraform validate
på varje PR — den kontroll jag inte kunde köra själv.

Två fel hittade vid egengranskning av definitionen: schemafilen delades
på semikolon, vilket hade klippt itu plpgsql-funktionen med
append-only-triggern (nu hela filen via postInitApplicationSQLRefs), och
null-satta fält i kubernetes_manifest utelämnas nu i stället.

Verifierat: 87 vitest-tester, typkontroll, eslint, OpenAPI-validering,
terraform fmt, statisk referenskontroll av modulen och integrationstest
mot riktig Postgres. terraform validate kunde inte köras här —
registry.terraform.io är blockerad av sessionens egress-policy, därav
CI-jobbet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 22:16:52 +00:00
parent cbc7bf2751
commit 19796bec70
27 changed files with 626 additions and 580 deletions
+17
View File
@@ -33,6 +33,23 @@ jobs:
- run: sudo bash integrationstest.sh - run: sudo bash integrationstest.sh
working-directory: services/plattform working-directory: services/plattform
# Kontrollerar infrastrukturens definition utan kluster: formatering,
# leverantörer, typer och referenser — och att alla tre databaslägena
# går att planera.
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra/terraform
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- run: terraform fmt -check -recursive
- run: terraform init -backend=false -input=false
- run: terraform validate
container-byggen: container-byggen:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+103
View File
@@ -0,0 +1,103 @@
# Driftsättning: terraform apply mot klustret.
#
# Startas för hand med en bildtagg från publiceringsflödet. Det är
# avsiktligt — driftsättning är ett beslut, och miljön `produktion` kan
# kräva godkännande i GitHub innan jobbet får köra.
#
# Rollback = kör flödet igen med en tidigare tagg.
#
# Krävs på miljön `produktion`:
# secrets KUBE_CONFIG (base64), TF_API_TOKEN eller backend-uppgifter,
# ANTHROPIC_API_NYCKEL, DATABAS_URL
# vars DOMAN, REGISTER, DATABAS_LAGE
name: Driftsätt
on:
workflow_dispatch:
inputs:
bildtagg:
description: "Bildtagg att driftsätta (git-SHA från Publicera)"
required: true
type: string
bara_plan:
description: "Visa bara planen, applicera inte"
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
driftsatt:
runs-on: ubuntu-latest
environment: produktion
defaults:
run:
working-directory: infra/terraform
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- name: Kubeconfig
run: |
mkdir -p "$HOME/.kube"
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
- name: Formatering
run: terraform fmt -check -recursive
- name: Init
run: terraform init -input=false
- name: Validering
run: terraform validate
- name: Plan
run: terraform plan -input=false -out=plan.tfplan
env:
TF_VAR_bildtagg: ${{ inputs.bildtagg }}
TF_VAR_doman: ${{ vars.DOMAN }}
TF_VAR_register: ${{ vars.REGISTER }}
TF_VAR_databas_lage: ${{ vars.DATABAS_LAGE }}
TF_VAR_databas_url: ${{ secrets.DATABAS_URL }}
TF_VAR_anthropic_api_nyckel: ${{ secrets.ANTHROPIC_API_NYCKEL }}
TF_VAR_jwt_hemlighet: ${{ secrets.JWT_HEMLIGHET }}
TF_VAR_postgres_losenord: ${{ secrets.POSTGRES_LOSENORD }}
TF_VAR_integration_nyckel: ${{ secrets.INTEGRATION_NYCKEL }}
- name: Applicera
if: ${{ !inputs.bara_plan }}
run: terraform apply -input=false plan.tfplan
- name: Kartan efter driftsättning
if: ${{ !inputs.bara_plan }}
run: |
{
echo "### Driftsatt \`${{ inputs.bildtagg }}\`"
echo ""
echo '```'
terraform output -no-color karta
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Rök: klienten, API-specen och hälsokontrollen ska svara.
- name: Rökkontroll
if: ${{ !inputs.bara_plan }}
run: |
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "https://${{ vars.DOMAN }}/halsa" > /dev/null; then
echo "hälsokontrollen svarar"
curl -fsS "https://${{ vars.DOMAN }}/api/openapi.yaml" | head -1
exit 0
fi
echo "väntar på att tjänsten ska svara ($i/10)"
sleep 15
done
echo "tjänsten svarade inte inom 150 s"
exit 1
+20 -22
View File
@@ -1,17 +1,15 @@
# CD-halvan av GitOps-flödet: varje push till main bygger och publicerar # Bygger och publicerar de tre bilderna till GHCR, taggade med git-SHA:t.
# de tre bilderna till GHCR taggade med git-SHA:t, och uppdaterar sedan # Driftsättningen sker i ett eget flöde (driftsatt.yml) som kör
# produktions-overlayens taggar i git. Argo CD ser ändringen och synkar # terraform apply mot en miljö med godkännandekrav — en bild som ligger i
# klustret — ingen kubectl-åtkomst från CI, klustret drar själv. # registret är alltså inte samma sak som en bild som kör.
name: Publicera name: Publicera
on: on:
push: push:
branches: [main] branches: [main]
paths-ignore:
- "infra/overlays/**" # gitops-commiten ska inte trigga en ny publicering
permissions: permissions:
contents: write contents: read
packages: write packages: write
env: env:
@@ -28,34 +26,34 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Klientens API-adress bakas in vid bygget (Vite), så bilden är
# miljöbunden — en bild byggd mot en domän pekar på fel API i en
# annan miljö.
- name: Bygg och publicera web - name: Bygg och publicera web
run: | run: |
docker build -t "$REGISTER/guidad-felsokning-web:${{ github.sha }}" \ docker build -t "$REGISTER/guidad-felsokning-web:${{ github.sha }}" \
--build-arg VITE_PLATTFORM_URL="${{ vars.PLATTFORM_URL }}" \ --build-arg VITE_PLATTFORM_URL="${{ vars.PLATTFORM_URL }}" \
--build-arg VITE_AI_ORKESTER_URL="${{ vars.PLATTFORM_URL }}" . --build-arg VITE_AI_ORKESTER_URL="${{ vars.PLATTFORM_URL }}" .
docker push "$REGISTER/guidad-felsokning-web:${{ github.sha }}" docker push "$REGISTER/guidad-felsokning-web:${{ github.sha }}"
- name: Bygg och publicera ai-orkester - name: Bygg och publicera ai-orkester
run: | run: |
docker build -t "$REGISTER/guidad-felsokning-ai-orkester:${{ github.sha }}" services/ai-orkester docker build -t "$REGISTER/guidad-felsokning-ai-orkester:${{ github.sha }}" services/ai-orkester
docker push "$REGISTER/guidad-felsokning-ai-orkester:${{ github.sha }}" docker push "$REGISTER/guidad-felsokning-ai-orkester:${{ github.sha }}"
- name: Bygg och publicera plattform - name: Bygg och publicera plattform
run: | run: |
docker build -t "$REGISTER/guidad-felsokning-plattform:${{ github.sha }}" services/plattform docker build -t "$REGISTER/guidad-felsokning-plattform:${{ github.sha }}" services/plattform
docker push "$REGISTER/guidad-felsokning-plattform:${{ github.sha }}" docker push "$REGISTER/guidad-felsokning-plattform:${{ github.sha }}"
- name: Uppdatera produktions-overlayen (GitOps) - name: Sammanfatta
run: | run: |
curl -sL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz" | tar xz {
cd infra/overlays/produktion echo "### Publicerat"
for tjanst in web ai-orkester plattform; do echo ""
../../../kustomize edit set image \ echo '```'
"ghcr.io/ORGANISATION/guidad-felsokning-$tjanst=$REGISTER/guidad-felsokning-$tjanst:${{ github.sha }}" echo "bildtagg: ${{ github.sha }}"
done echo '```'
../../../kustomize build . > /dev/null # verifiera att overlayen fortfarande bygger echo ""
- name: Committa overlay-uppdateringen echo "Driftsätt med flödet **Driftsätt** och den taggen."
run: | } >> "$GITHUB_STEP_SUMMARY"
git config user.name "gitops-bot"
git config user.email "gitops@users.noreply.github.com"
git add infra/overlays/produktion/kustomization.yaml
git diff --cached --quiet || git commit -m "gitops: driftsätt ${{ github.sha }} [skip ci]"
git push
+54 -60
View File
@@ -21,7 +21,7 @@ flowchart LR
| `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB | | `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB |
| `plattform` | Självhostad backend (`services/plattform`): **multi-tenant** — registrering skapar organisation + systemadministratör, admin hanterar användare (tekniker/arbetsledare/admin), all ärendedata organisationsisolerad. Inloggning (bcrypt via pgcrypto, HS256-JWT med roll + org i anspråken), append-only händelse-API, publik delningsendpoint | Deployment + Service + HPA + PDB | | `plattform` | Självhostad backend (`services/plattform`): **multi-tenant** — registrering skapar organisation + systemadministratör, admin hanterar användare (tekniker/arbetsledare/admin), all ärendedata organisationsisolerad. Inloggning (bcrypt via pgcrypto, HS256-JWT med roll + org i anspråken), append-only händelse-API, publik delningsendpoint | Deployment + Service + HPA + PDB |
| `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB | | `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB |
| `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR | | `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | Tre lägen: extern managerad Postgres (rekommenderat), CloudNativePG i klustret, eller en enkel StatefulSet utan backup för prov |
| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord`, `integration-nyckel` (krypterar kundernas märkesspecifika credentials) | | Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord`, `integration-nyckel` (krypterar kundernas märkesspecifika credentials) |
| Miljöflaggor | `TILLATNA_URSPRUNG` (CORS-lista; utelämnad = `*`), `TILLAT_INTERNA_UPPSLAG` (`true` tillåter leverantörsuppslag mot privata nät), `REGISTRERING_OPPEN`, `ECM_REGLER_FIL`, `INTEGRATIONER_FIL` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | | Miljöflaggor | `TILLATNA_URSPRUNG` (CORS-lista; utelämnad = `*`), `TILLAT_INTERNA_UPPSLAG` (`true` tillåter leverantörsuppslag mot privata nät), `REGISTRERING_OPPEN`, `ECM_REGLER_FIL`, `INTEGRATIONER_FIL` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest |
@@ -34,11 +34,11 @@ Börja i `karta.tf`: hela systemet beskrivet en gång som data (tjänster,
portar, routing, hemligheter, dataflöden, gränser). `terraform output portar, routing, hemligheter, dataflöden, gränser). `terraform output
karta` skriver ut samma sak i klartext. karta` skriver ut samma sak i klartext.
`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver samma system i Definitionen omfattar hemligheter, databasschema, nätverksgränser och
kustomize, synkat av Argo CD. **Kör inte båda mot samma kluster** — Argo alla tre databaslägena. Kustomize- och Argo CD-vägen är borttagen —
CD:s `selfHeal` återställer det Terraform ändrar och `prune` tar bort det `infra/postgres-init.sql` är det enda som blivit kvar utanför Terraform,
Terraform skapar. Terraform-vägen har dessutom nätverkspolicyer och och den läses av både Terraform och integrationstestet så att schemat
säkerhetskontext på databasen, vilket kustomize-vägen saknar. inte kan glida isär från det som testas.
## Nätverksgränser ## Nätverksgränser
@@ -61,59 +61,52 @@ dokumentation, inte skydd.
## Driftsätta ## Driftsätta
Allt går genom Terraform — hemligheter, schema och nätverksgränser
ingår. Det finns ingen `kubectl apply` att komma ihåg.
```sh ```sh
# 1. Bygg och publicera bilderna (ersätt registry i infra/k8s/*.yaml) cd infra/terraform
docker build -t ghcr.io/ORG/guidad-felsokning-web \ cp terraform.tfvars.exempel terraform.tfvars # domän, register, databasläge, nycklar
--build-arg VITE_PLATTFORM_URL=https://app.exempel.se . terraform init
docker build -t ghcr.io/ORG/guidad-felsokning-ai-orkester services/ai-orkester terraform plan
docker build -t ghcr.io/ORG/guidad-felsokning-plattform services/plattform terraform apply -var bildtagg=<git-sha>
docker push ghcr.io/ORG/guidad-felsokning-web
docker push ghcr.io/ORG/guidad-felsokning-ai-orkester
docker push ghcr.io/ORG/guidad-felsokning-plattform
# 2. Skapa secret:en (eller använd External Secrets/Sealed Secrets) terraform output karta # hela systemet i klartext
kubectl create namespace guidad-felsokning terraform output endpoints # adresser att kontrollera
kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
--from-literal=anthropic-api-key='sk-ant-…' \
--from-literal=jwt-secret="$(openssl rand -base64 48)" \
--from-literal=postgres-losenord="$(openssl rand -base64 24)" \
--from-literal=integration-nyckel="$(openssl rand -hex 32)"
# 3. Applicera manifesten (Postgres initieras med schema + append-only-triggers)
kubectl apply -k infra/k8s
# 4. Verifiera
kubectl -n guidad-felsokning get pods
curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen)
curl https://app.exempel.se/api/openapi.yaml # API-first: hela API-specen
``` ```
Med Terraform i stället: `cd infra/terraform && terraform apply -var bildtagg=<git-sha>`. Sedan:
Byt domän och cert-issuer i `infra/k8s/ingress.yaml` (eller `var.doman` i Terraform). Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör. ```sh
curl https://app.exempel.se/halsa # → {"status":"ok"}
curl https://app.exempel.se/api/openapi.yaml # hela API-specen
```
## Märkesspecifika kopplingar Klustret behöver: en CNI som tillämpar NetworkPolicy, ingress-nginx,
cert-manager, en metrics-server och en StorageClass med ReadWriteOnce.
Varje verkstad har sina egna avtal med tillverkare och dataleverantörer. Att skapa nya organisationer är stängt som standard
Kopplingarna konfigureras därför av kunden själv under **Inställningar → (`registrering_oppen = false`); användare inom en organisation skapas
Märkesspecifika kopplingar**: systemadministratören väljer leverantör och alltid av dess systemadministratör.
fyller i sina credentials.
* **Uppgifterna når aldrig webbläsaren.** De krypteras med AES-256-GCM ## Databasen: valet som avgör om det finns backup
(`INTEGRATION_NYCKEL`, 32 byte hex eller base64) innan de skrivs till
tabellen `integrationer`, och API:t returnerar hemliga fält maskerade `databas_lage` saknar standardvärde med flit.
(`••••3456`). Alla uppslag mot leverantören görs av servern.
* **Fail closed.** Saknas `INTEGRATION_NYCKEL` sparas ingenting — API:t | Läge | Backup | Failover | Använd när |
svarar 503 och inställningssidan säger varför. Inga uppgifter hamnar | --- | --- | --- | --- |
någonsin i klartext. | `extern` | Leverantörens, med PITR | Leverantörens | **Produktion.** Cloud SQL, RDS, Neon, Azure |
* **Leverantörer är data, inte kod.** Registret ligger i | `cnpg` | Basbackup 02:30 + WAL-arkiv → objektlagring, PITR | Ja | Produktion när databasen måste ligga i klustret |
`services/plattform/integrationer.json` och kan bytas mot en | `inbyggd` | **Ingen** | Nej | Prov och demo — spärras när `miljo = "produktion"` |
ConfigMap-mount via `INTEGRATIONER_FIL`. Nya märken läggs till genom
att beskriva URL-mall, autentiseringstyp och svarsmappning — ingen Går händelseloggen förlorad är det inte "data" som försvinner utan varje
ombyggnad av applikationen krävs. ärendes bevisvärde: vad som kontrollerades, av vem, när, med vilken
* **Testresultat loggas på kopplingen.** Varje uppslag skriver evidens. Det går inte att återskapa i efterhand.
`senast_testad` och `senaste_status`, så ett trasigt abonnemang syns i
inställningarna i stället för att tyst ge tomma svar. `cnpg` kräver CloudNativePG-operatorn installerad först — Terraform slår
upp dess CRD redan vid plan. I `extern` läge kör ni
`infra/postgres-init.sql` mot databasen själva; det är samma fil som
integrationstestet kör.
## Multi-tenant och roller ## Multi-tenant och roller
@@ -137,18 +130,19 @@ Integrationstestet (`services/plattform/integrationstest.sh`, körs även i CI m
**CI** (`.github/workflows/ci.yml`): tester, produktionsbygge, integrationstest mot riktig Postgres och verifierande containerbyggen på varje push/PR. **CI** (`.github/workflows/ci.yml`): tester, produktionsbygge, integrationstest mot riktig Postgres och verifierande containerbyggen på varje push/PR.
**CD** (`.github/workflows/publicera.yml` + Argo CD): klustret följer git — ingen CI-process har kubectl-åtkomst. **CD** — två flöden, medvetet åtskilda: en bild i registret är inte samma sak som en bild som kör.
```mermaid ```mermaid
flowchart LR flowchart LR
P[Push till main] --> B[Bygg + publicera\n3 bilder till GHCR\ntaggade med git-SHA] P[Push till main] --> B[Publicera:\nbygger 3 bilder\ntaggade med git-SHA] --> G[(GHCR)]
B --> O[Uppdatera\ninfra/overlays/produktion\n+ commit till git] G -.-> D[Driftsätt:\nstartas för hand\nmed en tagg]
O --> A[Argo CD ser ändringen] --> S[Synkar klustret\nprune + selfHeal] D --> M[miljö: produktion\ngodkännande] --> T[terraform apply] --> K[Klustret]
T --> R[Rökkontroll\nhälsa + API-spec]
``` ```
1. Varje main-push bygger de tre bilderna, publicerar till GHCR (`GITHUB_TOKEN`, inga externa hemligheter) och uppdaterar produktions-overlayens taggar med `kustomize edit set image` — overlayen ombyggs som verifiering innan commiten. 1. **Publicera** vid varje main-push: bygger de tre bilderna och taggar med git-SHA:t (`GITHUB_TOKEN`, inga externa hemligheter).
2. **Argo CD är enda vägen in i klustret.** Bootstrap en gång: installera Argo CD, ersätt repo-URL:en i `infra/gitops/argocd-application.yaml` och `kubectl apply -f` den. Därefter: `prune` tar bort det som försvinner ur git, `selfHeal` återställer manuella klusteravvikelser. 2. **Driftsätt** startas för hand med en tagg, mot GitHub-miljön `produktion` som kan kräva godkännande. Kör `fmt`, `init`, `validate`, `plan`, `apply`, skriver ut kartan och rökkontrollerar hälsa och API-spec. `bara_plan` visar planen utan att applicera.
3. **Rollback = `git revert`** av gitops-commiten — Argo CD synkar tillbaka föregående SHA-taggade bilder. 3. **Rollback** = kör Driftsätt igen med en tidigare tagg.
4. Repo-variabeln `PLATTFORM_URL` (Settings → Variables) styr webbyggets `VITE_PLATTFORM_URL`/`VITE_AI_ORKESTER_URL`. Hemligheten `felsokning-hemligheter` ligger utanför både git och synken.
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 och `prune` tog bort det Terraform skapade.
Manuell `kubectl apply -k infra/k8s` (avsnittet Driftsätta ovan) fungerar fortfarande för miljöer utan Argo CD.
+1 -1
View File
@@ -55,7 +55,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. | | Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. |
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. |
| Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). | | Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). |
| Infrastruktur som kod | ✅ `infra/terraform` är systemets definition ([README](../infra/terraform/README.md)): `karta.tf` beskriver hela systemet en gång som data — tjänster, portar, routing, hemligheter per tjänst, dataflöden och gränser — och `terraform output karta` skriver ut samma sak i klartext. Namnrymden är stängd med nätverkspolicyer (bara ingress→tjänster, plattform→postgres, HTTPS ut utom privata nät), Postgres kör med säkerhetskontext, hemligheter kan genereras eller komma från en secrets-hanterare. Kustomize-/Argo CD-vägen finns kvar men de två ska inte köras mot samma kluster — dokumenterat med rekommendation och bytesväg. | | Infrastruktur som kod | ✅ `infra/terraform` är systemets definition ([README](../infra/terraform/README.md)): `karta.tf` beskriver hela systemet en gång som data — tjänster, portar, routing, hemligheter per tjänst, dataflöden och gränser — och `terraform output karta` skriver ut samma sak i klartext. Namnrymden är stängd med nätverkspolicyer (bara ingress→tjänster, plattform→postgres, HTTPS ut utom privata nät), Postgres kör med säkerhetskontext, hemligheter kan genereras eller komma från en secrets-hanterare. **Databasen har tre lägen** och `databas_lage` saknar standardvärde med flit — valet avgör om det finns säkerhetskopiering: `extern` (managerad Postgres, leverantörens PITR — rekommenderat i produktion), `cnpg` (CloudNativePG i klustret: basbackup 02:30 + WAL-arkivering + failover) och `inbyggd` (en volym, ingen backup, spärrad av en precondition när miljön är produktion). Driftsättning är ett eget CI-flöde som startas för hand med en bildtagg mot en miljö med godkännandekrav, kör plan/apply, skriver ut kartan och rökkontrollerar. Kustomize-/Argo CD-vägen är borttagen. |
| Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | | Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. |
## Arkitekturprinciper i koden ## Arkitekturprinciper i koden
-27
View File
@@ -1,27 +0,0 @@
# Argo CD-applikationen: klustret följer git — appliceras EN gång i
# argocd-namespacet, därefter sköter Argo CD all synk automatiskt.
#
# kubectl apply -f infra/gitops/argocd-application.yaml
#
# prune tar bort resurser som försvunnit ur git; selfHeal återställer
# manuella ändringar i klustret. Hemligheten felsokning-hemligheter
# ligger medvetet utanför git och utanför synken.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: guidad-felsokning
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/ERSATT-MIG/ERSATT-MIG.git # ersätt med ert repo
targetRevision: main
path: infra/overlays/produktion
destination:
server: https://kubernetes.default.svc
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- ServerSideApply=true
-80
View File
@@ -1,80 +0,0 @@
# AI-orkestern: plattformens AI-endpoint. Claude API-nyckeln och
# JWT-hemligheten kommer från secret:en felsokning-hemligheter —
# de finns aldrig i bilder, manifest eller klient.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-orkester
namespace: guidad-felsokning
labels: { app: ai-orkester }
spec:
replicas: 2
selector:
matchLabels: { app: ai-orkester }
template:
metadata:
labels: { app: ai-orkester }
spec:
securityContext:
runAsNonRoot: true
containers:
- name: ai-orkester
image: ghcr.io/ORGANISATION/guidad-felsokning-ai-orkester:latest # ersätt med ert registry
ports:
- containerPort: 8080
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: anthropic-api-key }
- name: JWT_SECRET
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret }
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: "1", memory: 256Mi }
readinessProbe:
httpGet: { path: /halsa, port: 8080 }
initialDelaySeconds: 3
livenessProbe:
httpGet: { path: /halsa, port: 8080 }
periodSeconds: 15
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: ai-orkester
namespace: guidad-felsokning
spec:
selector: { app: ai-orkester }
ports:
- port: 80
targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ai-orkester
namespace: guidad-felsokning
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: ai-orkester }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ai-orkester
namespace: guidad-felsokning
spec:
minAvailable: 1
selector:
matchLabels: { app: ai-orkester }
-24
View File
@@ -1,24 +0,0 @@
# EXEMPEL — checka aldrig in riktiga värden. Skapa hellre direkt:
#
# kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
# --from-literal=anthropic-api-key='sk-ant-…' \
# --from-literal=jwt-secret="$(openssl rand -base64 48)" \
# --from-literal=postgres-losenord="$(openssl rand -base64 24)" \
# --from-literal=integration-nyckel="$(openssl rand -hex 32)"
#
# I produktion: använd extern secrets-hantering (External Secrets Operator,
# Sealed Secrets eller molnleverantörens secret manager).
apiVersion: v1
kind: Secret
metadata:
name: felsokning-hemligheter
namespace: guidad-felsokning
type: Opaque
stringData:
anthropic-api-key: ERSATT-MIG
jwt-secret: ERSATT-MIG
postgres-losenord: ERSATT-MIG
# 32 byte hex (openssl rand -hex 32) — krypterar kundernas
# märkesspecifika credentials i vila. Byts nyckeln måste kopplingarna
# sparas om; tjänsten visar då inga värden i stället för att gissa.
integration-nyckel: ERSATT-MIG
-39
View File
@@ -1,39 +0,0 @@
# Ingress med TLS (cert-manager). Längsta prefix vinner:
# /api/ai → AI-orkestern, övriga /api + /halsa → plattformen, / → webben.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: guidad-felsokning
namespace: guidad-felsokning
annotations:
cert-manager.io/cluster-issuer: letsencrypt # ersätt med er issuer
# Teknikern dokumenterar med foto och video som följer med
# händelseloggen. Standardgränsen (1 MB) skulle avvisa
# dokumentationen vid dörren medan tjänsten själv tar 4 MB.
nginx.ingress.kubernetes.io/proxy-body-size: 8m
nginx.ingress.kubernetes.io/proxy-read-timeout: "120" # AI-svar kan ta tid
spec:
ingressClassName: nginx
tls:
- hosts: [app.exempel.se] # ersätt med er domän
secretName: guidad-felsokning-tls
rules:
- host: app.exempel.se # ersätt med er domän
http:
paths:
- path: /api/ai
pathType: Prefix
backend:
service: { name: ai-orkester, port: { number: 80 } }
- path: /api
pathType: Prefix
backend:
service: { name: plattform, port: { number: 80 } }
- path: /halsa
pathType: Prefix
backend:
service: { name: plattform, port: { number: 80 } }
- path: /
pathType: Prefix
backend:
service: { name: web, port: { number: 80 } }
-17
View File
@@ -1,17 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- postgres.yaml
- plattform.yaml
- web.yaml
- ai-orkester.yaml
- ingress.yaml
# hemligheter.exempel.yaml appliceras medvetet inte — skapa secret:en
# separat (se kommentaren i den filen).
configMapGenerator:
- name: postgres-init
namespace: guidad-felsokning
files:
- init.sql=postgres-init.sql
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: guidad-felsokning
-86
View File
@@ -1,86 +0,0 @@
# Plattformstjänsten: självhostad auth, händelse-API och Live Share-delning.
apiVersion: apps/v1
kind: Deployment
metadata:
name: plattform
namespace: guidad-felsokning
labels: { app: plattform }
spec:
replicas: 2
selector:
matchLabels: { app: plattform }
template:
metadata:
labels: { app: plattform }
spec:
securityContext:
runAsNonRoot: true
containers:
- name: plattform
image: ghcr.io/ORGANISATION/guidad-felsokning-plattform:latest # ersätt med ert registry
ports:
- containerPort: 8080
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret }
# Krypteringsnyckel för kundernas märkesspecifika credentials
# (32 byte hex eller base64). Saknas den sparas inga uppgifter
# alls — tjänsten failar closed i stället för att lagra i klartext.
- name: INTEGRATION_NYCKEL
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: integration-nyckel }
- name: POSTGRES_LOSENORD
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord }
- name: DATABASE_URL
value: postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: "1", memory: 256Mi }
readinessProbe:
httpGet: { path: /halsa, port: 8080 }
initialDelaySeconds: 3
livenessProbe:
httpGet: { path: /halsa, port: 8080 }
periodSeconds: 15
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: plattform
namespace: guidad-felsokning
spec:
selector: { app: plattform }
ports:
- port: 80
targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: plattform
namespace: guidad-felsokning
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: plattform }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: plattform
namespace: guidad-felsokning
spec:
minAvailable: 1
selector:
matchLabels: { app: plattform }
-67
View File
@@ -1,67 +0,0 @@
# Självhostad Postgres för Guidad Felsökning.
# Schemat (infra/k8s/postgres-init.sql) genereras in som ConfigMap av
# kustomize och körs vid första starten — samma fil används av
# integrationstestet. För produktion rekommenderas CloudNativePG-operatorn
# (automatiska säkerhetskopior, failover, PITR).
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: guidad-felsokning
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels: { app: postgres }
template:
metadata:
labels: { app: postgres }
spec:
containers:
- name: postgres
image: postgres:17-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: felsokning
- name: POSTGRES_USER
value: plattform
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord }
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: init
mountPath: /docker-entrypoint-initdb.d
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { cpu: "1", memory: 1Gi }
readinessProbe:
exec:
command: ["pg_isready", "-U", "plattform", "-d", "felsokning"]
initialDelaySeconds: 5
volumes:
- name: init
configMap: { name: postgres-init }
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: guidad-felsokning
spec:
selector: { app: postgres }
ports:
- port: 5432
-70
View File
@@ -1,70 +0,0 @@
# Webbklienten: statisk SPA bakom oprivilegierad nginx.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: guidad-felsokning
labels: { app: web }
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
securityContext:
runAsNonRoot: true
containers:
- name: web
image: ghcr.io/ORGANISATION/guidad-felsokning-web:latest # ersätt med ert registry
ports:
- containerPort: 8080
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 250m, memory: 128Mi }
readinessProbe:
httpGet: { path: /, port: 8080 }
initialDelaySeconds: 3
livenessProbe:
httpGet: { path: /, port: 8080 }
periodSeconds: 15
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: guidad-felsokning
spec:
selector: { app: web }
ports:
- port: 80
targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
namespace: guidad-felsokning
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: web }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web
namespace: guidad-felsokning
spec:
minAvailable: 1
selector:
matchLabels: { app: web }
@@ -1,19 +0,0 @@
# Produktions-overlay — GitOps-sanningen för vad som kör i klustret.
# Bildtaggarna uppdateras av publiceringsflödet (.github/workflows/
# publicera.yml) med git-SHA:t för varje main-push; Argo CD synkar
# klustret från den här katalogen. Ändra aldrig taggar för hand —
# rulla tillbaka genom att revertera commiten.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../k8s
images:
- name: ghcr.io/ORGANISATION/guidad-felsokning-web
newName: ghcr.io/ersatt-mig/guidad-felsokning-web
newTag: latest
- name: ghcr.io/ORGANISATION/guidad-felsokning-ai-orkester
newName: ghcr.io/ersatt-mig/guidad-felsokning-ai-orkester
newTag: latest
- name: ghcr.io/ORGANISATION/guidad-felsokning-plattform
newName: ghcr.io/ersatt-mig/guidad-felsokning-plattform
newTag: latest
+6 -1
View File
@@ -25,7 +25,12 @@ resource "kubernetes_secret_v1" "hemligheter" {
# Databasschemat körs vid databasens första start. Samma fil används av # 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. # 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" { resource "kubernetes_config_map_v1" "postgres_init" {
count = local.extern_databas ? 0 : 1
metadata { metadata {
name = "postgres-init" name = "postgres-init"
namespace = kubernetes_namespace_v1.denna.metadata[0].name namespace = kubernetes_namespace_v1.denna.metadata[0].name
@@ -33,6 +38,6 @@ resource "kubernetes_config_map_v1" "postgres_init" {
} }
data = { data = {
"init.sql" = file("${path.module}/../k8s/postgres-init.sql") "init.sql" = file("${path.module}/../postgres-init.sql")
} }
} }
+210 -5
View File
@@ -1,14 +1,217 @@
# Postgres — händelseloggen. # Händelseloggen — systemets enda sanningskälla.
# #
# Append-only garanteras av triggers i schemat, inte av att API:t låter # 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 # bli att skriva. Det gör historiken oantastlig även för den som har
# databasåtkomst med rätt roll. # databasåtkomst med rätt roll.
# #
# En StatefulSet med en PVC är INTE säkerhetskopiering. Innan skarp drift: # Tre lägen, valda med var.databas_lage. Skillnaden mellan dem är
# byt mot CloudNativePG-operatorn (basbackup + WAL-arkivering + PITR + # säkerhetskopiering, och det är därför variabeln saknar standardvärde:
# failover) och peka plattformens DATABASE_URL på dess tjänst. #
# 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 = !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" { resource "kubernetes_stateful_set_v1" "postgres" {
count = local.inbyggd_databas ? 1 : 0
metadata { metadata {
name = "postgres" name = "postgres"
namespace = kubernetes_namespace_v1.denna.metadata[0].name namespace = kubernetes_namespace_v1.denna.metadata[0].name
@@ -123,7 +326,7 @@ resource "kubernetes_stateful_set_v1" "postgres" {
name = "init" name = "init"
config_map { config_map {
name = kubernetes_config_map_v1.postgres_init.metadata[0].name name = kubernetes_config_map_v1.postgres_init[0].metadata[0].name
} }
} }
} }
@@ -147,6 +350,8 @@ resource "kubernetes_stateful_set_v1" "postgres" {
} }
resource "kubernetes_service_v1" "postgres" { resource "kubernetes_service_v1" "postgres" {
count = local.inbyggd_databas ? 1 : 0
metadata { metadata {
name = "postgres" name = "postgres"
namespace = kubernetes_namespace_v1.denna.metadata[0].name namespace = kubernetes_namespace_v1.denna.metadata[0].name
+8 -2
View File
@@ -72,7 +72,12 @@ resource "kubernetes_deployment_v1" "plattform" {
} }
} }
env { # 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" name = "POSTGRES_LOSENORD"
value_from { value_from {
@@ -82,10 +87,11 @@ resource "kubernetes_deployment_v1" "plattform" {
} }
} }
} }
}
env { env {
name = "DATABASE_URL" name = "DATABASE_URL"
value = "postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning" value = local.databas_anslutning
} }
# Klienten serveras från samma domän som API:t, så CORS behöver # Klienten serveras från samma domän som API:t, så CORS behöver
+28 -3
View File
@@ -22,8 +22,12 @@ resource "kubernetes_network_policy_v1" "neka_allt_in" {
} }
} }
# Databasen: bara plattformstjänsten, bara 5432. # 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" { resource "kubernetes_network_policy_v1" "postgres_in" {
count = local.inbyggd_databas ? 1 : 0
metadata { metadata {
name = "postgres-endast-fran-plattform" name = "postgres-endast-fran-plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name namespace = kubernetes_namespace_v1.denna.metadata[0].name
@@ -132,6 +136,8 @@ resource "kubernetes_network_policy_v1" "web_ut" {
# Databasen ringer heller ingenting. # Databasen ringer heller ingenting.
resource "kubernetes_network_policy_v1" "postgres_ut" { resource "kubernetes_network_policy_v1" "postgres_ut" {
count = local.inbyggd_databas ? 1 : 0
metadata { metadata {
name = "postgres-inget-utgaende" name = "postgres-inget-utgaende"
namespace = kubernetes_namespace_v1.denna.metadata[0].name namespace = kubernetes_namespace_v1.denna.metadata[0].name
@@ -165,10 +171,16 @@ resource "kubernetes_network_policy_v1" "plattform_ut" {
policy_types = ["Egress"] policy_types = ["Egress"]
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 { to {
pod_selector { pod_selector {
match_labels = { app = "postgres" } match_labels = local.inbyggd_databas ? { app = "postgres" } : { "cnpg.io/cluster" = "felsokning-db" }
} }
} }
@@ -177,6 +189,7 @@ resource "kubernetes_network_policy_v1" "plattform_ut" {
protocol = "TCP" protocol = "TCP"
} }
} }
}
egress { egress {
to { to {
@@ -199,6 +212,18 @@ resource "kubernetes_network_policy_v1" "plattform_ut" {
protocol = "TCP" 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"
}
}
}
} }
} }
+35 -25
View File
@@ -9,7 +9,7 @@ begripliga att läsa:
| `variables.tf` | Allt som skiljer en installation från en annan | | `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 | | `karta.tf` | **Systemet som data** — tjänster, portar, routing, hemligheter, dataflöden, gränser |
| `10-namnrymd.tf` | Namnrymd, hemligheten, databasschemat | | `10-namnrymd.tf` | Namnrymd, hemligheten, databasschemat |
| `20-databas.tf` | Postgres — händelseloggen | | `20-databas.tf` | Händelseloggen — tre lägen, se nedan |
| `30-plattform.tf` | Backend: auth, händelse-API, delning, integrationer | | `30-plattform.tf` | Backend: auth, händelse-API, delning, integrationer |
| `40-orkester.tf` | AI-orkestern | | `40-orkester.tf` | AI-orkestern |
| `50-web.tf` | Klienten | | `50-web.tf` | Klienten |
@@ -45,41 +45,51 @@ terraform validate # typer och referenser
`terraform validate` kräver att leverantörerna hämtats från `terraform validate` kräver att leverantörerna hämtats från
registry.terraform.io. registry.terraform.io.
## Förhållandet till kustomize och Argo CD ## Databasen: tre lägen
`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver **samma system** `var.databas_lage` saknar standardvärde med flit. Valet avgör om det
i kustomize, synkat av Argo CD. Det är en historisk parallell väg. finns säkerhetskopiering, och det ska inte kunna bli fel av slentrian.
**Välj en.** Kör båda mot samma kluster och de motarbetar varandra: Argo | Läge | Backup | Failover | Använd när |
CD:s `selfHeal` återställer det Terraform just ändrat, och `prune` tar | --- | --- | --- | --- |
bort det Terraform skapat. | `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"` |
Rekommendationen är Terraform, av tre skäl: 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.
1. Nätverkspolicyer, genererade hemligheter och (senare) molnresurser `cnpg` kräver att CloudNativePG-operatorn redan är installerad —
som databas, DNS och objektlagring hör hemma här. Terraform slår upp dess CRD vid plan:
2. Kartan blir läsbar — `karta.tf` och `terraform output` finns inte i
kustomize-varianten.
3. Kustomize-vägen saknar i dag nätverkspolicyer och kör Postgres utan
säkerhetskontext.
Byter ni: ta bort Argo CD-applikationen (`kubectl -n argocd delete ```sh
application guidad-felsokning`) **innan** första `terraform apply`, och kubectl apply --server-side -f \
importera befintliga resurser med `terraform import` om ni vill undvika https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml
omstart. Behåller ni Argo CD i stället: applicera nätverkspolicyerna och ```
ingressens kroppsgräns därifrån också.
Bildtaggen kommer från publiceringsflödet oavsett väg — kör I `extern` läge ansvarar ni själva för att köra
`terraform apply -var bildtagg=<git-sha>` i stället för att låta CI `infra/postgres-init.sql` mot databasen. Det är samma fil som
skriva i git. 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 ## Det som medvetet inte ingår
Står också i `terraform output karta` under `avgränsningar`: Står också i `terraform output karta` under `avgränsningar`:
- **Säkerhetskopiering.** En StatefulSet med en PVC är inte backup. Sätt
CloudNativePG (basbackup, WAL-arkivering, PITR, failover) innan skarp
drift och peka `DATABASE_URL` på dess tjänst.
- **Objektlagring.** Foton och video ligger som data-URL:er i - **Objektlagring.** Foton och video ligger som data-URL:er i
händelseloggen. Det gör loggen till systemets enda sanningskälla, men händelseloggen. Det gör loggen till systemets enda sanningskälla, men
också stor och tung att säkerhetskopiera. också stor och tung att säkerhetskopiera.
+13 -8
View File
@@ -46,10 +46,15 @@ locals {
utat = ["api.anthropic.com"] utat = ["api.anthropic.com"]
} }
postgres = { postgres = {
roll = "Händelseloggen — systemets enda sanningskälla. Append-only garanteras av databastriggers, inte bara av API:t." roll = join(" ", [
bild = "postgres:17-alpine" "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"] hemligt = ["postgres-losenord"]
utat = [] utat = var.databas_lage == "cnpg" ? ["objektlagringen för backup och WAL-arkiv"] : []
} }
} }
@@ -79,8 +84,6 @@ locals {
) )
} }
databas_url = "postgresql://plattform:${local.hemligheter["postgres-losenord"]}@postgres:5432/felsokning"
# ---- Dataflöden ----------------------------------------------------- # ---- Dataflöden -----------------------------------------------------
# #
# Det som är värt att förstå innan man ändrar något: vad som rör sig # Det som är värt att förstå innan man ändrar något: vad som rör sig
@@ -102,13 +105,15 @@ locals {
# ---- Det som medvetet inte ingår ------------------------------------ # ---- Det som medvetet inte ingår ------------------------------------
avgransningar = [ avgransningar = concat(
"Säkerhetskopiering av databasen. StatefulSet + PVC är inte backup — sätt CloudNativePG eller motsvarande innan skarp drift.", var.databas_lage == "inbyggd" ? ["INGEN SÄKERHETSKOPIERING — läget inbyggd har en volym och inget mer. Endast prov och demo."] : [],
[
"Objektlagring. Foton och video ligger som data-URL:er i händelseloggen, vilket gör volymen stor och tung att säkerhetskopiera.", "Objektlagring. Foton och video ligger som data-URL:er i händelseloggen, vilket gör volymen stor och tung att säkerhetskopiera.",
"Observability. Ingen metrikexport, ingen tracing — bara containerloggar.", "Observability. Ingen metrikexport, ingen tracing — bara containerloggar.",
"Takt-begränsning på inloggning. Endast den publika beslutsvägen är begränsad, och bara per pod.", "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.", "Å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. # Genereras bara när motsvarande variabel lämnats tom.
+16 -2
View File
@@ -29,10 +29,24 @@ output "karta" {
gränser = local.granser gränser = local.granser
databas = { databas = {
läge = var.databas_lage
motor = "PostgreSQL 17" motor = "PostgreSQL 17"
volym = var.databas_storlek
säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll" säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll"
backup = "INGEN i den här definitionen — se avgränsningar"
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)"
)
} }
drift = { drift = {
+24
View File
@@ -27,6 +27,30 @@ registrering_oppen = false
# adresser. Sätt bara true om verkstaden har en OEM-server på eget nät. # adresser. Sätt bara true om verkstaden har en OEM-server på eget nät.
tillat_interna_uppslag = false 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" databas_storlek = "50Gi"
# lagringsklass = "premium-rwo" # lagringsklass = "premium-rwo"
+73
View File
@@ -169,6 +169,79 @@ variable "max_repliker" {
} }
} }
# ---- 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
}
variable "databas_storlek" { variable "databas_storlek" {
description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen." description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen."
type = string type = string
+1 -1
View File
@@ -27,7 +27,7 @@ su postgres -c "$PGBIN/initdb -D '$DATADIR' -A trust" >/dev/null
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l '$DATADIR/logg' start" >/dev/null su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l '$DATADIR/logg' start" >/dev/null
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc \"create role plattform login password 'test'\"" su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc \"create role plattform login password 'test'\""
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc 'create database felsokning owner plattform'" su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc 'create database felsokning owner plattform'"
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q -f ../../infra/k8s/postgres-init.sql PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q -f ../../infra/postgres-init.sql
# ---- Tjänsten upp ---- # ---- Tjänsten upp ----
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \ DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
+1 -1
View File
@@ -55,7 +55,7 @@ describe("AI-orkestern", () => {
expect(plattform).toContain("organisation_id"); expect(plattform).toContain("organisation_id");
expect(plattform).toContain("arendeIOrg"); expect(plattform).toContain("arendeIOrg");
const schema = readFileSync("infra/k8s/postgres-init.sql", "utf8"); const schema = readFileSync("infra/postgres-init.sql", "utf8");
expect(schema).toContain("before update or delete on felsokning_handelser"); expect(schema).toContain("before update or delete on felsokning_handelser");
expect(schema).toContain("before update or delete on felsokning_arenden"); expect(schema).toContain("before update or delete on felsokning_arenden");
expect(schema).toContain("create table if not exists organisationer"); expect(schema).toContain("create table if not exists organisationer");