diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4a4064..6dabdb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,23 @@ jobs: - run: sudo bash integrationstest.sh 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: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/driftsatt.yml b/.github/workflows/driftsatt.yml new file mode 100644 index 0000000..b9e9ed8 --- /dev/null +++ b/.github/workflows/driftsatt.yml @@ -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 diff --git a/.github/workflows/publicera.yml b/.github/workflows/publicera.yml index d25a23e..1581881 100644 --- a/.github/workflows/publicera.yml +++ b/.github/workflows/publicera.yml @@ -1,17 +1,15 @@ -# CD-halvan av GitOps-flödet: varje push till main bygger och publicerar -# de tre bilderna till GHCR taggade med git-SHA:t, och uppdaterar sedan -# produktions-overlayens taggar i git. Argo CD ser ändringen och synkar -# klustret — ingen kubectl-åtkomst från CI, klustret drar själv. +# Bygger och publicerar de tre bilderna till GHCR, taggade med git-SHA:t. +# Driftsättningen sker i ett eget flöde (driftsatt.yml) som kör +# terraform apply mot en miljö med godkännandekrav — en bild som ligger i +# registret är alltså inte samma sak som en bild som kör. name: Publicera on: push: branches: [main] - paths-ignore: - - "infra/overlays/**" # gitops-commiten ska inte trigga en ny publicering permissions: - contents: write + contents: read packages: write env: @@ -28,34 +26,34 @@ jobs: username: ${{ github.actor }} 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 run: | docker build -t "$REGISTER/guidad-felsokning-web:${{ github.sha }}" \ --build-arg VITE_PLATTFORM_URL="${{ vars.PLATTFORM_URL }}" \ --build-arg VITE_AI_ORKESTER_URL="${{ vars.PLATTFORM_URL }}" . docker push "$REGISTER/guidad-felsokning-web:${{ github.sha }}" + - name: Bygg och publicera ai-orkester run: | docker build -t "$REGISTER/guidad-felsokning-ai-orkester:${{ github.sha }}" services/ai-orkester docker push "$REGISTER/guidad-felsokning-ai-orkester:${{ github.sha }}" + - name: Bygg och publicera plattform run: | docker build -t "$REGISTER/guidad-felsokning-plattform:${{ github.sha }}" services/plattform docker push "$REGISTER/guidad-felsokning-plattform:${{ github.sha }}" - - name: Uppdatera produktions-overlayen (GitOps) + - name: Sammanfatta 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 - for tjanst in web ai-orkester plattform; do - ../../../kustomize edit set image \ - "ghcr.io/ORGANISATION/guidad-felsokning-$tjanst=$REGISTER/guidad-felsokning-$tjanst:${{ github.sha }}" - done - ../../../kustomize build . > /dev/null # verifiera att overlayen fortfarande bygger - - name: Committa overlay-uppdateringen - run: | - 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 + { + echo "### Publicerat" + echo "" + echo '```' + echo "bildtagg: ${{ github.sha }}" + echo '```' + echo "" + echo "Driftsätt med flödet **Driftsätt** och den taggen." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/DRIFT.md b/docs/DRIFT.md index d1deaf2..01a6753 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -21,7 +21,7 @@ flowchart LR | `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 | | `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) | | 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 karta` skriver ut samma sak i klartext. -`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver samma system i -kustomize, synkat av Argo CD. **Kör inte båda mot samma kluster** — Argo -CD:s `selfHeal` återställer det Terraform ändrar och `prune` tar bort det -Terraform skapar. Terraform-vägen har dessutom nätverkspolicyer och -säkerhetskontext på databasen, vilket kustomize-vägen saknar. +Definitionen omfattar hemligheter, databasschema, nätverksgränser och +alla tre databaslägena. Kustomize- och Argo CD-vägen är borttagen — +`infra/postgres-init.sql` är det enda som blivit kvar utanför Terraform, +och den läses av både Terraform och integrationstestet så att schemat +inte kan glida isär från det som testas. ## Nätverksgränser @@ -61,59 +61,52 @@ dokumentation, inte skydd. ## 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 -# 1. Bygg och publicera bilderna (ersätt registry i infra/k8s/*.yaml) -docker build -t ghcr.io/ORG/guidad-felsokning-web \ - --build-arg VITE_PLATTFORM_URL=https://app.exempel.se . -docker build -t ghcr.io/ORG/guidad-felsokning-ai-orkester services/ai-orkester -docker build -t ghcr.io/ORG/guidad-felsokning-plattform services/plattform -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 +cd infra/terraform +cp terraform.tfvars.exempel terraform.tfvars # domän, register, databasläge, nycklar +terraform init +terraform plan +terraform apply -var bildtagg= -# 2. Skapa secret:en (eller använd External Secrets/Sealed Secrets) -kubectl create namespace guidad-felsokning -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 +terraform output karta # hela systemet i klartext +terraform output endpoints # adresser att kontrollera ``` -Med Terraform i stället: `cd infra/terraform && terraform apply -var bildtagg=`. +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. -Kopplingarna konfigureras därför av kunden själv under **Inställningar → -Märkesspecifika kopplingar**: systemadministratören väljer leverantör och -fyller i sina credentials. +Att skapa nya organisationer är stängt som standard +(`registrering_oppen = false`); användare inom en organisation skapas +alltid av dess systemadministratör. -* **Uppgifterna når aldrig webbläsaren.** De krypteras med AES-256-GCM - (`INTEGRATION_NYCKEL`, 32 byte hex eller base64) innan de skrivs till - tabellen `integrationer`, och API:t returnerar hemliga fält maskerade - (`••••3456`). Alla uppslag mot leverantören görs av servern. -* **Fail closed.** Saknas `INTEGRATION_NYCKEL` sparas ingenting — API:t - svarar 503 och inställningssidan säger varför. Inga uppgifter hamnar - någonsin i klartext. -* **Leverantörer är data, inte kod.** Registret ligger i - `services/plattform/integrationer.json` och kan bytas mot en - ConfigMap-mount via `INTEGRATIONER_FIL`. Nya märken läggs till genom - att beskriva URL-mall, autentiseringstyp och svarsmappning — ingen - ombyggnad av applikationen krävs. -* **Testresultat loggas på kopplingen.** Varje uppslag skriver - `senast_testad` och `senaste_status`, så ett trasigt abonnemang syns i - inställningarna i stället för att tyst ge tomma svar. +## Databasen: valet som avgör om det finns backup + +`databas_lage` saknar standardvärde med flit. + +| Läge | Backup | Failover | Använd när | +| --- | --- | --- | --- | +| `extern` | Leverantörens, med PITR | Leverantörens | **Produktion.** Cloud SQL, RDS, Neon, Azure | +| `cnpg` | Basbackup 02:30 + WAL-arkiv → objektlagring, PITR | Ja | Produktion när databasen måste ligga i klustret | +| `inbyggd` | **Ingen** | Nej | Prov och demo — spärras 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 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 @@ -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. -**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 flowchart LR - P[Push till main] --> B[Bygg + publicera\n3 bilder till GHCR\ntaggade med git-SHA] - B --> O[Uppdatera\ninfra/overlays/produktion\n+ commit till git] - O --> A[Argo CD ser ändringen] --> S[Synkar klustret\nprune + selfHeal] + P[Push till main] --> B[Publicera:\nbygger 3 bilder\ntaggade med git-SHA] --> G[(GHCR)] + G -.-> D[Driftsätt:\nstartas för hand\nmed en tagg] + 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. -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. -3. **Rollback = `git revert`** av gitops-commiten — Argo CD synkar tillbaka föregående SHA-taggade bilder. -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. +1. **Publicera** vid varje main-push: bygger de tre bilderna och taggar med git-SHA:t (`GITHUB_TOKEN`, inga externa hemligheter). +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** = kör Driftsätt 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 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. diff --git a/docs/MVP.md b/docs/MVP.md index ce09b31..9f704ce 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -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. | | 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). | -| 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`. | ## Arkitekturprinciper i koden diff --git a/infra/gitops/argocd-application.yaml b/infra/gitops/argocd-application.yaml deleted file mode 100644 index 3e548fc..0000000 --- a/infra/gitops/argocd-application.yaml +++ /dev/null @@ -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 diff --git a/infra/k8s/ai-orkester.yaml b/infra/k8s/ai-orkester.yaml deleted file mode 100644 index 655602a..0000000 --- a/infra/k8s/ai-orkester.yaml +++ /dev/null @@ -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 } diff --git a/infra/k8s/hemligheter.exempel.yaml b/infra/k8s/hemligheter.exempel.yaml deleted file mode 100644 index 633aead..0000000 --- a/infra/k8s/hemligheter.exempel.yaml +++ /dev/null @@ -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 diff --git a/infra/k8s/ingress.yaml b/infra/k8s/ingress.yaml deleted file mode 100644 index 10fb694..0000000 --- a/infra/k8s/ingress.yaml +++ /dev/null @@ -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 } } diff --git a/infra/k8s/kustomization.yaml b/infra/k8s/kustomization.yaml deleted file mode 100644 index 9f4373b..0000000 --- a/infra/k8s/kustomization.yaml +++ /dev/null @@ -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 diff --git a/infra/k8s/namespace.yaml b/infra/k8s/namespace.yaml deleted file mode 100644 index 1894df4..0000000 --- a/infra/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: guidad-felsokning diff --git a/infra/k8s/plattform.yaml b/infra/k8s/plattform.yaml deleted file mode 100644 index a034bbe..0000000 --- a/infra/k8s/plattform.yaml +++ /dev/null @@ -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 } diff --git a/infra/k8s/postgres.yaml b/infra/k8s/postgres.yaml deleted file mode 100644 index 2003b98..0000000 --- a/infra/k8s/postgres.yaml +++ /dev/null @@ -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 diff --git a/infra/k8s/web.yaml b/infra/k8s/web.yaml deleted file mode 100644 index b8b4ed9..0000000 --- a/infra/k8s/web.yaml +++ /dev/null @@ -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 } diff --git a/infra/overlays/produktion/kustomization.yaml b/infra/overlays/produktion/kustomization.yaml deleted file mode 100644 index 9340856..0000000 --- a/infra/overlays/produktion/kustomization.yaml +++ /dev/null @@ -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 diff --git a/infra/k8s/postgres-init.sql b/infra/postgres-init.sql similarity index 100% rename from infra/k8s/postgres-init.sql rename to infra/postgres-init.sql diff --git a/infra/terraform/10-namnrymd.tf b/infra/terraform/10-namnrymd.tf index de2373a..0bdd395 100644 --- a/infra/terraform/10-namnrymd.tf +++ b/infra/terraform/10-namnrymd.tf @@ -25,7 +25,12 @@ resource "kubernetes_secret_v1" "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 @@ -33,6 +38,6 @@ resource "kubernetes_config_map_v1" "postgres_init" { } data = { - "init.sql" = file("${path.module}/../k8s/postgres-init.sql") + "init.sql" = file("${path.module}/../postgres-init.sql") } } diff --git a/infra/terraform/20-databas.tf b/infra/terraform/20-databas.tf index c28d825..96f868a 100644 --- a/infra/terraform/20-databas.tf +++ b/infra/terraform/20-databas.tf @@ -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 # bli att skriva. Det gör historiken oantastlig även för den som har # databasåtkomst med rätt roll. # -# En StatefulSet med en PVC är INTE säkerhetskopiering. Innan skarp drift: -# byt mot CloudNativePG-operatorn (basbackup + WAL-arkivering + PITR + -# failover) och peka plattformens DATABASE_URL på dess tjänst. +# 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 = !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 @@ -123,7 +326,7 @@ resource "kubernetes_stateful_set_v1" "postgres" { name = "init" 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" { + count = local.inbyggd_databas ? 1 : 0 + metadata { name = "postgres" namespace = kubernetes_namespace_v1.denna.metadata[0].name diff --git a/infra/terraform/30-plattform.tf b/infra/terraform/30-plattform.tf index 00572a0..23ab066 100644 --- a/infra/terraform/30-plattform.tf +++ b/infra/terraform/30-plattform.tf @@ -72,20 +72,26 @@ resource "kubernetes_deployment_v1" "plattform" { } } - env { - name = "POSTGRES_LOSENORD" + # 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] - value_from { - secret_key_ref { - name = kubernetes_secret_v1.hemligheter.metadata[0].name - key = "postgres-losenord" + 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 = "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 diff --git a/infra/terraform/70-natverk.tf b/infra/terraform/70-natverk.tf index 3c2b728..cb8a0e7 100644 --- a/infra/terraform/70-natverk.tf +++ b/infra/terraform/70-natverk.tf @@ -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" { + count = local.inbyggd_databas ? 1 : 0 + metadata { name = "postgres-endast-fran-plattform" namespace = kubernetes_namespace_v1.denna.metadata[0].name @@ -132,6 +136,8 @@ resource "kubernetes_network_policy_v1" "web_ut" { # Databasen ringer heller ingenting. resource "kubernetes_network_policy_v1" "postgres_ut" { + count = local.inbyggd_databas ? 1 : 0 + metadata { name = "postgres-inget-utgaende" namespace = kubernetes_namespace_v1.denna.metadata[0].name @@ -165,16 +171,23 @@ resource "kubernetes_network_policy_v1" "plattform_ut" { policy_types = ["Egress"] - egress { - to { - pod_selector { - match_labels = { app = "postgres" } - } - } + # 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] - ports { - port = "5432" - protocol = "TCP" + content { + to { + pod_selector { + match_labels = local.inbyggd_databas ? { app = "postgres" } : { "cnpg.io/cluster" = "felsokning-db" } + } + } + + ports { + port = "5432" + protocol = "TCP" + } } } @@ -199,6 +212,18 @@ resource "kubernetes_network_policy_v1" "plattform_ut" { 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" + } + } + } } } diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 2feef86..74fe84a 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -9,7 +9,7 @@ begripliga att läsa: | `variables.tf` | Allt som skiljer en installation från en annan | | `karta.tf` | **Systemet som data** — tjänster, portar, routing, hemligheter, dataflöden, gränser | | `10-namnrymd.tf` | Namnrymd, hemligheten, databasschemat | -| `20-databas.tf` | Postgres — händelseloggen | +| `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 | @@ -45,41 +45,51 @@ terraform validate # typer och referenser `terraform validate` kräver att leverantörerna hämtats från registry.terraform.io. -## Förhållandet till kustomize och Argo CD +## Databasen: tre lägen -`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver **samma system** -i kustomize, synkat av Argo CD. Det är en historisk parallell väg. +`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. -**Välj en.** Kör båda mot samma kluster och de motarbetar varandra: Argo -CD:s `selfHeal` återställer det Terraform just ändrat, och `prune` tar -bort det Terraform skapat. +| 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"` | -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 - som databas, DNS och objektlagring hör hemma här. -2. Kartan blir läsbar — `karta.tf` och `terraform output` finns inte i - kustomize-varianten. -3. Kustomize-vägen saknar i dag nätverkspolicyer och kör Postgres utan - säkerhetskontext. +`cnpg` kräver att CloudNativePG-operatorn redan är installerad — +Terraform slår upp dess CRD vid plan: -Byter ni: ta bort Argo CD-applikationen (`kubectl -n argocd delete -application guidad-felsokning`) **innan** första `terraform apply`, och -importera befintliga resurser med `terraform import` om ni vill undvika -omstart. Behåller ni Argo CD i stället: applicera nätverkspolicyerna och -ingressens kroppsgräns därifrån också. +```sh +kubectl apply --server-side -f \ + https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml +``` -Bildtaggen kommer från publiceringsflödet oavsett väg — kör -`terraform apply -var bildtagg=` i stället för att låta CI -skriva i git. +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`: -- **Säkerhetskopiering.** En StatefulSet med en PVC är inte backup. Sätt - CloudNativePG (basbackup, WAL-arkivering, PITR, failover) innan skarp - drift och peka `DATABASE_URL` på dess tjänst. - **Objektlagring.** Foton och video ligger som data-URL:er i händelseloggen. Det gör loggen till systemets enda sanningskälla, men också stor och tung att säkerhetskopiera. diff --git a/infra/terraform/karta.tf b/infra/terraform/karta.tf index 17a3c34..4f52831 100644 --- a/infra/terraform/karta.tf +++ b/infra/terraform/karta.tf @@ -46,10 +46,15 @@ locals { utat = ["api.anthropic.com"] } postgres = { - roll = "Händelseloggen — systemets enda sanningskälla. Append-only garanteras av databastriggers, inte bara av API:t." - bild = "postgres:17-alpine" + 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 = [] + 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 ----------------------------------------------------- # # 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 ------------------------------------ - avgransningar = [ - "Säkerhetskopiering av databasen. StatefulSet + PVC är inte backup — sätt CloudNativePG eller motsvarande innan skarp drift.", - "Objektlagring. Foton och video ligger som data-URL:er i händelseloggen, vilket gör volymen stor och tung att säkerhetskopiera.", - "Observability. Ingen metrikexport, ingen tracing — bara containerloggar.", - "Takt-begränsning på inloggning. Endast den publika beslutsvägen är begränsad, och bara per pod.", - "Återkallelse av utfärdade JWT. En token gäller sin livstid ut även om användaren tas bort.", - ] + avgransningar = concat( + 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.", + "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. diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index a965470..c83f4aa 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -29,10 +29,24 @@ output "karta" { gränser = local.granser databas = { + läge = var.databas_lage motor = "PostgreSQL 17" - volym = var.databas_storlek säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll" - backup = "INGEN i den här definitionen — se avgränsningar" + + 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 = { diff --git a/infra/terraform/terraform.tfvars.exempel b/infra/terraform/terraform.tfvars.exempel index b6359dd..da8f58f 100644 --- a/infra/terraform/terraform.tfvars.exempel +++ b/infra/terraform/terraform.tfvars.exempel @@ -27,6 +27,30 @@ registrering_oppen = false # 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" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 8b6ea58..799015a 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -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" { description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen." type = string diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 424144b..bed9d91 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -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/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'" -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 ---- DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \ diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 4aa0c9c..12351bc 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -55,7 +55,7 @@ describe("AI-orkestern", () => { expect(plattform).toContain("organisation_id"); 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_arenden"); expect(schema).toContain("create table if not exists organisationer");