# Guidad Felsökning — operations on Kubernetes (fully self-hosted) > Canonical version. Swedish: [OPERATIONS.sv.md](OPERATIONS.sv.md). > Code identifiers, environment variables and file paths are Swedish and appear > verbatim. The target architecture from the [Master Prompt](MASTER-PROMPT.md) as code: the whole stack runnable in our own cluster — web, orchestrator, platform backend (auth + event API + Live Share) and Postgres. No external service dependencies beyond the Anthropic API for the model calls. ## Architecture ```mermaid flowchart LR T[Technician] -->|HTTPS| I[Ingress + TLS] T2[Customer via share link] -->|HTTPS| I I -->|/| W[web\n2–10 pods, HPA] I -->|/api/ai| A[ai-orkester\n2–10 pods, HPA] I -->|/api, /halsa| P[plattform\n2–10 pods, HPA] A -->|Claude API| C[(Anthropic)] P --> DB[(Postgres\nStatefulSet + PVC)] K[Secret: felsokning-hemligheter] --> A & P & DB ``` | Component | What | Where | | --- | --- | --- | | `web` | The SPA behind unprivileged nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB | | `plattform` | Self-hosted backend (`services/plattform`): **multi-tenant** — registration creates an organisation plus a system administrator, admins manage users (technician/supervisor/admin), all case data organisation-isolated. Login (bcrypt via pgcrypto, HS256 JWT with role and org in the claims), append-only event API, public share endpoint | Deployment + Service + HPA + PDB | | `ai-orkester` | The orchestrator (`services/ai-orkester`): tasks routed to Sonnet 5 / Opus 5 / Haiku 4.5 — verifies the platform's JWT (shared secret) | Deployment + Service + HPA + PDB | | `postgres` | Event log and users; **append-only guaranteed by database triggers** — history cannot be changed or deleted regardless of role | **Aurora PostgreSQL Serverless v2** outside the cluster, in a subnet layer with no route out. Automatic backup with PITR to the second | | Secrets | `anthropic-api-key`, `jwt-secret` (shared by platform and orchestrator), `postgres-losenord`, `integration-nyckel` (encrypts the customers' brand-specific credentials) | | Environment flags | `TILLATNA_URSPRUNG` (CORS list; omitted = `*`), `TILLAT_INTERNA_UPPSLAG` (`true` allows vendor lookups against private networks), `REGISTRERING_OPPEN`, `ECM_REGLER_FIL`, `INTEGRATIONER_FIL` | Secret `felsokning-hemligheter` — never in images or manifests | **The client has two operating modes**, chosen at build time: with `VITE_PLATTFORM_URL`, login, sync, Live Share and the model calls go to the cluster (fully self-hosted); without it the Supabase mode is used (edge function plus managed Postgres/Auth) as before. Same event model, same orchestrator — locked by parity tests. ## Infrastructure as code Two layers, in order: | Layer | Where | What | | --- | --- | --- | | 1 | `felsokning/infra/aws` | VPC, EKS, Aurora, S3, ECR, Secrets Manager, Route 53/ACM, CloudWatch | | 2 | `felsokning/infra/terraform` | The workload in the cluster — reads layer 1's outputs | The split is not a matter of taste. A single apply that both creates an EKS cluster and schedules into it is a known trap: the Kubernetes provider has to be configured with details that do not exist until the cluster does. Layer 2 decides almost nothing on its own — `05-aws.tf` reads the base's outputs, so domain, registry, roles, certificate, bucket and secret names are stated in exactly one place. `terraform output karta` in either layer prints the whole truth in plain text, straight from the definition. ## Network boundaries The Terraform path closes the namespace and opens only the actual flows: | From | To | Why | | --- | --- | --- | | the ingress controller | web, plattform, ai-orkester :8080 | the only way in | | plattform | postgres :5432 | the event log | | plattform | internet :443 except private networks | the customers' vendors | | ai-orkester | internet :443 except private networks | Claude | | web, postgres | — | call nothing | The exceptions for private networks (10/8, 172.16/12, 192.168/16, 169.254/16, 127/8, 100.64/10) are the same boundary the code itself enforces in `pekarInat` — two independent barriers against a customer-configured lookup being used to reach the cluster's interior or the cloud metadata service. Requires a CNI that enforces NetworkPolicy; otherwise the rules are documentation, not protection. ## Deploying ```sh # Layer 1 — the AWS base cd felsokning/infra/aws cp terraform.tfvars.exempel terraform.tfvars # domain, region, alarm address terraform init && terraform apply terraform output karta # Layer 2 — the workload cd ../terraform cp terraform.tfvars.exempel terraform.tfvars # state bucket and image tag terraform init && terraform apply -var bildtagg= ``` After the first apply, three things remain, listed by `terraform output karta` under `kvar_att_gora` (still to do): fill in the Claude key in Secrets Manager, run `felsokning/infra/postgres-init.sql` against Aurora, and narrow `tillatna_api_cidr` from `0.0.0.0/0`. Creating new organisations is closed by default (`registrering_oppen = false`); users within an organisation are always created by its system administrator. ## The database Aurora PostgreSQL Serverless v2, outside the cluster, in a subnet layer **with no route out at all** — the database's inability to reach the internet therefore does not depend on a security group being configured correctly. Automatic backup with PITR to the second within the retention window. If the event log is lost, what disappears is not "data" but every case's probative value: what was checked, by whom, when, with what evidence. That cannot be recreated afterwards. The schema with the append-only triggers is `felsokning/infra/postgres-init.sql` — the same file the integration test runs, so they cannot drift apart. ## Secrets AWS Secrets Manager is the source of truth. External Secrets mirrors them into the cluster every hour, and the pods read them as ordinary environment variables. **Terraform never sees the values**, and that is the whole point: a secret that passes through Terraform ends up in the state file. When a secret is rotated, the cluster follows on its own within the hour. Access goes via IRSA: the platform's service account has a role bound to exactly that account in that namespace. The neighbouring pod on the same node gets nothing for free, and IMDSv2 with hop limit 1 stops a pod from borrowing the node's role via the metadata service. The same role signs against S3 — no keys exist to leak. ## Attachments Photos, video clips and instrument images used to sit as data URLs inside the events. That affected everything that reads the log: sync dragged the entire image payload along every fifteen seconds, the customer view likewise, and a backup of the log was in practice a copy of every photo. Now the content lives outside the event and the log carries a reference with the content's SHA-256. **That strengthens the probative value rather than weakening it**: the hash sits in the append-only-protected log, so an image that has been swapped can be detected — previously the image sat in the log and simply had to be taken on trust. The content is checked against the hash every time it is served; if it does not match, the service answers 409 instead of showing the image. Content-addressed, so the same photo documented twice is stored once. | `bilage_lage` | Where the content lives | Use when | | --- | --- | --- | | `databas` (default) | `bilage_innehall` (bytea) | Works everywhere with no configuration; the images travel with the database backups | | `s3` | S3-compatible object storage (AWS, MinIO, Ceph) | The log and the images should grow independently of each other | The signing against object storage is our own (SigV4 for PUT and GET) rather than the cloud provider's SDK — two operations do not justify tens of megabytes of dependencies. It is cross-verified against botocore in the tests, bit for bit. **The sharing boundary applies to attachments too.** An attachment can be fetched through a share link only if the event it belongs to is visible at that level; the scanned work order is therefore never reachable through the customer link. Older events with an embedded data URL keep working and always will — the log is append-only. Local mode, without login, also embeds: there is no server to upload to, and the documentation must not be lost because the network is down. ## Access: blocking and revocation A valid JWT signature is not enough. Every authenticated call looks up the account and checks two further things: that it is still active, and that the token version matches. It costs one primary-key lookup per call and in return gives **immediate** revocation, instead of a suspension taking effect only when the token expires up to twelve hours later. | Situation | Route | Effect | | --- | --- | --- | | Someone leaves | `POST /api/anvandare/{id}/avaktivera` (admin) | Login is closed and ongoing sessions end immediately | | The account should come back | `POST /api/anvandare/{id}/aktivera` (admin) | Can log in again; previously revoked tokens stay dead | | Phone lost | `POST /api/auth/logga-ut-alla` (oneself) | All devices are logged out | An administrator cannot disable themselves, and the boundary between organisations holds — org B cannot touch org A's users. The event log is never touched: the history is still tied to the person who did the work. **Login rate limiting** lives in the database, not in memory, so the block holds behind several replicas: 10 failed attempts per account and 30 per source address within 15 minutes give a 429. The block applies to the account even on a correct password — otherwise it could be bypassed by whoever eventually guesses right. Other accounts are unaffected. No password is stored, only that an attempt happened and whether it succeeded; rows older than a day are cleaned up on the write path. ## Observability The services deliberately have almost no dependencies. Pulling in an OpenTelemetry SDK with thirty packages to measure four things would be the wrong trade, so observability rests on two standards that are both just text on stdout: **W3C Trace Context.** The client starts the trace and `traceparent` travels through the platform to the orchestrator. A technician's action can therefore be followed all the way to the model's answer, instead of becoming two unrelated traces. **CloudWatch EMF.** Structured JSON from which CloudWatch itself extracts metrics — no agent, no SDK, nothing that can silently stop working. Every call produces a log line with a **breakdown of the time**: ```json {"nivå":"info","meddelande":"plattform","spårId":"fd5dec…","väg":"/api/arenden/:id/handelser", "status":200,"ms":842.1,"delar":{"databas":{"antal":3,"ms":31.2},"bilaga_skriv":{"antal":1,"ms":780.4}}} ``` That answers the question you actually have at three in the morning: *where did the time go*. Here in object storage, not in the database. ### The route is always normalised The route is normalised (`/api/arenden/:id/handelser`) before it becomes a dimension. Organisation, case id and trace id **never** become dimensions — every unique combination is its own time series that costs money. They sit as ordinary fields, searchable in Logs Insights. A test locks this. ### Queries that tend to be needed ``` # Where did the time go on a slow call? fields tid, väg, ms, delar.databas.ms, delar.modell_handledning.ms, spårId | filter ms > 1000 | sort ms desc | limit 20 # The whole chain for one trace — platform and orchestrator in the same view fields tid, meddelande, väg, ms, status | filter spårId = "fd5dec…" | sort tid # Which model calls cost the most? stats sum(ut) as ut_tokens, avg(ms) as snitt by Uppgift, Modell ``` ### Alarms Beyond the infrastructure alarms there are three on the application's own metrics: response time **p95** above three seconds (the mean hides that every twentieth technician waits unreasonably long), server errors, and the model declining — the last of which indicates that the input contains something unexpected, not an operational fault. ## Multi-tenancy and roles Per the Master Prompt: each customer is its own tenant, no data is mixed between customers. - **Registration creates the organisation** and makes the user its system administrator. - **Admins create users** (technician/supervisor/admin) in their organisation — through the UI or `POST /api/anvandare`. - **All case data is organisation-scoped**: cases are created in the user's organisation and the event API verifies organisation membership on every call — another organisation's cases give a 404. - **The role lives in the JWT** and is verified on the server; the client only adapts the UI. The integration test (`services/plattform/integrationstest.sh`, also run in CI against real Postgres) verifies the whole chain: registration, sync, idempotency, the append-only trigger, organisation isolation, share filtering and role enforcement. ## Security and robustness - **Append-only in three layers:** the client only appends, the API exposes no update or delete, and database triggers reject changes even for a misconfigured role. - **The JWT flow is verified across the services:** the platform signs, the orchestrator verifies the same secret; a wrong secret and expired tokens are rejected (tested). - All containers run **non-root** without capabilities; the backend services with a read-only root filesystem. Both fail closed without their secrets. - **HPA** 2–10 pods per service at 70 % CPU; **PDB** keeps at least one pod up during node drain; readiness and liveness probes everywhere (`pg_isready` for Postgres). ## CI/CD with GitOps **CI** (`.gitea/workflows/felsokning.yml`): tests, production build, integration test against real Postgres and verifying container builds on every push and PR. **CD** — all our own, no GitHub. Source code, build, registry and operations live in our AWS environment. ```mermaid flowchart LR D[Developer] --> G[Gitea on our own EKS] G --> R[Actions runner same cluster] R --> T[tests typecheck integration test] T --> E[(ECR immutable tags)] E -.->|manual step| P[terraform apply] P --> K[EKS] ``` 1. **Gitea** runs in the cluster with its own Actions runners. The workflow syntax is the same as GitHub Actions, so `.gitea/workflows/felsokning.yml` is the same file that used to sit under `.github` — just moved. 2. **The build** publishes to ECR with immutable tags: a tag that has pointed at one build cannot point at another, so "which code is running in production" has an unambiguous answer. 3. **Deployment** is a separate, manual step with an image tag. An image in the registry is not the same thing as an image that is running. Rollback = run again with the previous tag. 4. **Split permissions:** the build role may publish to ECR but not touch the cluster; the operations role the other way round. A compromised build cannot deploy. `.github/workflows/ci.yml` belongs to Semantika and is not touched.