feat(backups): GET /backups and POST /backups/pull #60

Merged
maximus merged 2 commits from issue-58-backups-routes into main 2026-10-12 23:15:44 +00:00
Owner

Refs #58

Backup monitoring routes for the PostgreSQL cluster (la-compagnie-maximus#220), built on the /claude-usage model: freshness is decided by the server only.

What changes

  • GET /backups (read token): { vps, lenovo, vpsError?, lenovoError? }.
    • vps is the status.json written by backup-postgres.sh, rebuilt through a whitelist in the new pure module backups.js. It includes the rotation block from the 2026-10-10 issue comment.
    • An unknown field, an invalid value, an error code outside the closed list, or a database name the script refused is dropped (ok: false is kept). Never coerced.
    • lenovo is the last pull report, re-validated on read.
    • Each block gets ageSeconds, stale and staleAfterSeconds from the server (BACKUPS_STALE_SECONDS, 36 h). The VPS block is aged from generatedAt, the workstation block from the server-stamped receivedAt, never from pulledAt.
    • A file that exists but is broken gives a null block plus an error code, never a 500, and the other block is still served. no_data only when both files are absent.
  • POST /backups/pull: fourth token kind backups-ingest, on BACKUPS_INGEST_TOKEN (503 naming the variable when it is unset).
    • The body { pulledAt, files, ok, error? } is validated strictly: error is required iff ok is false, and must be pull_failed or rotation_failed.
    • The server stamps receivedAt. The write is atomic, mode 600.
    • writeClaudeUsage() became writeJsonAtomic(filePath, record), shared by both routes. Behaviour is unchanged: same temp name, and the existing claude-usage tests pass untouched.
  • backups.js is added to the Dockerfile COPY.

Deviations from the issue text

  • 204, not 200 on success, and 413, not 400 past 1 KiB: the same answers as the two other ingest routes, from the same readBody(). run-pg-backup-pull.sh treats any 2xx as success.
  • vpsError / lenovoError take one of four codes: unreadable, invalid_json, unsupported_schema, invalid_contract. A missing file gives a null block with no error code.

Tests

  • npm test: 865 passed (101 new in __tests__/backups.test.js; auth.test.js 76 -> 84 with /backups in the matrix; writes.test.js 15 -> 18 with the third write surface).
  • bash test-curl.sh: 57 passed (6 new socket cases). The count pinned in ci.test.js is updated.
  • sh scripts/boot-from-copy.sh: PASS.
  • Mutations: 12 applied to the new code, all red (1 to 15 failures each). The tree was restored from a copy and the diff is empty. The mutations:
    • age the workstation on pulledAt;
    • give the pull route the read token;
    • give the pull route the hosts token;
    • copy a component error raw;
    • hide a broken file behind no_data;
    • store the pull body verbatim;
    • write in mode 644;
    • log err.message;
    • stale on >=;
    • accept ok: true with an error;
    • drop the 64 bound;
    • drop the workstation freshness.

Post-merge checklist (hence Refs, not a closing keyword)

  1. Coolify, Persistent Storages: /var/backups/postgres-status -> /data/backups-status:ro (directory, read-only), and /data/backups-pull -> /data/backups-pull (read-write, owned by uid 1000, create it on the host first).
  2. Coolify env: BACKUPS_INGEST_TOKEN (is_runtime=true, is_buildtime=false).
  3. Manual redeploy (no auto-deploy). Then docker inspect the mounts (RW=false on backups-status, RW=true on backups-pull), and check that GET /backups serves a real vps block, not just a 200.
  4. ThinkPad: put BACKUPS_API_URL and BACKUPS_INGEST_TOKEN in ~/.config/maximus-pg-backup-pull.env (mode 600). The next timer run must log report ok status=204, and GET /backups must then show the lenovo block.
  5. Issue #58 is closed by hand with the evidence once 1 to 4 are done. The admin card and /warmup are consumers in other repos.

🤖 Generated with Claude Code

Refs #58 Backup monitoring routes for the PostgreSQL cluster (la-compagnie-maximus#220), built on the `/claude-usage` model: freshness is decided by the server only. ## What changes - **`GET /backups`** (read token): `{ vps, lenovo, vpsError?, lenovoError? }`. - `vps` is the `status.json` written by `backup-postgres.sh`, rebuilt through a whitelist in the new pure module `backups.js`. It includes the `rotation` block from the 2026-10-10 issue comment. - An unknown field, an invalid value, an error code outside the closed list, or a database name the script refused is dropped (`ok: false` is kept). Never coerced. - `lenovo` is the last pull report, re-validated on read. - Each block gets `ageSeconds`, `stale` and `staleAfterSeconds` from the server (`BACKUPS_STALE_SECONDS`, 36 h). The VPS block is aged from `generatedAt`, the workstation block from the server-stamped `receivedAt`, never from `pulledAt`. - A file that exists but is broken gives a `null` block plus an error code, never a 500, and the other block is still served. `no_data` only when both files are absent. - **`POST /backups/pull`**: fourth token kind `backups-ingest`, on `BACKUPS_INGEST_TOKEN` (503 naming the variable when it is unset). - The body `{ pulledAt, files, ok, error? }` is validated strictly: `error` is required iff `ok` is false, and must be `pull_failed` or `rotation_failed`. - The server stamps `receivedAt`. The write is atomic, mode 600. - `writeClaudeUsage()` became `writeJsonAtomic(filePath, record)`, shared by both routes. Behaviour is unchanged: same temp name, and the existing claude-usage tests pass untouched. - `backups.js` is added to the Dockerfile `COPY`. ## Deviations from the issue text - **204, not 200** on success, and **413, not 400** past 1 KiB: the same answers as the two other ingest routes, from the same `readBody()`. `run-pg-backup-pull.sh` treats any 2xx as success. - `vpsError` / `lenovoError` take one of four codes: `unreadable`, `invalid_json`, `unsupported_schema`, `invalid_contract`. A missing file gives a `null` block with no error code. ## Tests - `npm test`: 865 passed (101 new in `__tests__/backups.test.js`; `auth.test.js` 76 -> 84 with `/backups` in the matrix; `writes.test.js` 15 -> 18 with the third write surface). - `bash test-curl.sh`: 57 passed (6 new socket cases). The count pinned in `ci.test.js` is updated. - `sh scripts/boot-from-copy.sh`: PASS. - Mutations: 12 applied to the new code, all red (1 to 15 failures each). The tree was restored from a copy and the diff is empty. The mutations: - age the workstation on `pulledAt`; - give the pull route the read token; - give the pull route the hosts token; - copy a component `error` raw; - hide a broken file behind `no_data`; - store the pull body verbatim; - write in mode 644; - log `err.message`; - stale on `>=`; - accept `ok: true` with an error; - drop the 64 bound; - drop the workstation freshness. ## Post-merge checklist (hence `Refs`, not a closing keyword) 1. Coolify, Persistent Storages: `/var/backups/postgres-status` -> `/data/backups-status:ro` (directory, read-only), and `/data/backups-pull` -> `/data/backups-pull` (read-write, owned by uid 1000, create it on the host first). 2. Coolify env: `BACKUPS_INGEST_TOKEN` (`is_runtime=true`, `is_buildtime=false`). 3. Manual redeploy (no auto-deploy). Then `docker inspect` the mounts (`RW=false` on backups-status, `RW=true` on backups-pull), and check that `GET /backups` serves a real `vps` block, not just a 200. 4. ThinkPad: put `BACKUPS_API_URL` and `BACKUPS_INGEST_TOKEN` in `~/.config/maximus-pg-backup-pull.env` (mode 600). The next timer run must log `report ok status=204`, and `GET /backups` must then show the `lenovo` block. 5. Issue #58 is closed by hand with the evidence once 1 to 4 are done. The admin card and `/warmup` are consumers in other repos. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-10 23:43:27 +00:00
feat(backups): GET /backups and POST /backups/pull
All checks were successful
CI / verification (pull_request) Successful in 36s
1b619249e2
Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.

- GET /backups (read token) serves { vps, lenovo }: the status.json the
  VPS script writes, rebuilt through a whitelist (closed error codes,
  rotation block included), and the last workstation pull report. Each
  block gets ageSeconds, stale and staleAfterSeconds from the server
  (BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
  server-stamped receivedAt. A broken file gives a null block plus
  vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
  BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
  { pulledAt, files, ok, error? }, server receivedAt, atomic write in
  mode 600 through writeJsonAtomic(), now shared with /claude-usage.
  Answers 204 and 413 like the two other ingest routes (the agent only
  checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
  (101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.

Refs #58

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Revue adversariale — PR #60

Verdict : APPROVE

Résumé. Les deux routes suivent fidèlement le modèle /claude-usage : le routage passe avant l'authentification, le quatrième type de token passe par la table TOKEN_KINDS, l'écriture est atomique en mode 600, la fraîcheur est décidée par le serveur, et une liste blanche jette les valeurs invalides sans jamais les forcer. Le contrat colle au vrai producteur, et chaque mutation essayée sauf une fait rougir la suite.

Ce qui a été exécuté (copie hors dépôt de la tête 1b61924)

  • npx vitest run : 865/865 verts.
  • bash test-curl.sh sur des ports isolés (3189/3188) : 57/57.
  • sh scripts/boot-from-copy.sh (port 3187) : PASS.
  • CI de la tête : success (statut de commit).
  • 14 mutations du relecteur, distinctes de celles de l'auteur :
Mutation Tests rouges
nom de base copié brut (sans DATABASE_NAME_RE) 1
null jeté au lieu d'être gardé (putNullable) 6
receivedAt de pull.json non vérifié à la lecture 2
filtre isPlainObject retiré sur databases 1
ok: false accepté sans error 1
EISDIR traité comme absent 1
âge non borné à 0 (generatedAt futur) 1
plafond de corps passé à 2048 6
rapport écrit dans le dossier de CLAUDE_USAGE_PATH 10
files accepté comme n'importe quel nombre 5
ok retiré des composants 6
VPS daté à l'heure de lecture au lieu de generatedAt 3
borne de 64 décalée d'un cran 1
retentionDays.offsite copié brut 0, survit

Les fichiers ont été restaurés depuis les originaux (diff vide) et la copie a été supprimée.

Points vérifiés

  • Contrat VPS : la-compagnie-maximus/scripts/backup/backup-postgres.sh écrit exactement les champs pris par la liste blanche (schemaVersion, generatedAt au format date -u +%Y-%m-%dT%H:%M:%SZ, ok, expectedCount, databases[] avec dumpedAt à null en cas d'échec, globals, mirror.uploadedAt à null, rotation, retentionDays.{local,offsite}). Ses huit codes d'erreur sont ceux de VPS_ERROR_CODES. L'entrée invalid_name porte un nom arbitraire : il est jeté ici, et l'entrée gardée.
  • Contrat du poste : le corps de report() dans agent/pg-backup-pull/run-pg-backup-pull.sh (pulledAt, files, ok, plus error seulement quand ok est faux, dans pull_failed|rotation_failed) passe la validation stricte, et l'agent accepte le 204 (2??).
  • Auth : le 404 passe avant le 401, BACKUPS_INGEST_TOKEN n'ouvre que sa route, et le 503 nomme la variable.
  • Journal : il ne porte que des codes, ni err.message, ni chemin, ni contenu.
  • Fermeture d'issue : la timeline de #58 ne porte aucun pull_ref en closes (ref_action: none). Le Refs #58 et la fermeture manuelle tiennent.
  • Commit : feat(backups): ..., au format conventionnel.
  • Le helper pushOversized n'avale pas l'erreur de transport : un 413 remplacé par une socket coupée ferait rougir le test.

Suggestions (non bloquantes)

  1. Épingle manquante sur retentionDays.offsite : __tests__/backups.test.js:281 ne teste une valeur invalide que sur local ("7"). Copier offsite brut ne fait rougir aucun test. Le code est correct, l'ajout tient en une ligne (par exemple offsite: -1 dans le même cas). C'est une épingle sur du code correct : elle ne justifie pas un tour de plus, à glisser dans une PR voisine.
  2. Dans la section « Routage et auth » de CLAUDE.md, la puce Coolify (is_runtime=true, is_buildtime=false) ne nomme encore que HEALTH_TOKEN et HOSTS_INGEST_TOKEN. La section Config couvre déjà les deux nouveaux tokens : un rappel en une ligne suffirait.

La checklist post-merge du corps (montages, token, redeploy, docker inspect, env du poste) est complète et cohérente avec la section Montages de CLAUDE.md.

## Revue adversariale — PR #60 **Verdict : APPROVE** **Résumé.** Les deux routes suivent fidèlement le modèle `/claude-usage` : le routage passe avant l'authentification, le quatrième type de token passe par la table `TOKEN_KINDS`, l'écriture est atomique en mode 600, la fraîcheur est décidée par le serveur, et une liste blanche jette les valeurs invalides sans jamais les forcer. Le contrat colle au vrai producteur, et chaque mutation essayée sauf une fait rougir la suite. ### Ce qui a été exécuté (copie hors dépôt de la tête `1b61924`) - `npx vitest run` : 865/865 verts. - `bash test-curl.sh` sur des ports isolés (3189/3188) : 57/57. - `sh scripts/boot-from-copy.sh` (port 3187) : PASS. - CI de la tête : `success` (statut de commit). - 14 mutations du relecteur, distinctes de celles de l'auteur : | Mutation | Tests rouges | |---|---| | nom de base copié brut (sans `DATABASE_NAME_RE`) | 1 | | `null` jeté au lieu d'être gardé (`putNullable`) | 6 | | `receivedAt` de `pull.json` non vérifié à la lecture | 2 | | filtre `isPlainObject` retiré sur `databases` | 1 | | `ok: false` accepté sans `error` | 1 | | `EISDIR` traité comme absent | 1 | | âge non borné à 0 (`generatedAt` futur) | 1 | | plafond de corps passé à 2048 | 6 | | rapport écrit dans le dossier de `CLAUDE_USAGE_PATH` | 10 | | `files` accepté comme n'importe quel nombre | 5 | | `ok` retiré des composants | 6 | | VPS daté à l'heure de lecture au lieu de `generatedAt` | 3 | | borne de 64 décalée d'un cran | 1 | | **`retentionDays.offsite` copié brut** | **0, survit** | Les fichiers ont été restaurés depuis les originaux (`diff` vide) et la copie a été supprimée. ### Points vérifiés - **Contrat VPS** : `la-compagnie-maximus/scripts/backup/backup-postgres.sh` écrit exactement les champs pris par la liste blanche (`schemaVersion`, `generatedAt` au format `date -u +%Y-%m-%dT%H:%M:%SZ`, `ok`, `expectedCount`, `databases[]` avec `dumpedAt` à `null` en cas d'échec, `globals`, `mirror.uploadedAt` à `null`, `rotation`, `retentionDays.{local,offsite}`). Ses huit codes d'erreur sont ceux de `VPS_ERROR_CODES`. L'entrée `invalid_name` porte un nom arbitraire : il est jeté ici, et l'entrée gardée. - **Contrat du poste** : le corps de `report()` dans `agent/pg-backup-pull/run-pg-backup-pull.sh` (`pulledAt`, `files`, `ok`, plus `error` seulement quand `ok` est faux, dans `pull_failed|rotation_failed`) passe la validation stricte, et l'agent accepte le 204 (`2??`). - **Auth** : le 404 passe avant le 401, `BACKUPS_INGEST_TOKEN` n'ouvre que sa route, et le 503 nomme la variable. - **Journal** : il ne porte que des codes, ni `err.message`, ni chemin, ni contenu. - **Fermeture d'issue** : la timeline de #58 ne porte aucun `pull_ref` en `closes` (`ref_action: none`). Le `Refs #58` et la fermeture manuelle tiennent. - **Commit** : `feat(backups): ...`, au format conventionnel. - Le helper `pushOversized` n'avale pas l'erreur de transport : un 413 remplacé par une socket coupée ferait rougir le test. ### Suggestions (non bloquantes) 1. **Épingle manquante sur `retentionDays.offsite`** : `__tests__/backups.test.js:281` ne teste une valeur invalide que sur `local` (`"7"`). Copier `offsite` brut ne fait rougir aucun test. Le code est correct, l'ajout tient en une ligne (par exemple `offsite: -1` dans le même cas). C'est une épingle sur du code correct : elle ne justifie pas un tour de plus, à glisser dans une PR voisine. 2. Dans la section « Routage et auth » de `CLAUDE.md`, la puce Coolify (`is_runtime=true, is_buildtime=false`) ne nomme encore que `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN`. La section Config couvre déjà les deux nouveaux tokens : un rappel en une ligne suffirait. La checklist post-merge du corps (montages, token, redeploy, `docker inspect`, env du poste) est complète et cohérente avec la section Montages de `CLAUDE.md`.
maximus added 1 commit 2026-10-12 23:15:02 +00:00
test(backups): an invalid offsite retention is dropped
All checks were successful
CI / verification (pull_request) Successful in 34s
CI / verification (push) Successful in 36s
3f0abc1871
Review follow-up on #60: only `local` was checked with an invalid value,
so copying `retentionDays.offsite` unvalidated left the suite green.
Also name all four tokens in the Coolify runtime-only rule of CLAUDE.md.

Refs #58

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus merged commit 3f0abc1871 into main 2026-10-12 23:15:44 +00:00
maximus deleted branch issue-58-backups-routes 2026-10-12 23:15:44 +00:00
Sign in to join this conversation.
No reviewers
No milestone
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#60
No description provided.