Helt självhostat: plattformsbackend, Postgres i klustret, klientläge

Sista externa beroendet borta — hela stacken kör i eget kluster:

- Ny plattformstjänst (services/plattform): egen inloggning
  (registrering + inloggning, lösenord bcrypt-hashade med pgcrypto,
  HS256-JWT), append-only händelse-API för synken (endast insert,
  on conflict do nothing) och publik delningsendpoint för Live Share
  med samma filtrering som Supabase-funktionen. Fail closed utan
  hemligheter; inga update/delete-operationer exponeras.
- Postgres som StatefulSet med PVC och init-schema; append-only
  garanterat i databasen med triggers på både händelser och ärenden —
  historik kan inte ändras oavsett ansluten roll. CloudNativePG
  rekommenderas för produktion i docs.
- JWT-hemligheten delas mellan plattform och AI-orkester (orkestern
  läser JWT_SECRET med SUPABASE_JWT_SECRET som fallback) — flödet
  livetestat: plattformens token accepteras av orkestern, fel
  hemlighet och utgångna tokens avvisas.
- Klienten får självhostat läge via VITE_PLATTFORM_URL: inloggnings-
  panel på startsidan, synk mot plattformens API (samma konfliktfria
  flätning), Live Share via /api/delad och AI via klustrets orkester.
  Utan variabeln är Supabase-läget oförändrat.
- Ingress routar /api/ai → orkestern, /api + /halsa → plattformen,
  / → webben; kustomize inkluderar hela stacken; CI bygger alla tre
  bilderna; DRIFT.md omskriven för självhostad drift.

Verifierat: 28 vitest-tester gröna (inkl. append-only-triggers och
API-ytan), plattformstjänsten livetestad (hälsa, 401, fail closed),
JWT-paritet mellan tjänsterna testad, alla manifest YAML-validerade,
produktionsbygge ok, Playwright-röktest grönt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 01:41:58 +00:00
parent 03f1ef20f6
commit 7c24186cc5
20 changed files with 906 additions and 45 deletions
+2 -2
View File
@@ -26,9 +26,9 @@ spec:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: anthropic-api-key }
- name: SUPABASE_JWT_SECRET
- name: JWT_SECRET
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: supabase-jwt-secret }
secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret }
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: "1", memory: 256Mi }
+4 -2
View File
@@ -2,7 +2,8 @@
#
# kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
# --from-literal=anthropic-api-key='sk-ant-…' \
# --from-literal=supabase-jwt-secret='…'
# --from-literal=jwt-secret="$(openssl rand -base64 48)" \
# --from-literal=postgres-losenord="$(openssl rand -base64 24)"
#
# I produktion: använd extern secrets-hantering (External Secrets Operator,
# Sealed Secrets eller molnleverantörens secret manager).
@@ -14,4 +15,5 @@ metadata:
type: Opaque
stringData:
anthropic-api-key: ERSATT-MIG
supabase-jwt-secret: ERSATT-MIG
jwt-secret: ERSATT-MIG
postgres-losenord: ERSATT-MIG
+7 -2
View File
@@ -1,4 +1,5 @@
# Ingress med TLS (cert-manager): webben på /, AI-orkestern på /api/ai.
# 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:
@@ -19,10 +20,14 @@ spec:
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: ai-orkester, port: { number: 80 } }
service: { name: plattform, port: { number: 80 } }
- path: /
pathType: Prefix
backend:
+2
View File
@@ -2,6 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- postgres.yaml
- plattform.yaml
- web.yaml
- ai-orkester.yaml
- ingress.yaml
+80
View File
@@ -0,0 +1,80 @@
# 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 }
- 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 }
+123
View File
@@ -0,0 +1,123 @@
# Självhostad Postgres för Guidad Felsökning.
# För produktion rekommenderas CloudNativePG-operatorn (automatiska
# säkerhetskopior, failover, PITR) — den här StatefulSet:en är den enkla
# grundplattan med samma schema.
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-init
namespace: guidad-felsokning
data:
# Körs vid första starten. Append-only garanteras med triggers —
# historiken kan inte ändras eller raderas oavsett vilken roll som
# ansluter.
init.sql: |
create extension if not exists pgcrypto;
create table if not exists anvandare (
id uuid primary key default gen_random_uuid(),
epost text unique not null,
losen_hash text not null,
namn text not null,
skapad timestamptz not null default now()
);
create table if not exists felsokning_arenden (
id text primary key,
nummer integer not null,
skapad timestamptz not null,
delningskod text unique,
metodik_id text,
skapad_av uuid references anvandare(id),
insatt timestamptz not null default now()
);
create table if not exists felsokning_handelser (
id text primary key,
arende_id text not null references felsokning_arenden(id),
tidpunkt timestamptz not null,
anvandare text not null,
handelse jsonb not null,
insatt timestamptz not null default now()
);
create index if not exists felsokning_handelser_arende_idx
on felsokning_handelser (arende_id, tidpunkt);
create or replace function forbjud_andring() returns trigger
language plpgsql as $$
begin
raise exception 'Loggen är append-only — historik kan inte ändras eller raderas';
end $$;
drop trigger if exists handelser_append_only on felsokning_handelser;
create trigger handelser_append_only
before update or delete on felsokning_handelser
for each row execute function forbjud_andring();
drop trigger if exists arenden_append_only on felsokning_arenden;
create trigger arenden_append_only
before update or delete on felsokning_arenden
for each row execute function forbjud_andring();
---
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