feat(agent): daily pull of the encrypted PostgreSQL backups #59

Merged
maximus merged 4 commits from pg-backup-pull-agent into main 2026-10-10 23:22:59 +00:00
Owner

Refs maximus/la-compagnie-maximus#220

Lenovo side of the PostgreSQL cluster backups: a daily pull of the age-encrypted dumps from the VPS outbox.

What

  • agent/pg-backup-pull/run-pg-backup-pull.sh (POSIX sh): rsync over the classic sshd on 100.86.30.21:2222 (Tailscale SSH keeps port 22), dedicated key ~/.ssh/pgbackup_pull_ed25519 limited on the VPS to rrsync -ro from the Lenovo IP, host key pinned in ~/.ssh/known_hosts_pgbackup. Only directories and *.age cross; no --delete. 30-day rotation that fails loudly. Report to POST /backups/pull (contract in the issue) only when BACKUPS_INGEST_TOKEN is set, token through a header file, never argv. Never decrypts.
  • systemd user unit + timer (OnCalendar=daily, Persistent=true, Restart=on-failure every 15 min, 8 tries in 4 h): the one assumed departure from the cron convention, to catch up a pass missed while the laptop is off.
  • agent/README.md: install, check, exit codes.
  • __tests__/agent-pg-backup-pull.test.js: 21 tests, 20 with fake rsync, ssh and curl (argv and filter order, .age filter, no --delete, pinned host key, token never in argv nor output, header file removed, pull_failed, rotation_failed through a fake rm, rotation anchored on the newest backup, stale-latest and empty-directory warnings, partial files not counted, future or impossible dated names ignored, pulled files never rewritten, header file removed on SIGTERM, exit codes), plus one case through the real rsync where it is installed. The script runs under busybox (node:22-alpine CI).

Verification

  • Full suite: 752 passed (after three review rounds, 5208957).
  • Mutations (each one red): --exclude='*' removed, token in argv, rotation failure ignored, header file left behind. A chmod 755 on the destination survives because the later chmod -R go-rwx restores it (redundant layer, not a gap).
  • Installed on the Lenovo from a copy: first run under systemd pulled 9 files (2026-10-10), report skipped (no token yet), timer armed for 00:10.
  • The agent is not in the server image (existing Dockerfile guard unchanged).

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

  • The timer catches up a missed pass (laptop off at the scheduled time, systemctl --user list-timers after wake-up).
  • Step 6 of the plan: BACKUPS_INGEST_TOKEN written in ~/.config/maximus-pg-backup-pull.env once POST /backups/pull is deployed (maximus/vps-health-api#58).

🤖 Generated with Claude Code

Refs maximus/la-compagnie-maximus#220 Lenovo side of the PostgreSQL cluster backups: a daily pull of the age-encrypted dumps from the VPS outbox. ## What - `agent/pg-backup-pull/run-pg-backup-pull.sh` (POSIX sh): rsync over the classic sshd on `100.86.30.21:2222` (Tailscale SSH keeps port 22), dedicated key `~/.ssh/pgbackup_pull_ed25519` limited on the VPS to `rrsync -ro` from the Lenovo IP, host key pinned in `~/.ssh/known_hosts_pgbackup`. Only directories and `*.age` cross; no `--delete`. 30-day rotation that fails loudly. Report to `POST /backups/pull` (contract in the issue) only when `BACKUPS_INGEST_TOKEN` is set, token through a header file, never argv. Never decrypts. - systemd user unit + timer (`OnCalendar=daily`, `Persistent=true`, `Restart=on-failure` every 15 min, 8 tries in 4 h): the one assumed departure from the cron convention, to catch up a pass missed while the laptop is off. - `agent/README.md`: install, check, exit codes. - `__tests__/agent-pg-backup-pull.test.js`: 21 tests, 20 with fake rsync, ssh and curl (argv and filter order, .age filter, no --delete, pinned host key, token never in argv nor output, header file removed, pull_failed, rotation_failed through a fake rm, rotation anchored on the newest backup, stale-latest and empty-directory warnings, partial files not counted, future or impossible dated names ignored, pulled files never rewritten, header file removed on SIGTERM, exit codes), plus one case through the real rsync where it is installed. The script runs under busybox (node:22-alpine CI). ## Verification - Full suite: 752 passed (after three review rounds, 5208957). - Mutations (each one red): `--exclude='*'` removed, token in argv, rotation failure ignored, header file left behind. A `chmod 755` on the destination survives because the later `chmod -R go-rwx` restores it (redundant layer, not a gap). - Installed on the Lenovo from a copy: first run under systemd pulled 9 files (`2026-10-10`), report skipped (no token yet), timer armed for 00:10. - The agent is not in the server image (existing Dockerfile guard unchanged). ## Post-merge checklist (hence Refs, not a closing keyword) - [ ] The timer catches up a missed pass (laptop off at the scheduled time, `systemctl --user list-timers` after wake-up). - [ ] Step 6 of the plan: `BACKUPS_INGEST_TOKEN` written in `~/.config/maximus-pg-backup-pull.env` once `POST /backups/pull` is deployed (maximus/vps-health-api#58). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-10 20:18:30 +00:00
feat(agent): daily pull of the encrypted PostgreSQL backups
Some checks failed
CI / verification (pull_request) Failing after 28s
59b963220d
POSIX sh agent run by a systemd user timer (Persistent=true, retries on
failure): rsync over the classic sshd on 100.86.30.21:2222 with a pinned
host key, .age files only, 30-day rotation that fails loudly, and a
report to POST /backups/pull when the ingest token is configured (sent
through a header file, never argv). Never decrypts.

Refs maximus/la-compagnie-maximus#220

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

Revue adversariale — REQUEST_CHANGES

Résumé. Le script est solide sur le fond (clé épinglée, filtre .age, pas de --delete, jeton par fichier d'en-tête, rotation qui échoue bruyamment), mais la CI de la tête 59b9632 est rouge : 9 des 12 nouveaux tests échouent dans node:22-alpine. Le « 743 passed » du corps de la PR n'est vrai que sur le poste.

Ce que la revue a exécuté : le log du run 45 (CI), le fichier de test en local (12/12 vert), et quatre mutations sur une copie hors dépôt (supprimée en fin de revue).

Points bloquants

  1. CI rouge : le script dépend d'outils GNU absents du conteneur de CI (agent/pg-backup-pull/run-pg-backup-pull.sh:38-49, :118, :126). Les 9 échecs sortent tous en code 1 avant la création de dest (ENOENT .../dest). C'est donc la vérification d'installation : ssh n'existe pas dans node:22-alpine, et le PATH du test (${bin}:/usr/bin:/bin) ne fournit qu'un faux rsync et un faux curl. Ajouter un faux ssh (et flock si besoin) ne suffira pas. Sondé avec busybox : date -u -d "2026-10-11 -29 days" répond invalid date, et find -printf répond unrecognized: -printf. Sous set -eu, le calcul de cutoff ou de latest arrêterait le script. Deux voies possibles :

    • script portable : latest par une boucle sur le glob "$DEST"/*/ déjà utilisée pour la rotation, cutoff par l'arithmétique d'epoch (date -u -d "$TODAY" +%s moins (RETENTION_DAYS-1)*86400, puis date -u -d "@$x" +%F), plus de faux ssh et flock dans bin/ ;
    • ou des binaires GNU installés dans la CI. Attention : ci.test.js épingle les étapes du workflow.

    Dans les deux cas, valider sur le statut de commit, pas en local.

  2. Le test rotation_failed ne peut pas passer en root (__tests__/agent-pg-backup-pull.test.js:176-190). La CI tourne en root (conteneur sans USER), et root ignore le chmod 0500 : rm -rf réussira, le script sortira 0 et le test restera rouge même après le point 1. Il faut injecter l'échec autrement, par exemple avec un faux rm dans bin/ qui échoue sur le chemin 2026-09-01. Ce cas porte la garantie « la rotation échoue bruyamment ». C'est aussi une des mutations citées dans la PR, donc il doit tourner pour de vrai en CI.

Suggestions (non bloquantes)

  • Mutation survivante : retirer --include='*/' laisse les 12 tests verts. Avec --exclude='*' derrière, les dossiers datés ne passent plus. rsync ne tire alors rien et sort 0, le script journalise pull ok puis rapporte ok:true. C'est un échec silencieux de toute la sauvegarde. Le faux rsync dépose ses fichiers quel que soit le filtre. Il faudrait au minimum une assertion sur --include=*/ placé avant --exclude=*. Mieux, si rsync est présent : un cas qui fait passer le vrai rsync sur une boîte d'envoi locale contenant un .dump en clair et un .age, et qui vérifie que seul le .age arrive. Les trois autres mutations du relecteur rougissent : rétention -lt→-le (1 rouge), StrictHostKeyChecking=accept-new (1 rouge), exit 0 après rotation_failed (1 rouge).
  • files compte le dossier daté local le plus récent, pas celui du jour (:118-122). Si le VPS cesse de produire, chaque passage rapporte ok:true, files=9 sur un vieux dossier. Le bloc vps de GET /backups (âge sur generatedAt) couvre ce cas côté tableau de bord, donc rien à changer dans le contrat. Une ligne de journal quand latest diffère de TODAY aiderait au diagnostic.
  • Marge du timer (maximus-pg-backup-pull.timer) : OnCalendar=daily tombe à 00:00 heure locale, soit 04:00 UTC en heure avancée. Ça laisse une heure après le passage VPS de 03:00 UTC, moins le délai aléatoire. Un OnCalendar=*-*-* 05:00 UTC dirait l'intention plutôt que de dépendre du fuseau du poste. Si le VPS n'écrit pas ses .age de façon atomique, un tirage trop tôt prendrait un fichier partiel. Le passage du lendemain le corrigerait (-t), mais le rapport du jour dirait ok.

Vérifié sans grief

Aucun secret dans le diff, et le jeton n'apparaît ni dans argv ni dans la sortie (testé). Le fichier d'en-tête est créé en umask 077 puis supprimé. Le contrat de corps correspond à celui de #220. Il n'y a pas de mot-clé de fermeture : la timeline de la PR ne porte aucun closes, conforme au « Refs » annoncé. Le commit suit le format conventionnel. agent/ reste hors de l'image.

Actions faites

  • Log CI du run 45 lu, tests locaux et 4 mutations rejoués sur une copie dans le scratchpad, copie supprimée.
  • Label non modifié : l'issue liée est maximus/la-compagnie-maximus#220, une issue parapluie d'un autre dépôt, encore en status:review. À basculer à la main si cette PR est la seule en revue dessus.
## Revue adversariale — REQUEST_CHANGES **Résumé.** Le script est solide sur le fond (clé épinglée, filtre `.age`, pas de `--delete`, jeton par fichier d'en-tête, rotation qui échoue bruyamment), mais la CI de la tête `59b9632` est **rouge** : 9 des 12 nouveaux tests échouent dans `node:22-alpine`. Le « 743 passed » du corps de la PR n'est vrai que sur le poste. Ce que la revue a exécuté : le log du run 45 (CI), le fichier de test en local (12/12 vert), et quatre mutations sur une copie hors dépôt (supprimée en fin de revue). ### Points bloquants 1. **CI rouge : le script dépend d'outils GNU absents du conteneur de CI** (`agent/pg-backup-pull/run-pg-backup-pull.sh:38-49`, `:118`, `:126`). Les 9 échecs sortent tous en code 1 avant la création de `dest` (`ENOENT .../dest`). C'est donc la vérification d'installation : `ssh` n'existe pas dans `node:22-alpine`, et le `PATH` du test (`${bin}:/usr/bin:/bin`) ne fournit qu'un faux `rsync` et un faux `curl`. Ajouter un faux `ssh` (et `flock` si besoin) ne suffira pas. Sondé avec busybox : `date -u -d "2026-10-11 -29 days"` répond `invalid date`, et `find -printf` répond `unrecognized: -printf`. Sous `set -eu`, le calcul de `cutoff` ou de `latest` arrêterait le script. Deux voies possibles : - script portable : `latest` par une boucle sur le glob `"$DEST"/*/` déjà utilisée pour la rotation, `cutoff` par l'arithmétique d'epoch (`date -u -d "$TODAY" +%s` moins `(RETENTION_DAYS-1)*86400`, puis `date -u -d "@$x" +%F`), plus de faux `ssh` et `flock` dans `bin/` ; - ou des binaires GNU installés dans la CI. Attention : `ci.test.js` épingle les étapes du workflow. Dans les deux cas, valider sur le statut de commit, pas en local. 2. **Le test `rotation_failed` ne peut pas passer en root** (`__tests__/agent-pg-backup-pull.test.js:176-190`). La CI tourne en root (conteneur sans `USER`), et root ignore le `chmod 0500` : `rm -rf` réussira, le script sortira 0 et le test restera rouge même après le point 1. Il faut injecter l'échec autrement, par exemple avec un faux `rm` dans `bin/` qui échoue sur le chemin `2026-09-01`. Ce cas porte la garantie « la rotation échoue bruyamment ». C'est aussi une des mutations citées dans la PR, donc il doit tourner pour de vrai en CI. ### Suggestions (non bloquantes) - **Mutation survivante : retirer `--include='*/'` laisse les 12 tests verts.** Avec `--exclude='*'` derrière, les dossiers datés ne passent plus. rsync ne tire alors rien et sort 0, le script journalise `pull ok` puis rapporte `ok:true`. C'est un échec silencieux de toute la sauvegarde. Le faux `rsync` dépose ses fichiers quel que soit le filtre. Il faudrait au minimum une assertion sur `--include=*/` placé avant `--exclude=*`. Mieux, si `rsync` est présent : un cas qui fait passer le vrai rsync sur une boîte d'envoi locale contenant un `.dump` en clair et un `.age`, et qui vérifie que seul le `.age` arrive. Les trois autres mutations du relecteur rougissent : rétention `-lt`→`-le` (1 rouge), `StrictHostKeyChecking=accept-new` (1 rouge), `exit 0` après `rotation_failed` (1 rouge). - **`files` compte le dossier daté local le plus récent, pas celui du jour** (`:118-122`). Si le VPS cesse de produire, chaque passage rapporte `ok:true, files=9` sur un vieux dossier. Le bloc `vps` de `GET /backups` (âge sur `generatedAt`) couvre ce cas côté tableau de bord, donc rien à changer dans le contrat. Une ligne de journal quand `latest` diffère de `TODAY` aiderait au diagnostic. - **Marge du timer** (`maximus-pg-backup-pull.timer`) : `OnCalendar=daily` tombe à 00:00 heure locale, soit 04:00 UTC en heure avancée. Ça laisse une heure après le passage VPS de 03:00 UTC, moins le délai aléatoire. Un `OnCalendar=*-*-* 05:00 UTC` dirait l'intention plutôt que de dépendre du fuseau du poste. Si le VPS n'écrit pas ses `.age` de façon atomique, un tirage trop tôt prendrait un fichier partiel. Le passage du lendemain le corrigerait (`-t`), mais le rapport du jour dirait `ok`. ### Vérifié sans grief Aucun secret dans le diff, et le jeton n'apparaît ni dans argv ni dans la sortie (testé). Le fichier d'en-tête est créé en `umask 077` puis supprimé. Le contrat de corps correspond à celui de #220. Il n'y a pas de mot-clé de fermeture : la timeline de la PR ne porte aucun `closes`, conforme au « Refs » annoncé. Le commit suit le format conventionnel. `agent/` reste hors de l'image. ### Actions faites - Log CI du run 45 lu, tests locaux et 4 mutations rejoués sur une copie dans le scratchpad, copie supprimée. - **Label non modifié** : l'issue liée est `maximus/la-compagnie-maximus#220`, une issue parapluie d'un autre dépôt, encore en `status:review`. À basculer à la main si cette PR est la seule en revue dessus.
maximus added 1 commit 2026-10-10 22:56:08 +00:00
fix(agent): run the backup pull under busybox, catch an empty filter
All checks were successful
CI / verification (pull_request) Successful in 31s
b87b3d7253
The CI runs the tests in node:22-alpine, where 9 of the 12 cases failed:
no ssh on PATH, and busybox has neither `find -printf` nor
`date -d "... days"`. The newest dated directory is now found by a loop
over the glob, and the rotation cutoff by epoch arithmetic, which GNU
date and busybox both read. The tests add a fake ssh, and a fake flock
where the host has none.

The rotation_failed case failed the pass through a read-only directory,
which root ignores: it now uses a fake rm.

Review suggestions: the rsync argv must put --include=*/ before
--exclude=* (without it nothing crosses and the pass still says ok), and
a case runs the real rsync through the fake ssh when rsync is present.
A pass whose newest directory is not today's logs a warning.

Refs maximus/la-compagnie-maximus#220

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

Correctifs après revue (b87b3d7)

Nouvelle tête non relue. Verdict CI à lire sur le statut de commit.

Bloquants

  1. Busybox : latest est trouvé par une boucle sur le glob "$DEST"/*/, et cutoff par l'arithmétique d'epoch (date -u -d "$TODAY" +%s, puis date -u -d "@N" +%F). Les tests ajoutent un faux ssh, et un faux flock seulement si l'hôte n'en a pas. Vérifié en local avec un PATH réduit à busybox seul : le nouveau script sort 0 et la rotation retire 2026-09-11, alors que l'ancien reproduit find: unrecognized: -printf et date: invalid date.
  2. rotation_failed en root : l'échec vient d'un faux rm (bin/rm) qui refuse le chemin 2026-09-01, et non plus des droits du dossier. Le test exige aussi la ligne rotation: could not remove 2026-09-01 et que le dossier soit toujours là.

Suggestions

  • --include='*/' : le test d'argv exige qu'il soit présent et placé avant --exclude=*. Un nouveau cas fait passer le vrai rsync, par le faux ssh, sur une boîte d'envoi locale qui contient un .dump.age, un .dump en clair et un .sql à la racine. Seul le .age arrive, dans son dossier daté. Ce cas est sauté là où rsync est absent (image de CI), et le test d'argv couvre ce cas.
  • Dossier du jour absent : la ligne warning: no directory for <today>, latest is <date> est journalisée, avec deux cas (avec et sans le dossier du jour). Documenté dans agent/README.md.
  • Le timer n'a pas changé : Max a choisi de reporter la suggestion 3 après sa vérification du rattrapage.

Mutations (copie de sauvegarde dans le scratchpad, diff vide après restauration)

Mutation Rouges
--include='*/' retiré (la mutation verte de la revue) 2
échec de rm ignoré (rotation_ok=false retiré) 1
ligne d'avertissement retirée 1
latest prend le plus ancien (-gt en -lt) 0 au premier passage, puis 1 après l'ajout d'une assertion au test de rotation

Suite complète : 743 cas avant, 746 après.

## Correctifs après revue (b87b3d7) Nouvelle tête non relue. Verdict CI à lire sur le statut de commit. ### Bloquants 1. **Busybox** : `latest` est trouvé par une boucle sur le glob `"$DEST"/*/`, et `cutoff` par l'arithmétique d'epoch (`date -u -d "$TODAY" +%s`, puis `date -u -d "@N" +%F`). Les tests ajoutent un faux `ssh`, et un faux `flock` seulement si l'hôte n'en a pas. Vérifié en local avec un `PATH` réduit à busybox seul : le nouveau script sort 0 et la rotation retire `2026-09-11`, alors que l'ancien reproduit `find: unrecognized: -printf` et `date: invalid date`. 2. **`rotation_failed` en root** : l'échec vient d'un faux `rm` (`bin/rm`) qui refuse le chemin `2026-09-01`, et non plus des droits du dossier. Le test exige aussi la ligne `rotation: could not remove 2026-09-01` et que le dossier soit toujours là. ### Suggestions - **`--include='*/'`** : le test d'argv exige qu'il soit présent et placé avant `--exclude=*`. Un nouveau cas fait passer le **vrai rsync**, par le faux `ssh`, sur une boîte d'envoi locale qui contient un `.dump.age`, un `.dump` en clair et un `.sql` à la racine. Seul le `.age` arrive, dans son dossier daté. Ce cas est sauté là où rsync est absent (image de CI), et le test d'argv couvre ce cas. - **Dossier du jour absent** : la ligne `warning: no directory for <today>, latest is <date>` est journalisée, avec deux cas (avec et sans le dossier du jour). Documenté dans `agent/README.md`. - Le timer n'a pas changé : Max a choisi de reporter la suggestion 3 après sa vérification du rattrapage. ### Mutations (copie de sauvegarde dans le scratchpad, `diff` vide après restauration) | Mutation | Rouges | |---|---| | `--include='*/'` retiré (la mutation verte de la revue) | 2 | | échec de `rm` ignoré (`rotation_ok=false` retiré) | 1 | | ligne d'avertissement retirée | 1 | | `latest` prend le plus ancien (`-gt` en `-lt`) | 0 au premier passage, puis 1 après l'ajout d'une assertion au test de rotation | Suite complète : 743 cas avant, 746 après.
Author
Owner

Re-revue adversariale (b87b3d7) — REQUEST_CHANGES

Résumé. Les deux bloquants de la première passe sont corrigés : la CI de b87b3d7 est verte (CI / verification en succès), le script tourne sous busybox, et rotation_failed passe par un faux rm. Il reste un défaut nouveau, d'une autre famille : la rotation se cale sur la date du jour, pas sur la dernière sauvegarde reçue. Quand le VPS cesse de produire, le poste efface sa dernière copie hors site au bout de 30 jours.

Ce que la revue a exécuté : la suite complète sur une copie hors dépôt (746/746, le cas avec le vrai rsync compris), 7 mutations et 2 sondes de comportement. La copie a été supprimée.

Point bloquant

  1. La rotation peut supprimer la dernière sauvegarde (agent/pg-backup-pull/run-pg-backup-pull.sh:151-152). cutoff vaut TODAY - 29 jours, quel que soit le contenu de $DEST. Sonde : un VPS arrêté depuis le 2026-09-01, un passage le 2026-10-11.

    pg-backup-pull: pull ok latest=2026-09-01 files=1
    pg-backup-pull: warning: no directory for 2026-10-11, latest is 2026-09-01
    pg-backup-pull: rotation: removed 2026-09-01
    exit=0 dest=[]
    

    On obtient trois effets.

    • Perte de la copie hors site : ~/backups/postgres/ est vidé, et le code de sortie vaut 0. Cette copie existe précisément pour survivre à une panne côté VPS. La panne franche (VPS injoignable) est bien couverte, puisque pull_failed sort avant la rotation. Mais la panne lente ne l'est pas : un cron mort ou un pg_dump qui échoue alors que la boîte d'envoi garde ses vieux dossiers.
    • Rapport faux : latest et files sont calculés avant la rotation. Le rapport dit donc ok:true, files=N sur un dossier effacé dans le même passage.
    • Va-et-vient : sans --delete, la boîte d'envoi du VPS garde ses vieux dossiers tant que son propre script ne tourne plus. Chaque passage les retire, puis les efface aussitôt.

    Le même calcul effacerait tout si l'horloge du poste partait de plus de 30 jours en avant. Le seul garde-fou est l'alerte stale du bloc vps de GET /backups, et #58 est en status:blocked : pour l'instant, rien ne prévient hors du journal.

    Correctif proposé : calculer cutoff à partir de latest, et non de TODAY. On garde ainsi les 30 jours qui précèdent la sauvegarde la plus récente, et le dossier le plus récent n'est jamais supprimé. Le test de rotation existant reste valable tel quel, puisque latest y vaut TODAY. Il faut un cas de plus : un latest vieux de plus de 30 jours qui survit au passage. Mutation à faire rougir : remettre TODAY dans le calcul de today_epoch.

Suggestions (non bloquantes)

  • Un dossier daté vide passe pour un succès. Sonde : la boîte d'envoi n'a qu'un dossier 2026-10-11/ sans .age, par exemple parce que le chiffrement a échoué ou qu'il ne contient qu'un dump en clair. Résultat : pull ok latest=2026-10-11 files=0, sans avertissement, avec un rapport ok:true. Avec --include='*/', rsync crée les dossiers même vides. -m (--prune-empty-dirs) les écarterait, et l'avertissement « no directory for today » se déclencherait alors. Ou bien journaliser un avertissement quand files=0.
  • --partial-dir=.rsync-partial (:106, :138) : un fichier partiel laissé par un transfert interrompu, dont la source a ensuite tourné côté VPS, reste dans <date>/.rsync-partial/x.age. find le compte alors, puisqu'il n'a pas de -maxdepth 1 (busybox l'accepte).
  • Fichier d'en-tête : si le processus est tué entre sa création et le rm -f, le jeton reste sur disque (mode 600 dans un dossier en 700, donc risque faible). Un trap 'rm -f "$header_file"' EXIT fermerait ce cas.
  • Le cas qui passe par le vrai rsync est sauté en CI, faute de rsync dans node:22-alpine. C'est documenté, et le test d'argv couvre l'ordre des filtres. Il ne tourne cependant que sur le poste.

Mutations du relecteur (copie hors dépôt, git diff --stat d'une ligne chacune, restaurée après)

Mutation Rouges
--include='*/' retiré 2
--exclude='*' affaibli en --exclude='*.tmp' 2
échec de rm ignoré 1
rétention RETENTION_DAYS - 1 → RETENTION_DAYS 1
chmod -R go-rwx retiré 1
rapport pull_failed retiré 1
fichier d'en-tête laissé en place 1

Vérifié sans grief

Le corps envoyé correspond au contrat de #58 (pulledAt, files, ok, error limité à pull_failed/rotation_failed). Le jeton ne passe ni par argv ni par la sortie. L'unité Type=oneshot n'a pas de délai de démarrage par défaut, donc le timeout 900 de rsync n'est pas coupé par systemd. Restart=on-failure est accepté sur un oneshot avec systemd 244 ou plus. Les commits suivent le format conventionnel. La timeline ne porte aucun closes (Refs seulement).

Actions faites

  • Suite et mutations rejouées sur une copie dans le scratchpad, sondes comprises. Copie et sondes supprimées.
  • Label non modifié : comme à la première passe, l'issue liée est maximus/la-compagnie-maximus#220, une issue parapluie d'un autre dépôt, toujours en status:review. À basculer à la main.
## Re-revue adversariale (b87b3d7) — REQUEST_CHANGES **Résumé.** Les deux bloquants de la première passe sont corrigés : la CI de `b87b3d7` est verte (`CI / verification` en succès), le script tourne sous busybox, et `rotation_failed` passe par un faux `rm`. Il reste un défaut nouveau, d'une autre famille : la rotation se cale sur la date du jour, pas sur la dernière sauvegarde reçue. Quand le VPS cesse de produire, le poste efface sa dernière copie hors site au bout de 30 jours. Ce que la revue a exécuté : la suite complète sur une copie hors dépôt (746/746, le cas avec le vrai rsync compris), 7 mutations et 2 sondes de comportement. La copie a été supprimée. ### Point bloquant 1. **La rotation peut supprimer la dernière sauvegarde** (`agent/pg-backup-pull/run-pg-backup-pull.sh:151-152`). `cutoff` vaut `TODAY - 29 jours`, quel que soit le contenu de `$DEST`. Sonde : un VPS arrêté depuis le `2026-09-01`, un passage le `2026-10-11`. ``` pg-backup-pull: pull ok latest=2026-09-01 files=1 pg-backup-pull: warning: no directory for 2026-10-11, latest is 2026-09-01 pg-backup-pull: rotation: removed 2026-09-01 exit=0 dest=[] ``` On obtient trois effets. - **Perte de la copie hors site** : `~/backups/postgres/` est vidé, et le code de sortie vaut 0. Cette copie existe précisément pour survivre à une panne côté VPS. La panne franche (VPS injoignable) est bien couverte, puisque `pull_failed` sort avant la rotation. Mais la panne lente ne l'est pas : un cron mort ou un `pg_dump` qui échoue alors que la boîte d'envoi garde ses vieux dossiers. - **Rapport faux** : `latest` et `files` sont calculés **avant** la rotation. Le rapport dit donc `ok:true, files=N` sur un dossier effacé dans le même passage. - **Va-et-vient** : sans `--delete`, la boîte d'envoi du VPS garde ses vieux dossiers tant que son propre script ne tourne plus. Chaque passage les retire, puis les efface aussitôt. Le même calcul effacerait tout si l'horloge du poste partait de plus de 30 jours en avant. Le seul garde-fou est l'alerte `stale` du bloc `vps` de `GET /backups`, et #58 est en `status:blocked` : pour l'instant, rien ne prévient hors du journal. **Correctif proposé** : calculer `cutoff` à partir de `latest`, et non de `TODAY`. On garde ainsi les 30 jours qui précèdent la sauvegarde la plus récente, et le dossier le plus récent n'est jamais supprimé. Le test de rotation existant reste valable tel quel, puisque `latest` y vaut `TODAY`. Il faut un cas de plus : un `latest` vieux de plus de 30 jours qui survit au passage. Mutation à faire rougir : remettre `TODAY` dans le calcul de `today_epoch`. ### Suggestions (non bloquantes) - **Un dossier daté vide passe pour un succès.** Sonde : la boîte d'envoi n'a qu'un dossier `2026-10-11/` sans `.age`, par exemple parce que le chiffrement a échoué ou qu'il ne contient qu'un dump en clair. Résultat : `pull ok latest=2026-10-11 files=0`, sans avertissement, avec un rapport `ok:true`. Avec `--include='*/'`, rsync crée les dossiers même vides. `-m` (`--prune-empty-dirs`) les écarterait, et l'avertissement « no directory for today » se déclencherait alors. Ou bien journaliser un avertissement quand `files=0`. - **`--partial-dir=.rsync-partial`** (`:106`, `:138`) : un fichier partiel laissé par un transfert interrompu, dont la source a ensuite tourné côté VPS, reste dans `<date>/.rsync-partial/x.age`. `find` le compte alors, puisqu'il n'a pas de `-maxdepth 1` (busybox l'accepte). - **Fichier d'en-tête** : si le processus est tué entre sa création et le `rm -f`, le jeton reste sur disque (mode 600 dans un dossier en 700, donc risque faible). Un `trap 'rm -f "$header_file"' EXIT` fermerait ce cas. - Le cas qui passe par le vrai rsync est sauté en CI, faute de rsync dans `node:22-alpine`. C'est documenté, et le test d'argv couvre l'ordre des filtres. Il ne tourne cependant que sur le poste. ### Mutations du relecteur (copie hors dépôt, `git diff --stat` d'une ligne chacune, restaurée après) | Mutation | Rouges | |---|---| | `--include='*/'` retiré | 2 | | `--exclude='*'` affaibli en `--exclude='*.tmp'` | 2 | | échec de `rm` ignoré | 1 | | rétention `RETENTION_DAYS - 1` → `RETENTION_DAYS` | 1 | | `chmod -R go-rwx` retiré | 1 | | rapport `pull_failed` retiré | 1 | | fichier d'en-tête laissé en place | 1 | ### Vérifié sans grief Le corps envoyé correspond au contrat de #58 (`pulledAt`, `files`, `ok`, `error` limité à `pull_failed`/`rotation_failed`). Le jeton ne passe ni par argv ni par la sortie. L'unité `Type=oneshot` n'a pas de délai de démarrage par défaut, donc le `timeout 900` de rsync n'est pas coupé par systemd. `Restart=on-failure` est accepté sur un oneshot avec systemd 244 ou plus. Les commits suivent le format conventionnel. La timeline ne porte aucun `closes` (Refs seulement). ### Actions faites - Suite et mutations rejouées sur une copie dans le scratchpad, sondes comprises. Copie et sondes supprimées. - **Label non modifié** : comme à la première passe, l'issue liée est `maximus/la-compagnie-maximus#220`, une issue parapluie d'un autre dépôt, toujours en `status:review`. À basculer à la main.
maximus added 1 commit 2026-10-10 23:03:52 +00:00
fix(agent): anchor the backup rotation on the newest backup, not today
All checks were successful
CI / verification (pull_request) Successful in 32s
f81a80e325
The rotation cutoff was TODAY minus 29 days. When the VPS stops
producing (dead cron, failing pg_dump) or this machine's clock jumps
ahead, the last off-site copy aged out after 30 days, the pass exited 0
and the report said ok on a directory it had just deleted. The cutoff
now comes from the newest dated directory held here, which is never
removed.

Review suggestions:
- --prune-empty-dirs: a dated directory with no .age file is not
  created, and one found empty logs a warning;
- files counts the dated directory only (-maxdepth 1), not partial
  files left in .rsync-partial/;
- an EXIT trap removes the header file holding the ingest token, with
  TERM/INT/HUP turned into an exit so a stop by systemd runs it.

Refs maximus/la-compagnie-maximus#220

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

Correctifs après la re-revue (f81a80e)

Nouvelle tête non relue. Verdict CI à lire sur le statut de commit.

Bloquant : rotation calée sur la sauvegarde la plus récente

cutoff vaut désormais latest - 29 jours, et non plus TODAY - 29 jours. Le dossier le plus récent n'est donc jamais supprimé, quelle que soit sa date. Le nouveau cas de test reprend la sonde de la revue : le VPS ne produit plus rien depuis 2026-09-01 et le passage a lieu le 2026-10-11. Le dossier 2026-09-01 reste, 2026-08-03 (borne de la fenêtre) aussi, 2026-08-02 part, et le rapport dit ok:true, files:1. Le même cas, lancé à la main avec un PATH réduit à busybox, donne la même sortie.

Suggestions

  • Dossier daté vide : rsync reçoit --prune-empty-dirs. Le cas du vrai rsync ajoute dans la boîte d'envoi un dossier 2026-10-12/ qui ne contient qu'un dump en clair, et ce dossier n'est pas créé. En plus, un dossier trouvé vide journalise warning: no .age file in <date> (nouveau cas).
  • .rsync-partial : find -maxdepth 1. Un nouveau cas place un partiel dans <date>/.rsync-partial/, et il n'est pas compté.
  • Fichier d'en-tête : un trap 'rm -f "$header_file"' EXIT, avec TERM/INT/HUP convertis en exit (dash ne lance pas le trap EXIT sur un signal). Dans le nouveau cas, un faux curl envoie SIGTERM au script pendant que le fichier existe (sa présence est constatée). Le script sort en 143 et le fichier n'est plus là.

Mutations (copie de sauvegarde, cmp vide et git diff --stat identique après restauration)

Mutation Rouges
latest_epoch calculé sur TODAY (la mutation de la revue) 1
--prune-empty-dirs retiré 2
-maxdepth 1 retiré 1
avertissement no .age file retiré 1
trap ... TERM retiré 1
trap ... EXIT retiré 1

Une septième mutation, qui retirait la garde [ -n "$cutoff" ] || break, est restée verte. Cette garde ne servait à rien : sans latest, il n'y a aucun dossier daté, donc rien à comparer. Je l'ai retirée du script plutôt que de la tester.

La suite complète est passée de 746 à 750 cas. Le README décrit l'ancrage de la rotation, les deux avertissements et le trap. Le timer n'a pas changé.

## Correctifs après la re-revue (f81a80e) Nouvelle tête non relue. Verdict CI à lire sur le statut de commit. ### Bloquant : rotation calée sur la sauvegarde la plus récente `cutoff` vaut désormais `latest - 29 jours`, et non plus `TODAY - 29 jours`. Le dossier le plus récent n'est donc jamais supprimé, quelle que soit sa date. Le nouveau cas de test reprend la sonde de la revue : le VPS ne produit plus rien depuis `2026-09-01` et le passage a lieu le `2026-10-11`. Le dossier `2026-09-01` reste, `2026-08-03` (borne de la fenêtre) aussi, `2026-08-02` part, et le rapport dit `ok:true, files:1`. Le même cas, lancé à la main avec un `PATH` réduit à busybox, donne la même sortie. ### Suggestions - **Dossier daté vide** : rsync reçoit `--prune-empty-dirs`. Le cas du vrai rsync ajoute dans la boîte d'envoi un dossier `2026-10-12/` qui ne contient qu'un dump en clair, et ce dossier n'est pas créé. En plus, un dossier trouvé vide journalise `warning: no .age file in <date>` (nouveau cas). - **`.rsync-partial`** : `find -maxdepth 1`. Un nouveau cas place un partiel dans `<date>/.rsync-partial/`, et il n'est pas compté. - **Fichier d'en-tête** : un `trap 'rm -f "$header_file"' EXIT`, avec `TERM`/`INT`/`HUP` convertis en `exit` (dash ne lance pas le trap EXIT sur un signal). Dans le nouveau cas, un faux `curl` envoie `SIGTERM` au script pendant que le fichier existe (sa présence est constatée). Le script sort en 143 et le fichier n'est plus là. ### Mutations (copie de sauvegarde, `cmp` vide et `git diff --stat` identique après restauration) | Mutation | Rouges | |---|---| | `latest_epoch` calculé sur `TODAY` (la mutation de la revue) | 1 | | `--prune-empty-dirs` retiré | 2 | | `-maxdepth 1` retiré | 1 | | avertissement `no .age file` retiré | 1 | | `trap ... TERM` retiré | 1 | | `trap ... EXIT` retiré | 1 | Une septième mutation, qui retirait la garde `[ -n "$cutoff" ] || break`, est restée verte. Cette garde ne servait à rien : sans `latest`, il n'y a aucun dossier daté, donc rien à comparer. Je l'ai retirée du script plutôt que de la tester. La suite complète est passée de 746 à 750 cas. Le README décrit l'ancrage de la rotation, les deux avertissements et le trap. Le timer n'a pas changé.
Author
Owner

Troisième revue adversariale (f81a80e) — REQUEST_CHANGES

Résumé. Les trois suggestions de la passe précédente sont appliquées et tiennent sous mutation. Le correctif du bloquant ouvre en revanche la faille inverse. La rotation prend maintenant sa référence dans un nom de dossier fourni par le VPS : un seul dossier daté dans le futur (horloge du VPS déréglée, ou VPS compromis) efface toutes les copies hors site du poste. C'est la deuxième passe de suite sur la même famille, l'ancrage de la rotation. Je ne propose donc pas un correctif ponctuel de plus, mais un changement de parti pris.

Ce que la revue a exécuté : la suite complète dans l'arbre (750/750), la CI de la tête (CI / verification en succès), puis, sur une copie hors dépôt, 5 mutations, 2 sondes et la validation du correctif proposé. Une sonde rsync a aussi tourné dans le scratchpad.

Point bloquant

  1. Ce qui vient du VPS peut détruire ou altérer la copie hors site (agent/pg-backup-pull/run-pg-backup-pull.sh:117, :139-146, :171-174).

    a. Un dossier daté dans le futur vide $DEST. latest est le plus grand nom daté reçu, et cutoff vaut latest - 29 jours. Sonde : $DEST contient 2026-09-20, 2026-10-01 et 2026-10-10, puis le VPS livre 2099-01-01/z.age.

    pg-backup-pull: pull ok latest=2099-01-01 files=1
    pg-backup-pull: warning: no directory for 2026-10-11, latest is 2099-01-01
    pg-backup-pull: rotation: removed 2026-09-20
    pg-backup-pull: rotation: removed 2026-10-01
    pg-backup-pull: rotation: removed 2026-10-10
    

    Le code de sortie vaut 0 et le rapport dit ok:true. Avec l'ancrage sur TODAY, ce cas ne pouvait pas arriver. Le README de la PR traite déjà la boîte d'envoi comme non fiable (« a misconfigured outbox cannot land a clear dump here ») : la rotation doit suivre la même règle.

    b. Un fichier déjà rapatrié est écrasé. rsync -rt remplace un fichier local dès que la taille ou la date diffère. Sonde avec le vrai rsync et les filtres du script : dst/2026-10-01/a.dump.age contient good-backup, et la source en livre une autre version. Sans option, le fichier local devient garbage. Avec --ignore-existing, il reste good-backup. Un VPS qui recrée les 30 derniers dossiers datés remplace ainsi toutes les copies du poste.

    c. Un nom daté impossible bloque l'agent pour de bon. Sonde : le VPS livre 2026-13-45/z.age. Le nom passe is_dated, devient latest, puis date -d échoue sous set -e : sortie en 1, aucun rapport, aucune rotation. Comme il n'y a pas de --delete, le dossier reste en local et chaque passage suivant échoue de la même façon. Seule la péremption côté GET /backups finirait par le signaler.

    Parti pris proposé : rien de ce qui vient du VPS ne détruit ni ne modifie une copie déjà présente.

    • Ancre de la rotation = min(latest, TODAY). Le cas « VPS arrêté » garde son comportement, puisque latest < TODAY, et un dossier futur ne fait plus reculer la fenêtre. Validé sur la copie : avec ces trois lignes avant latest_epoch, la sonde (a) garde les quatre dossiers et les 19 cas existants restent verts (20/20).
      anchor=$latest
      [ "$(day_number "$latest")" -gt "$(day_number "$TODAY")" ] && anchor=$TODAY
      latest_epoch=$(date -u -d "$anchor" +%s)
      
    • --ignore-existing sur rsync : un fichier rapatrié devient immuable. Il n'y a pas de risque de figer un fichier tronqué, car backup-postgres.sh écrit dans $dst.tmp puis renomme, et le .tmp est exclu par le filtre. La seule contrepartie est qu'une seconde exécution du VPS le même jour ne serait pas reprise.
    • Liste blanche des noms : un nom n'est daté que si date -u -d "$name" +%F le restitue à l'identique. Sinon il est ignoré, avec une ligne de journal, au lieu d'arrêter le passage. Ignoré veut dire qu'il n'est jamais choisi comme latest et jamais supprimé.
    • Trois cas, un par sonde. Mutations à faire rougir : remettre latest comme ancre, retirer --ignore-existing (cas avec le vrai rsync, ou argv), retirer la vérification du nom.

Suggestions (non bloquantes)

  • Le rapport compte le dossier le plus récent. Avec un dossier futur planté, il dirait ok:true sur ce dossier pour toujours. Choisir latest parmi les noms <= TODAY réglerait aussi ce point et simplifierait l'ancre.
  • OnCalendar=daily tombe à 04:00 UTC en heure d'été, une heure après le passage du VPS. C'est sans danger, puisque le VPS écrit en atomique et que le passage suivant rattrape. Un OnCalendar=*-*-* 06:00 éviterait un avertissement « no directory for today » les jours où le dump est long.

Mutations du relecteur (copie hors dépôt, restaurée entre chaque)

Mutation Rouges
latest_epoch calculé sur TODAY 1
trap ... EXIT retiré 1
--exclude='*' retiré 2
rétention RETENTION_DAYS - 1 → RETENTION_DAYS 2
--prune-empty-dirs retiré 2

Vérifié sans grief

Le jeton ne passe ni par argv ni par la sortie, et le fichier d'en-tête est retiré sur SIGTERM. Le partiel .rsync-partial/ n'est pas compté, un dossier daté vide n'est pas créé, et les codes de sortie correspondent au README. La timeline ne porte aucun closes (Refs seulement), et les commits suivent le format conventionnel.

Actions faites

  • Suite rejouée dans l'arbre, sans modification. Mutations et sondes faites sur une copie dans le scratchpad. La copie (pr59/, orig.sh) et le dossier de la sonde rsync ont été supprimés.
  • Label non modifié : l'issue liée est maximus/la-compagnie-maximus#220, issue parapluie d'un autre dépôt, toujours en status:review. À basculer à la main, comme aux deux passes précédentes.
## Troisième revue adversariale (f81a80e) — REQUEST_CHANGES **Résumé.** Les trois suggestions de la passe précédente sont appliquées et tiennent sous mutation. Le correctif du bloquant ouvre en revanche la faille inverse. La rotation prend maintenant sa référence dans un nom de dossier fourni par le VPS : un seul dossier daté dans le futur (horloge du VPS déréglée, ou VPS compromis) efface toutes les copies hors site du poste. C'est la deuxième passe de suite sur la même famille, l'ancrage de la rotation. Je ne propose donc pas un correctif ponctuel de plus, mais un changement de parti pris. Ce que la revue a exécuté : la suite complète dans l'arbre (750/750), la CI de la tête (`CI / verification` en succès), puis, sur une copie hors dépôt, 5 mutations, 2 sondes et la validation du correctif proposé. Une sonde rsync a aussi tourné dans le scratchpad. ### Point bloquant 1. **Ce qui vient du VPS peut détruire ou altérer la copie hors site** (`agent/pg-backup-pull/run-pg-backup-pull.sh:117`, `:139-146`, `:171-174`). **a. Un dossier daté dans le futur vide `$DEST`.** `latest` est le plus grand nom daté reçu, et `cutoff` vaut `latest - 29 jours`. Sonde : `$DEST` contient `2026-09-20`, `2026-10-01` et `2026-10-10`, puis le VPS livre `2099-01-01/z.age`. ``` pg-backup-pull: pull ok latest=2099-01-01 files=1 pg-backup-pull: warning: no directory for 2026-10-11, latest is 2099-01-01 pg-backup-pull: rotation: removed 2026-09-20 pg-backup-pull: rotation: removed 2026-10-01 pg-backup-pull: rotation: removed 2026-10-10 ``` Le code de sortie vaut 0 et le rapport dit `ok:true`. Avec l'ancrage sur `TODAY`, ce cas ne pouvait pas arriver. Le README de la PR traite déjà la boîte d'envoi comme non fiable (« a misconfigured outbox cannot land a clear dump here ») : la rotation doit suivre la même règle. **b. Un fichier déjà rapatrié est écrasé.** `rsync -rt` remplace un fichier local dès que la taille ou la date diffère. Sonde avec le vrai rsync et les filtres du script : `dst/2026-10-01/a.dump.age` contient `good-backup`, et la source en livre une autre version. Sans option, le fichier local devient `garbage`. Avec `--ignore-existing`, il reste `good-backup`. Un VPS qui recrée les 30 derniers dossiers datés remplace ainsi toutes les copies du poste. **c. Un nom daté impossible bloque l'agent pour de bon.** Sonde : le VPS livre `2026-13-45/z.age`. Le nom passe `is_dated`, devient `latest`, puis `date -d` échoue sous `set -e` : sortie en 1, aucun rapport, aucune rotation. Comme il n'y a pas de `--delete`, le dossier reste en local et chaque passage suivant échoue de la même façon. Seule la péremption côté `GET /backups` finirait par le signaler. **Parti pris proposé : rien de ce qui vient du VPS ne détruit ni ne modifie une copie déjà présente.** - Ancre de la rotation = `min(latest, TODAY)`. Le cas « VPS arrêté » garde son comportement, puisque `latest < TODAY`, et un dossier futur ne fait plus reculer la fenêtre. Validé sur la copie : avec ces trois lignes avant `latest_epoch`, la sonde (a) garde les quatre dossiers et les 19 cas existants restent verts (20/20). ```sh anchor=$latest [ "$(day_number "$latest")" -gt "$(day_number "$TODAY")" ] && anchor=$TODAY latest_epoch=$(date -u -d "$anchor" +%s) ``` - `--ignore-existing` sur rsync : un fichier rapatrié devient immuable. Il n'y a pas de risque de figer un fichier tronqué, car `backup-postgres.sh` écrit dans `$dst.tmp` puis renomme, et le `.tmp` est exclu par le filtre. La seule contrepartie est qu'une seconde exécution du VPS le même jour ne serait pas reprise. - Liste blanche des noms : un nom n'est daté que si `date -u -d "$name" +%F` le restitue à l'identique. Sinon il est ignoré, avec une ligne de journal, au lieu d'arrêter le passage. Ignoré veut dire qu'il n'est jamais choisi comme `latest` et jamais supprimé. - Trois cas, un par sonde. Mutations à faire rougir : remettre `latest` comme ancre, retirer `--ignore-existing` (cas avec le vrai rsync, ou argv), retirer la vérification du nom. ### Suggestions (non bloquantes) - Le rapport compte le dossier le plus récent. Avec un dossier futur planté, il dirait `ok:true` sur ce dossier pour toujours. Choisir `latest` parmi les noms `<= TODAY` réglerait aussi ce point et simplifierait l'ancre. - `OnCalendar=daily` tombe à 04:00 UTC en heure d'été, une heure après le passage du VPS. C'est sans danger, puisque le VPS écrit en atomique et que le passage suivant rattrape. Un `OnCalendar=*-*-* 06:00` éviterait un avertissement « no directory for today » les jours où le dump est long. ### Mutations du relecteur (copie hors dépôt, restaurée entre chaque) | Mutation | Rouges | |---|---| | `latest_epoch` calculé sur `TODAY` | 1 | | `trap ... EXIT` retiré | 1 | | `--exclude='*'` retiré | 2 | | rétention `RETENTION_DAYS - 1` → `RETENTION_DAYS` | 2 | | `--prune-empty-dirs` retiré | 2 | ### Vérifié sans grief Le jeton ne passe ni par argv ni par la sortie, et le fichier d'en-tête est retiré sur `SIGTERM`. Le partiel `.rsync-partial/` n'est pas compté, un dossier daté vide n'est pas créé, et les codes de sortie correspondent au README. La timeline ne porte aucun `closes` (Refs seulement), et les commits suivent le format conventionnel. ### Actions faites - Suite rejouée dans l'arbre, sans modification. Mutations et sondes faites sur une copie dans le scratchpad. La copie (`pr59/`, `orig.sh`) et le dossier de la sonde rsync ont été supprimés. - **Label non modifié** : l'issue liée est `maximus/la-compagnie-maximus#220`, issue parapluie d'un autre dépôt, toujours en `status:review`. À basculer à la main, comme aux deux passes précédentes.
maximus added 1 commit 2026-10-10 23:13:29 +00:00
fix(agent): nothing the VPS sends deletes or alters a pulled backup
All checks were successful
CI / verification (pull_request) Successful in 31s
CI / verification (push) Successful in 32s
5208957ce9
Anchoring the rotation on the newest directory name let the VPS steer
it: one future-dated directory (VPS clock off, or a VPS gone rogue)
pushed the window forward and deleted every real backup, with exit 0
and an ok report. Two more inputs from the outbox could do harm: rsync
-rt rewrote an already pulled .age whenever the VPS served another
version, and an impossible dated name (2026-13-45) failed `date -d`
under set -e, stopping every later pass for good.

- latest is the newest valid dated directory not after today; the
  rotation keeps it and the 29 days before it;
- a name is dated only if `date` gives it back unchanged (busybox turns
  2026-02-30 into 2026-03-02); an invalid or future name is logged,
  never counted, never removed, and the pass goes on;
- --ignore-existing: a pulled file is never rewritten. The VPS writes a
  .tmp and renames it, and the filter drops the .tmp.

Refs maximus/la-compagnie-maximus#220

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

Correctifs après la troisième revue (5208957)

Nouvelle tête non relue. Verdict CI à lire sur le statut de commit.

Le principe proposé est retenu : rien de ce qui vient du VPS ne supprime ni ne modifie une copie déjà présente.

Bloquant

  • (a) Dossier daté dans le futur : latest est maintenant le plus récent nom daté valide qui ne dépasse pas aujourd'hui (suggestion 1 de la revue). La rotation se cale sur lui, ce qui rend inutile une ancre min(latest, TODAY) séparée. Un nom futur est journalisé (warning: ignoring <nom>, after <today>) et n'est ni compté ni supprimé. Nouveau cas : la sonde de la revue (2026-09-20, 2026-10-01, 2026-10-10, puis le VPS livre 2099-01-01/z.age). Les quatre dossiers restent, le passage annonce latest=2026-10-10 files=1, et aucune rotation n'a lieu.
  • (b) Écrasement : ajout de --ignore-existing. Le cas du vrai rsync part d'un 2026-10-10/globals.sql.age local qui contient good-backup, alors que la boîte d'envoi en sert un garbage. Le fichier local garde good-backup.
  • (c) Date impossible : is_dated exige l'aller-retour date -u -d "$name" +%F, qui doit rendre le nom à l'identique. Sondé : GNU refuse 2026-13-45 et 2026-02-30, alors que busybox refuse le premier mais convertit le second en 2026-03-02, et l'aller-retour l'écarte. Un nom invalide est journalisé et ignoré, et le passage continue. Nouveau cas : 2026-13-45 et 2026-02-30 restent sur le poste et ne sont pas comptés, 2026-01-01 est bien retiré, le passage sort en 0 et le rapport vaut ok:true.
  • Seuls les noms de la forme NNNN-NN-NN sont journalisés : un nom arbitraire venu du VPS ne peut pas forger une ligne de journal.

Les trois cas ont été rejoués à la main avec un PATH réduit à busybox, avec le même résultat.

Mutations (copie de sauvegarde, cmp vide et git diff --stat identique après restauration)

Mutation Rouges
un nom futur peut devenir latest (l'équivalent de « remettre latest comme ancre ») 1
--ignore-existing retiré de la ligne rsync 2
aller-retour date retiré de is_dated 1
nom invalide non écarté dans le choix de latest 1
contrôle is_dated retiré de la rotation 1

La suite complète passe de 750 à 752 cas. Le README énonce le principe et ses trois règles. Le timer n'a pas changé : Max a choisi d'attendre sa vérification du rattrapage avant de suivre la suggestion 2 (06:00 UTC).

## Correctifs après la troisième revue (5208957) Nouvelle tête non relue. Verdict CI à lire sur le statut de commit. Le principe proposé est retenu : **rien de ce qui vient du VPS ne supprime ni ne modifie une copie déjà présente.** ### Bloquant - **(a) Dossier daté dans le futur** : `latest` est maintenant le plus récent nom daté valide **qui ne dépasse pas aujourd'hui** (suggestion 1 de la revue). La rotation se cale sur lui, ce qui rend inutile une ancre `min(latest, TODAY)` séparée. Un nom futur est journalisé (`warning: ignoring <nom>, after <today>`) et n'est ni compté ni supprimé. Nouveau cas : la sonde de la revue (`2026-09-20`, `2026-10-01`, `2026-10-10`, puis le VPS livre `2099-01-01/z.age`). Les quatre dossiers restent, le passage annonce `latest=2026-10-10 files=1`, et aucune rotation n'a lieu. - **(b) Écrasement** : ajout de `--ignore-existing`. Le cas du vrai rsync part d'un `2026-10-10/globals.sql.age` local qui contient `good-backup`, alors que la boîte d'envoi en sert un `garbage`. Le fichier local garde `good-backup`. - **(c) Date impossible** : `is_dated` exige l'aller-retour `date -u -d "$name" +%F`, qui doit rendre le nom à l'identique. Sondé : GNU refuse `2026-13-45` et `2026-02-30`, alors que busybox refuse le premier mais convertit le second en `2026-03-02`, et l'aller-retour l'écarte. Un nom invalide est journalisé et ignoré, et le passage continue. Nouveau cas : `2026-13-45` et `2026-02-30` restent sur le poste et ne sont pas comptés, `2026-01-01` est bien retiré, le passage sort en 0 et le rapport vaut `ok:true`. - Seuls les noms de la forme `NNNN-NN-NN` sont journalisés : un nom arbitraire venu du VPS ne peut pas forger une ligne de journal. Les trois cas ont été rejoués à la main avec un `PATH` réduit à busybox, avec le même résultat. ### Mutations (copie de sauvegarde, `cmp` vide et `git diff --stat` identique après restauration) | Mutation | Rouges | |---|---| | un nom futur peut devenir `latest` (l'équivalent de « remettre `latest` comme ancre ») | 1 | | `--ignore-existing` retiré de la ligne rsync | 2 | | aller-retour `date` retiré de `is_dated` | 1 | | nom invalide non écarté dans le choix de `latest` | 1 | | contrôle `is_dated` retiré de la rotation | 1 | La suite complète passe de 750 à 752 cas. Le README énonce le principe et ses trois règles. Le timer n'a pas changé : Max a choisi d'attendre sa vérification du rattrapage avant de suivre la suggestion 2 (06:00 UTC).
maximus merged commit 5208957ce9 into main 2026-10-10 23:22:59 +00:00
maximus deleted branch pg-backup-pull-agent 2026-10-10 23:22:59 +00:00
Author
Owner

Mergée en fast-forward sur 5208957, à la demande de Max, avec la CI verte sur cette tête. 5208957 a été ajouté après la troisième revue et n'a pas été relu : ses mutations sont dans le commentaire 21983.

L'agent est réinstallé sur le Lenovo à partir de main. Une copie de l'ancienne version est gardée à côté, en run-pg-backup-pull.sh.bak-2026-10-10. L'unité et le timer sont identiques à ceux du dépôt. Un passage manuel lancé à 19:23 EDT a donné pull ok latest=2026-10-10 files=9 et s'est terminé avec le code 0. Le rapport est sauté parce que le jeton n'est pas encore posé. Le prochain passage du timer est prévu le 2026-10-11 à 00:04 EDT.

Ce qui reste à faire relève de la checklist de la-compagnie-maximus#220 : la vérification du rattrapage d'un passage manqué, puis le jeton BACKUPS_INGEST_TOKEN une fois #58 déployé.

Mergée en fast-forward sur `5208957`, à la demande de Max, avec la CI verte sur cette tête. `5208957` a été ajouté après la troisième revue et n'a pas été relu : ses mutations sont dans le commentaire 21983. L'agent est réinstallé sur le Lenovo à partir de `main`. Une copie de l'ancienne version est gardée à côté, en `run-pg-backup-pull.sh.bak-2026-10-10`. L'unité et le timer sont identiques à ceux du dépôt. Un passage manuel lancé à 19:23 EDT a donné `pull ok latest=2026-10-10 files=9` et s'est terminé avec le code 0. Le rapport est sauté parce que le jeton n'est pas encore posé. Le prochain passage du timer est prévu le 2026-10-11 à 00:04 EDT. Ce qui reste à faire relève de la checklist de `la-compagnie-maximus#220` : la vérification du rattrapage d'un passage manqué, puis le jeton `BACKUPS_INGEST_TOKEN` une fois #58 déployé.
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#59
No description provided.