From e210b4db1183575fac35a9751a805965ac94b60d Mon Sep 17 00:00:00 2001 From: Luxit Date: Wed, 21 Oct 2026 10:11:48 +0200 Subject: [PATCH] Sync the self-hosting stack --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ README.md | 17 ++++++++++------- kubernetes/02-config.yaml | 19 +++++++++++++------ kubernetes/05-web.yaml | 11 +++++++++-- kubernetes/08-redis-sentinel.yaml | 6 +++++- kubernetes/README.md | 21 ++++++++++++++------- kubernetes/kustomization.yaml | 5 +++-- 7 files changed, 82 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e0e73..1385a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ one of them changed. An entry says which artefact and which version, so `web 0.1 Every version published to a channel appears here. Nothing is written from memory afterwards: a build cannot be published without its entry. +## 2026-10-21 — The last TypeScript release `api 0.151.0` `web 0.185.0` + +**The last release of the TypeScript api and console, and the one to be on before api 1.0.0** — +Jarvis rewritten on .NET — which starts only on a database api 0.149.0 or later has migrated. It +carries the fixes found while rewriting Jarvis, and no new feature. + +- **An api that stayed down after an invitation was accepted starts again.** Since api 0.138.0 the + upgrade check counted a redeemed invitation as a broken one: from the first acceptance on, every + start stopped with `invitation(s) hold no profile … Install api 0.139.1 first`. It no longer does. +- **A fresh compose install starts again**: the secrets directory exists in the image, owned by the + api's user. +- **On Kubernetes, the api writes to the Redis primary.** `02-config.yaml` now sets its two + `REDIS_SENTINEL*` lines, which name what `08-redis-sentinel.yaml` deploys. `REDIS_URL` alone at that + file's `redis` Service, the previous default, could land an api pod on a read-only replica: every + write it made failed with `READONLY` while its readiness probe stayed green. The web's + HorizontalPodAutoscaler moved to the last sync wave, so a first Argo sync succeeds. +- **Security fixes**: revoking a session closes its live connections even when one api pod does not + answer; a tenant's Microsoft Graph token is sent to that tenant's Graph endpoint only, and Graph + risk is judged on the request's own path; PowerShell commands double every quote character; a + diagram exported to SVG keeps only drawing elements; a share link's new expiry applies to the + readers it already admitted; a Teams message and an activity notification wait for approval. +- **The console says when a read was refused or failed**, instead of drawing an empty list or a wrong + answer; dialogs stay open while their write runs; actions are offered only to who may take them; + refusals read in the reader's language. + +Update the API and web images together. No agent update is required, and the schema does not +change. On Kubernetes, take the new `02-config.yaml`, or set the two `REDIS_SENTINEL*` lines in yours. + ## 2026-10-13 — Organization records take only what they expect `api 0.150.0` `web 0.184.0` **Each field of an organization's record now checks what it is given.** diff --git a/README.md b/README.md index c9faddb..2418376 100644 --- a/README.md +++ b/README.md @@ -347,11 +347,14 @@ follow their own upstream tags, `postgres:16-alpine` and `redis:7-alpine`. Schem themselves when the api starts, and the api and the web are versioned independently — their numbers are not meant to match, because usually only one side changed. -**A schema change is one-way.** Jarvis has no migration history: each boot force-syncs the database -to the schema its image carries, adding what a release added and dropping what it removed, without -prompting. Pulling an older api image does not undo that — unlike the image itself, the schema stays -where the newer build left it. Restoring a dump is the only way back, which is what makes the one -below a prerequisite rather than a precaution. +**A schema change is one-way.** Jarvis has no migration history: each boot syncs the database to +the schema its image carries, adding what a release added. A release that would DROP a table or a +column is refused instead: the schema is not touched, the api logs which columns or tables it would +drop and stays down, restarting into the same refusal. If those drops are intended, set +`SCHEMA_ACCEPT_DATA_LOSS=true` in `.env`, `docker compose up -d` once, then remove the line. Pulling +an older api image does not undo a change already applied — unlike the image itself, the schema +stays where the newer build left it. Restoring a dump is the only way back, which is what makes the +one below a prerequisite rather than a precaution. ```sh docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis-$(date +%F).sql.gz @@ -559,8 +562,8 @@ These are deliberate and documented rather than surprises waiting to be found. request. The exception is changing your own password: it revokes every *other* session and keeps the one you are changing it from, so a token stolen from that session stays valid until it expires — 15 minutes by default (`JWT_ACCESS_TTL`). An admin-forced reset drops every session. -- **The api container runs as root**, and so does nginx's master process in the web container, - though its workers drop privileges. Neither image declares a `USER`. +- **Neither container runs as root**: the api runs as `node` (uid 1000) and the web's nginx as + `nginx`, listening on 8080. Secret files mounted into them must be readable by that user. - **`/api/health` answers 200 with `status: "degraded"`** when the database is unreachable, so the container healthcheck alone is not a liveness signal for the database. - **Outbound mail is Microsoft Graph only.** There is no SMTP and no environment variable for any of diff --git a/kubernetes/02-config.yaml b/kubernetes/02-config.yaml index dec2be4..5f4d102 100644 --- a/kubernetes/02-config.yaml +++ b/kubernetes/02-config.yaml @@ -18,17 +18,24 @@ data: # # A SINGLE REDIS IS A SINGLE POINT OF FAILURE FOR EVERY POD AT ONCE. This line is correct for a # managed Redis that fails over behind one address, and correct-but-fragile for one Deployment of - # one Redis. For Sentinel, comment this out and set the two below instead — see - # `08-redis-sentinel.yaml`, which deploys exactly that. + # one Redis. It is IGNORED while the two Sentinel lines below are set — and they are, because + # `kustomization.yaml` deploys `08-redis-sentinel.yaml`. With a managed Redis instead: remove that + # file from `kustomization.yaml`, delete the two lines below, and point this one at it. + # + # NEVER THIS LINE ALONE WITH 08 DEPLOYED. Its `redis` Service is headless: the name answers with + # all three Redis pods, and two of them are read-only replicas. Each api connection lands on one + # of the three, so a pod that drew a replica fails every write it makes — `READONLY You can't + # write against a read only replica` — while its readiness probe, which only reads, stays green. + # That was this file's default while the two lines below were commented out. REDIS_URL: "redis://redis.jarvis.svc.cluster.local:6379" # Set BOTH or neither. Sentinel needs the sentinels and the name of the primary they watch; a # name is refused rather than defaulted, because ioredis's own default (`mymaster`) against a # primary called anything else is not an error — it is a pod that never becomes ready, silently. - # When these are set, REDIS_URL above is ignored. - # - #REDIS_SENTINELS: "redis-sentinel-0.redis-sentinel.jarvis.svc.cluster.local:26379,redis-sentinel-1.redis-sentinel.jarvis.svc.cluster.local:26379,redis-sentinel-2.redis-sentinel.jarvis.svc.cluster.local:26379" - #REDIS_SENTINEL_NAME: "jarvis" + # When these are set, REDIS_URL above is ignored. They name what `08-redis-sentinel.yaml` deploys: + # its three sentinels, and `jarvis`, the primary they monitor. + REDIS_SENTINELS: "redis-sentinel-0.redis-sentinel.jarvis.svc.cluster.local:26379,redis-sentinel-1.redis-sentinel.jarvis.svc.cluster.local:26379,redis-sentinel-2.redis-sentinel.jarvis.svc.cluster.local:26379" + REDIS_SENTINEL_NAME: "jarvis" # ONE, not the two the compose files use, and getting this wrong is a security fault rather than # a cosmetic one. diff --git a/kubernetes/05-web.yaml b/kubernetes/05-web.yaml index 45704c5..48ef252 100644 --- a/kubernetes/05-web.yaml +++ b/kubernetes/05-web.yaml @@ -147,9 +147,16 @@ spec: apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: - # With the Deployment it belongs to. + # A WAVE AFTER the Deployment it scales, in the last one, and that is the whole point of the line. + # + # Argo waits for every resource of a wave to report healthy before it starts the next, and reads + # an HPA as Degraded while the HPA reads no metric for its pods. On a FIRST install those pods are + # seconds old and metrics-server has not scraped them yet ("the HPA was unable to compute the + # replica count … no metrics returned"), so in wave 1 the first sync failed on it every time, and + # only an automated policy's retry got past it. The last wave is applied and not waited on: the + # sync succeeds, and the HPA turns healthy by itself within a minute. Nothing reads it before. annotations: - argocd.argoproj.io/sync-wave: "1" + argocd.argoproj.io/sync-wave: "2" name: jarvis-web namespace: jarvis spec: diff --git a/kubernetes/08-redis-sentinel.yaml b/kubernetes/08-redis-sentinel.yaml index f41caa0..31fcf48 100644 --- a/kubernetes/08-redis-sentinel.yaml +++ b/kubernetes/08-redis-sentinel.yaml @@ -17,7 +17,11 @@ # during the switch — see "What a failover actually costs" at the bottom. # # kubectl apply -f 08-redis-sentinel.yaml -# # then in 02-config.yaml: comment out REDIS_URL, uncomment the two REDIS_SENTINEL* lines +# +# 02-config.yaml points the api at it already: its two REDIS_SENTINEL* lines name these sentinels +# and the primary they monitor. Skip this file for a managed Redis, and delete those two lines with +# it — while they are set, REDIS_URL is ignored. Never point REDIS_URL at the `redis` Service below +# instead: it is headless, and two of the three pods it answers with are read-only replicas. # # ────────────────────────────────────────────────────────────────────────────────────────────── apiVersion: v1 diff --git a/kubernetes/README.md b/kubernetes/README.md index 4483298..41f3769 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -50,10 +50,13 @@ and an index can only do that because one server decides. Do not point pods at r `(replicas + maxSurge + jobs) × connection_limit + reserve`: with 3 api pods at 10 connections each, a migration Job at 5, and headroom, about **150**. The stock Postgres image ships 100, and exhausting it presents as an api that starts and then cannot serve. -- **Redis.** [`08-redis-sentinel.yaml`](08-redis-sentinel.yaml) deploys one, or point `REDIS_URL` at - a managed Redis that fails over behind a single address and skip that file. Persistence is not - required: everything Jarvis keeps in Redis is ephemeral by design, which is exactly why the run - lock is in Postgres instead. +- **Redis.** [`08-redis-sentinel.yaml`](08-redis-sentinel.yaml) deploys one — three Redis, one of + them the primary, and three sentinels — and `02-config.yaml`'s two `REDIS_SENTINEL*` lines point + the api at it. Or use a managed Redis that fails over behind a single address: skip that file, + delete those two lines, and set `REDIS_URL` to it. Never `REDIS_URL` alone at the file's `redis` + Service: it is headless, two of the three pods it answers with are read-only replicas, and an api + pod that draws one fails every write. Persistence is not required: everything Jarvis keeps in + Redis is ephemeral by design, which is exactly why the run lock is in Postgres instead. - **An Ingress controller and a TLS terminator.** Jarvis speaks plain HTTP. ## Order @@ -67,7 +70,7 @@ kubectl apply -f 01-secret-job.yaml # the half that is random bytes (optio kubectl wait --for=condition=complete job/jarvis-mint-secrets -n jarvis --timeout=2m kubectl apply -f 02-config.yaml -kubectl apply -f 08-redis-sentinel.yaml # only if you are running your own Redis +kubectl apply -f 08-redis-sentinel.yaml # unless you use a managed Redis (then edit 02-config.yaml) # The schema, to completion, before any api pod starts. kubectl apply -f 03-migrate-job.yaml @@ -130,10 +133,14 @@ thing being ordered: | `-1` | `jarvis-mint-secrets` **(hook)** | the migration reads `DATABASE_URL` out of what it writes | | `0` | `jarvis-migrate` **(hook)** | an api pod against an unmigrated schema is the failure all of this prevents | | `1` | api and web Deployments, Services | the web pod's `/readyz` proxies the api's health endpoint, so it cannot be ready before the api is | -| `2` | Ingress, PodDisruptionBudgets | an Ingress applied early is a route to a 503 | +| `2` | Ingress, PodDisruptionBudgets, the web's HPA | an Ingress applied early is a route to a 503; the HPA, below | Argo does not begin a wave until the previous one reports healthy, which is what the `kubectl wait` -lines are doing by hand. +lines are doing by hand. The last wave is applied and not waited on, which is why the web's +HorizontalPodAutoscaler is there rather than beside its Deployment: on a first install it has no +metric for pods seconds old, Argo reads that as Degraded, and in wave 1 it failed the first sync +every time. The application can read Degraded for the minute after a first install, until +metrics-server has scraped the new pods; the sync itself has succeeded. ### Why the two Jobs are hooks diff --git a/kubernetes/kustomization.yaml b/kubernetes/kustomization.yaml index fc5cf72..ada7b9e 100644 --- a/kubernetes/kustomization.yaml +++ b/kubernetes/kustomization.yaml @@ -42,8 +42,9 @@ resources: # host along with the main one. - 06b-ingress-uploads.yaml - 07-disruption.yaml - # Remove this line if you point REDIS_URL at a managed Redis. It deploys three Redis processes and - # three sentinels; see the README on what that costs on three nodes. + # Three Redis processes and three sentinels; see the README on what that costs on three nodes. + # 02-config.yaml's two REDIS_SENTINEL* lines point the api at them. For a managed Redis instead, + # remove this line AND those two lines, and set REDIS_URL there to your Redis. - 08-redis-sentinel.yaml # The headless browser and the NetworkPolicy that isolates it. COMMENTED OUT: web access is a # decision rather than a default, and an assistant that can read the internet is a new class of