feat(backups): GET /backups et POST /backups/pull (sauvegardes PostgreSQL) #58

Closed
opened 2026-10-10 19:24:04 +00:00 by maximus · 2 comments
Owner

Routes de suivi des sauvegardes de la grappe PostgreSQL du VPS (maximus/la-compagnie-maximus#220) : lecture du status.json écrit par le script du VPS et du dernier tirage du Lenovo, dépôt de ce tirage. Même modèle que /claude-usage : fraîcheur décidée par le serveur.

Contrat status.json (schemaVersion 1)

Écrit par le script du VPS, en dernier et de façon atomique (fichier temporaire dans le même
dossier
puis rename, mode 0644), dans /var/backups/postgres-status/status.json :

{
  "schemaVersion": 1,
  "generatedAt": "2026-10-11T03:04:12Z",
  "ok": true,
  "expectedCount": 8,
  "databases": [
    { "name": "maximus_api", "sizeBytes": 812345, "dumpedAt": "2026-10-11T03:00:41Z", "ok": true }
  ],
  "globals": { "sizeBytes": 4096, "ok": true },
  "mirror": { "ok": true, "uploadedAt": "2026-10-11T03:04:10Z" },
  "retentionDays": { "local": 7, "offsite": 30 }
}
  • ok global : vrai seulement si chaque base, les globals et le miroir sont ok et si
    databases.length === expectedCount (nombre de bases non modèles lu dans pg_database).
  • Une entrée en échec porte "ok": false et "error" pris dans une liste fermée de codes :
    dump_failed, dump_empty, restore_list_failed, invalid_name, encrypt_failed,
    mirror_upload_failed, mirror_purge_failed. Jamais de stderr brut.
  • Dépôt du Lenovo : POST /backups/pull, jeton BACKUPS_INGEST_TOKEN, corps
    { "pulledAt": "<ISO>", "files": <entier>, "ok": true|false, "error"?: <code> }, codes
    pull_failed, rotation_failed. Le serveur pose receivedAt lui-même.
  • GET /backups (jeton de lecture) rend { "vps": {...}|null, "lenovo": {...}|null }, chaque bloc
    filtré par liste blanche (comme sanitizeClaudeUsage) et enrichi par le serveur de
    ageSeconds, stale et staleAfterSeconds (BACKUPS_STALE_SECONDS, 36 h par défaut ; âge du VPS
    sur generatedAt, âge du Lenovo sur receivedAt). { "status": "no_data" } si les deux manquent.
    La carte /admin et /warmup lisent stale tel quel, sans recalcul.

Travail

Issue dans maximus/vps-health-api.

  • GET /backups (jeton de lecture HEALTH_TOKEN) : lit BACKUPS_STATUS_PATH
    (défaut /data/backups-status/status.json) et le dernier dépôt du Lenovo (BACKUPS_PULL_PATH,
    défaut /data/backups-pull/pull.json) ; filtre par liste blanche ; calcule ageSeconds, stale,
    staleAfterSeconds (BACKUPS_STALE_SECONDS, défaut 129600) ; rend le contrat ci-dessus.
  • POST /backups/pull : descripteur dans ROUTES, tokenKind backups-ingest dans TOKEN_KINDS,
    jeton BACKUPS_INGEST_TOKEN ; absent = 503 « BACKUPS_INGEST_TOKEN not configured » ; corps validé
    (champs, types, codes d'erreur de la liste, taille bornée comme CLAUDE_USAGE_MAX_BODY_BYTES) ;
    receivedAt posé par le serveur ; écriture atomique.
  • Lecture tolérante : status.json illisible ou schemaVersion inconnue donne vps: null et
    vpsError, jamais un 500.
  • Tests sur le modèle de __tests__ ; README (variables, montages, tableau d'authentification).

Fichiers concernés

  • index.js (ROUTES, TOKEN_KINDS)
  • backups.js (nouveau, sur le modèle de claude-usage.js)
  • __tests__/ (nouveau fichier de tests)
  • README.md

Critères d'acceptation

  • GET /backups : contrat ci-dessus, ageSeconds/stale/staleAfterSeconds calculés par le serveur ; {status:"no_data"} si rien ; vps: null + vpsError sur fichier illisible ou schemaVersion inconnue, jamais de 500.
  • POST /backups/pull : 503 nommé sans jeton, 401 avec un mauvais jeton, 400 sur un corps invalide (champ manquant, code d'erreur hors liste, taille dépassée), 200 et écriture atomique sinon ; receivedAt posé par le serveur, celui du client ignoré.
  • Liste blanche : un champ inconnu dans status.json n'est pas renvoyé.
  • Tests dans __tests__/ pour chaque cas ci-dessus ; README à jour (variables, montages, authentification).

Review caveats

  • 🔴 ARCHITECTURE — Fraîcheur calculée par les clients, contre la règle de /claude-usage (« Staleness is decided here and only here », receivedAt posé par le serveur). Résolu : intégré : ageSeconds/stale calculés par GET /backups, receivedAt serveur.
  • 🟡 SECURITE — Le champ error aurait recopié le stderr de pg_dump/rclone (hôtes, clés) jusqu'au dashboard. Résolu : intégré : codes fermés, liste blanche côté API.
  • 🟢 ARCHITECTURE — Une base disparue ne se voyait pas. Résolu : intégré : expectedCount.
  • 🟡 ARCHITECTURE — Nom et câblage du jeton hors convention <ROUTE>_INGEST_TOKEN, code de « route fermée » non précisé. Résolu : intégré : BACKUPS_INGEST_TOKEN, TOKEN_KINDS, 503 nommé.

Décisions prises en planification

  • Jeton BACKUPS_INGEST_TOKEN, convention <ROUTE>_INGEST_TOKEN.
  • Fraîcheur calculée ici seulement (BACKUPS_STALE_SECONDS, 36 h par défaut).
  • Codes d'erreur fermés, liste blanche à la lecture.
  • Exécution : status:blocked jusqu'au premier passage des sauvegardes (maximus/la-compagnie-maximus#220), puis milestone planned-* (décision Max).

Spec source

spec-plan-infra-vps.md dans maximus/la-compagnie-maximus (révisé par /review-spec le 2026-10-10). Ce corps est auto-suffisant.

Routes de suivi des sauvegardes de la grappe PostgreSQL du VPS (maximus/la-compagnie-maximus#220) : lecture du `status.json` écrit par le script du VPS et du dernier tirage du Lenovo, dépôt de ce tirage. Même modèle que `/claude-usage` : fraîcheur décidée par le serveur. ## Contrat `status.json` (schemaVersion 1) Écrit par le script du VPS, en dernier et de façon atomique (fichier temporaire **dans le même dossier** puis `rename`, mode `0644`), dans `/var/backups/postgres-status/status.json` : ```json { "schemaVersion": 1, "generatedAt": "2026-10-11T03:04:12Z", "ok": true, "expectedCount": 8, "databases": [ { "name": "maximus_api", "sizeBytes": 812345, "dumpedAt": "2026-10-11T03:00:41Z", "ok": true } ], "globals": { "sizeBytes": 4096, "ok": true }, "mirror": { "ok": true, "uploadedAt": "2026-10-11T03:04:10Z" }, "retentionDays": { "local": 7, "offsite": 30 } } ``` - `ok` global : vrai seulement si chaque base, les globals et le miroir sont `ok` **et** si `databases.length === expectedCount` (nombre de bases non modèles lu dans `pg_database`). - Une entrée en échec porte `"ok": false` et `"error"` pris dans une liste fermée de codes : `dump_failed`, `dump_empty`, `restore_list_failed`, `invalid_name`, `encrypt_failed`, `mirror_upload_failed`, `mirror_purge_failed`. Jamais de stderr brut. - Dépôt du Lenovo : `POST /backups/pull`, jeton `BACKUPS_INGEST_TOKEN`, corps `{ "pulledAt": "<ISO>", "files": <entier>, "ok": true|false, "error"?: <code> }`, codes `pull_failed`, `rotation_failed`. Le serveur pose `receivedAt` lui-même. - `GET /backups` (jeton de lecture) rend `{ "vps": {...}|null, "lenovo": {...}|null }`, chaque bloc filtré par liste blanche (comme `sanitizeClaudeUsage`) et enrichi par le **serveur** de `ageSeconds`, `stale` et `staleAfterSeconds` (`BACKUPS_STALE_SECONDS`, 36 h par défaut ; âge du VPS sur `generatedAt`, âge du Lenovo sur `receivedAt`). `{ "status": "no_data" }` si les deux manquent. La carte `/admin` et `/warmup` lisent `stale` tel quel, sans recalcul. ## Travail Issue dans `maximus/vps-health-api`. - `GET /backups` (jeton de lecture `HEALTH_TOKEN`) : lit `BACKUPS_STATUS_PATH` (défaut `/data/backups-status/status.json`) et le dernier dépôt du Lenovo (`BACKUPS_PULL_PATH`, défaut `/data/backups-pull/pull.json`) ; filtre par liste blanche ; calcule `ageSeconds`, `stale`, `staleAfterSeconds` (`BACKUPS_STALE_SECONDS`, défaut 129600) ; rend le contrat ci-dessus. - `POST /backups/pull` : descripteur dans `ROUTES`, tokenKind `backups-ingest` dans `TOKEN_KINDS`, jeton `BACKUPS_INGEST_TOKEN` ; absent = 503 « BACKUPS_INGEST_TOKEN not configured » ; corps validé (champs, types, codes d'erreur de la liste, taille bornée comme `CLAUDE_USAGE_MAX_BODY_BYTES`) ; `receivedAt` posé par le serveur ; écriture atomique. - Lecture tolérante : `status.json` illisible ou `schemaVersion` inconnue donne `vps: null` et `vpsError`, jamais un 500. - Tests sur le modèle de `__tests__` ; README (variables, montages, tableau d'authentification). ## Fichiers concernés - `index.js` (ROUTES, TOKEN_KINDS) - `backups.js` (nouveau, sur le modèle de `claude-usage.js`) - `__tests__/` (nouveau fichier de tests) - `README.md` ## Critères d'acceptation - [ ] `GET /backups` : contrat ci-dessus, `ageSeconds`/`stale`/`staleAfterSeconds` calculés par le serveur ; `{status:"no_data"}` si rien ; `vps: null` + `vpsError` sur fichier illisible ou `schemaVersion` inconnue, jamais de 500. - [ ] `POST /backups/pull` : 503 nommé sans jeton, 401 avec un mauvais jeton, 400 sur un corps invalide (champ manquant, code d'erreur hors liste, taille dépassée), 200 et écriture atomique sinon ; `receivedAt` posé par le serveur, celui du client ignoré. - [ ] Liste blanche : un champ inconnu dans `status.json` n'est pas renvoyé. - [ ] Tests dans `__tests__/` pour chaque cas ci-dessus ; README à jour (variables, montages, authentification). ## Review caveats - 🔴 ARCHITECTURE — Fraîcheur calculée par les clients, contre la règle de `/claude-usage` (« Staleness is decided here and only here », `receivedAt` posé par le serveur). Résolu : intégré : `ageSeconds`/`stale` calculés par `GET /backups`, `receivedAt` serveur. - 🟡 SECURITE — Le champ `error` aurait recopié le stderr de `pg_dump`/`rclone` (hôtes, clés) jusqu'au dashboard. Résolu : intégré : codes fermés, liste blanche côté API. - 🟢 ARCHITECTURE — Une base disparue ne se voyait pas. Résolu : intégré : `expectedCount`. - 🟡 ARCHITECTURE — Nom et câblage du jeton hors convention `<ROUTE>_INGEST_TOKEN`, code de « route fermée » non précisé. Résolu : intégré : `BACKUPS_INGEST_TOKEN`, `TOKEN_KINDS`, 503 nommé. ## Décisions prises en planification - Jeton `BACKUPS_INGEST_TOKEN`, convention `<ROUTE>_INGEST_TOKEN`. - Fraîcheur calculée ici seulement (`BACKUPS_STALE_SECONDS`, 36 h par défaut). - Codes d'erreur fermés, liste blanche à la lecture. - Exécution : `status:blocked` jusqu'au premier passage des sauvegardes (maximus/la-compagnie-maximus#220), puis milestone `planned-*` (décision Max). ## Spec source `spec-plan-infra-vps.md` dans `maximus/la-compagnie-maximus` (révisé par `/review-spec` le 2026-10-10). Ce corps est auto-suffisant.
maximus added this to the spec-sauvegardes-postgres milestone 2026-10-10 19:24:04 +00:00
maximus added the
status:blocked
type:feature
source:human
labels 2026-10-10 19:24:04 +00:00
Author
Owner

Ajout au contrat status.json (schemaVersion 1) de #220, merge par la PR maximus/la-compagnie-maximus#261 : un bloc racine rotation, {"ok": true} ou {"ok": false, "error": "rotation_failed"} quand un dossier date n a pas pu etre purge sur le VPS. Il entre dans le ok global. A ajouter a la liste blanche du filtre et a la carte (une purge en echec annonce un disque qui se remplit). rotation_failed existait deja dans les codes du depot du Lenovo.

Ajout au contrat `status.json` (schemaVersion 1) de #220, merge par la PR maximus/la-compagnie-maximus#261 : un bloc racine `rotation`, `{"ok": true}` ou `{"ok": false, "error": "rotation_failed"}` quand un dossier date n a pas pu etre purge sur le VPS. Il entre dans le `ok` global. A ajouter a la liste blanche du filtre et a la carte (une purge en echec annonce un disque qui se remplit). `rotation_failed` existait deja dans les codes du depot du Lenovo.
maximus added
status:review
and removed
status:blocked
labels 2026-10-10 23:43:33 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-10-12 23:04:27 +00:00
Author
Owner

Deployed and verified in prod on 2026-10-12 (PR #60, main at 3f0abc1).

  • Mounts (docker inspect): /var/backups/postgres-status -> /data/backups-status RW=false, /data/backups-pull -> /data/backups-pull RW=true (host dir created uid 1000, mode 755). /proc/mounts in the container agrees (ro / rw).
  • Token: BACKUPS_INGEST_TOKEN set in Coolify; fingerprint identical in the container and in ~/.config/maximus-pg-backup-pull.env (mode 600) on the workstation.
  • POST /backups/pull: no token -> 401. Manual run of maximus-pg-backup-pull.service: pull ok latest=2026-10-12 files=9, report ok status=204. pull.json written mode 600, uid 1000.
  • GET /backups -> 200 with both blocks: vps ok, expectedCount 8, 8 databases all ok, globals/mirror/rotation ok, retention 7/30, stale: false; lenovo files: 9, ok: true, server receivedAt, stale: false.
  • The same redeploy put #57 live: GET /claude-usage/history -> 200.
Deployed and verified in prod on 2026-10-12 (PR #60, `main` at `3f0abc1`). - **Mounts** (`docker inspect`): `/var/backups/postgres-status -> /data/backups-status RW=false`, `/data/backups-pull -> /data/backups-pull RW=true` (host dir created uid 1000, mode 755). `/proc/mounts` in the container agrees (`ro` / `rw`). - **Token**: `BACKUPS_INGEST_TOKEN` set in Coolify; fingerprint identical in the container and in `~/.config/maximus-pg-backup-pull.env` (mode 600) on the workstation. - **POST /backups/pull**: no token -> 401. Manual run of `maximus-pg-backup-pull.service`: `pull ok latest=2026-10-12 files=9`, `report ok status=204`. `pull.json` written mode 600, uid 1000. - **GET /backups** -> 200 with both blocks: `vps` ok, `expectedCount` 8, 8 databases all ok, globals/mirror/rotation ok, retention 7/30, `stale: false`; `lenovo` `files: 9`, `ok: true`, server `receivedAt`, `stale: false`. - The same redeploy put #57 live: `GET /claude-usage/history` -> 200.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/vps-health-api#58
No description provided.