feat(agent): daily pull of the encrypted PostgreSQL backups #59
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/vps-health-api#59
Loading…
Reference in a new issue
No description provided.
Delete branch "pg-backup-pull-agent"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 on100.86.30.21:2222(Tailscale SSH keeps port 22), dedicated key~/.ssh/pgbackup_pull_ed25519limited on the VPS torrsync -rofrom the Lenovo IP, host key pinned in~/.ssh/known_hosts_pgbackup. Only directories and*.agecross; no--delete. 30-day rotation that fails loudly. Report toPOST /backups/pull(contract in the issue) only whenBACKUPS_INGEST_TOKENis set, token through a header file, never argv. Never decrypts.OnCalendar=daily,Persistent=true,Restart=on-failureevery 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
5208957).--exclude='*'removed, token in argv, rotation failure ignored, header file left behind. Achmod 755on the destination survives because the laterchmod -R go-rwxrestores it (redundant layer, not a gap).2026-10-10), report skipped (no token yet), timer armed for 00:10.Post-merge checklist (hence Refs, not a closing keyword)
systemctl --user list-timersafter wake-up).BACKUPS_INGEST_TOKENwritten in~/.config/maximus-pg-backup-pull.envoncePOST /backups/pullis deployed (maximus/vps-health-api#58).🤖 Generated with Claude Code
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ête59b9632est rouge : 9 des 12 nouveaux tests échouent dansnode: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
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 dedest(ENOENT .../dest). C'est donc la vérification d'installation :sshn'existe pas dansnode:22-alpine, et lePATHdu test (${bin}:/usr/bin:/bin) ne fournit qu'un fauxrsyncet un fauxcurl. Ajouter un fauxssh(etflocksi besoin) ne suffira pas. Sondé avec busybox :date -u -d "2026-10-11 -29 days"répondinvalid date, etfind -printfrépondunrecognized: -printf. Sousset -eu, le calcul decutoffou delatestarrêterait le script. Deux voies possibles :latestpar une boucle sur le glob"$DEST"/*/déjà utilisée pour la rotation,cutoffpar l'arithmétique d'epoch (date -u -d "$TODAY" +%smoins(RETENTION_DAYS-1)*86400, puisdate -u -d "@$x" +%F), plus de fauxsshetflockdansbin/;ci.test.jsépingle les étapes du workflow.Dans les deux cas, valider sur le statut de commit, pas en local.
Le test
rotation_failedne peut pas passer en root (__tests__/agent-pg-backup-pull.test.js:176-190). La CI tourne en root (conteneur sansUSER), et root ignore lechmod 0500:rm -rfré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 fauxrmdansbin/qui échoue sur le chemin2026-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)
--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 journalisepull okpuis rapporteok:true. C'est un échec silencieux de toute la sauvegarde. Le fauxrsyncdépose ses fichiers quel que soit le filtre. Il faudrait au minimum une assertion sur--include=*/placé avant--exclude=*. Mieux, sirsyncest présent : un cas qui fait passer le vrai rsync sur une boîte d'envoi locale contenant un.dumpen clair et un.age, et qui vérifie que seul le.agearrive. Les trois autres mutations du relecteur rougissent : rétention-lt→-le(1 rouge),StrictHostKeyChecking=accept-new(1 rouge),exit 0aprèsrotation_failed(1 rouge).filescompte le dossier daté local le plus récent, pas celui du jour (:118-122). Si le VPS cesse de produire, chaque passage rapporteok:true, files=9sur un vieux dossier. Le blocvpsdeGET /backups(âge surgeneratedAt) couvre ce cas côté tableau de bord, donc rien à changer dans le contrat. Une ligne de journal quandlatestdiffère deTODAYaiderait au diagnostic.maximus-pg-backup-pull.timer) :OnCalendar=dailytombe à 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. UnOnCalendar=*-*-* 05:00 UTCdirait l'intention plutôt que de dépendre du fuseau du poste. Si le VPS n'écrit pas ses.agede façon atomique, un tirage trop tôt prendrait un fichier partiel. Le passage du lendemain le corrigerait (-t), mais le rapport du jour diraitok.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 077puis 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 aucuncloses, conforme au « Refs » annoncé. Le commit suit le format conventionnel.agent/reste hors de l'image.Actions faites
maximus/la-compagnie-maximus#220, une issue parapluie d'un autre dépôt, encore enstatus:review. À basculer à la main si cette PR est la seule en revue dessus.Correctifs après revue (
b87b3d7)Nouvelle tête non relue. Verdict CI à lire sur le statut de commit.
Bloquants
latestest trouvé par une boucle sur le glob"$DEST"/*/, etcutoffpar l'arithmétique d'epoch (date -u -d "$TODAY" +%s, puisdate -u -d "@N" +%F). Les tests ajoutent un fauxssh, et un fauxflockseulement si l'hôte n'en a pas. Vérifié en local avec unPATHréduit à busybox seul : le nouveau script sort 0 et la rotation retire2026-09-11, alors que l'ancien reproduitfind: unrecognized: -printfetdate: invalid date.rotation_faileden root : l'échec vient d'un fauxrm(bin/rm) qui refuse le chemin2026-09-01, et non plus des droits du dossier. Le test exige aussi la lignerotation: could not remove 2026-09-01et 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 fauxssh, sur une boîte d'envoi locale qui contient un.dump.age, un.dumpen clair et un.sqlà la racine. Seul le.agearrive, dans son dossier daté. Ce cas est sauté là où rsync est absent (image de CI), et le test d'argv couvre ce cas.warning: no directory for <today>, latest is <date>est journalisée, avec deux cas (avec et sans le dossier du jour). Documenté dansagent/README.md.Mutations (copie de sauvegarde dans le scratchpad,
diffvide après restauration)--include='*/'retiré (la mutation verte de la revue)rmignoré (rotation_ok=falseretiré)latestprend le plus ancien (-gten-lt)Suite complète : 743 cas avant, 746 après.
Re-revue adversariale (
b87b3d7) — REQUEST_CHANGESRésumé. Les deux bloquants de la première passe sont corrigés : la CI de
b87b3d7est verte (CI / verificationen succès), le script tourne sous busybox, etrotation_failedpasse par un fauxrm. 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
La rotation peut supprimer la dernière sauvegarde (
agent/pg-backup-pull/run-pg-backup-pull.sh:151-152).cutoffvautTODAY - 29 jours, quel que soit le contenu de$DEST. Sonde : un VPS arrêté depuis le2026-09-01, un passage le2026-10-11.On obtient trois effets.
~/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, puisquepull_failedsort avant la rotation. Mais la panne lente ne l'est pas : un cron mort ou unpg_dumpqui échoue alors que la boîte d'envoi garde ses vieux dossiers.latestetfilessont calculés avant la rotation. Le rapport dit doncok:true, files=Nsur un dossier effacé dans le même passage.--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
staledu blocvpsdeGET /backups, et #58 est enstatus:blocked: pour l'instant, rien ne prévient hors du journal.Correctif proposé : calculer
cutoffà partir delatest, et non deTODAY. 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, puisquelatesty vautTODAY. Il faut un cas de plus : unlatestvieux de plus de 30 jours qui survit au passage. Mutation à faire rougir : remettreTODAYdans le calcul detoday_epoch.Suggestions (non bloquantes)
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 rapportok: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 quandfiles=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.findle compte alors, puisqu'il n'a pas de-maxdepth 1(busybox l'accepte).rm -f, le jeton reste sur disque (mode 600 dans un dossier en 700, donc risque faible). Untrap 'rm -f "$header_file"' EXITfermerait ce cas.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 --statd'une ligne chacune, restaurée après)--include='*/'retiré--exclude='*'affaibli en--exclude='*.tmp'rmignoréRETENTION_DAYS - 1→RETENTION_DAYSchmod -R go-rwxretirépull_failedretiréVérifié sans grief
Le corps envoyé correspond au contrat de #58 (
pulledAt,files,ok,errorlimité àpull_failed/rotation_failed). Le jeton ne passe ni par argv ni par la sortie. L'unitéType=oneshotn'a pas de délai de démarrage par défaut, donc letimeout 900de rsync n'est pas coupé par systemd.Restart=on-failureest accepté sur un oneshot avec systemd 244 ou plus. Les commits suivent le format conventionnel. La timeline ne porte aucuncloses(Refs seulement).Actions faites
maximus/la-compagnie-maximus#220, une issue parapluie d'un autre dépôt, toujours enstatus:review. À basculer à la main.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
cutoffvaut désormaislatest - 29 jours, et non plusTODAY - 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 depuis2026-09-01et le passage a lieu le2026-10-11. Le dossier2026-09-01reste,2026-08-03(borne de la fenêtre) aussi,2026-08-02part, et le rapport ditok:true, files:1. Le même cas, lancé à la main avec unPATHréduit à busybox, donne la même sortie.Suggestions
--prune-empty-dirs. Le cas du vrai rsync ajoute dans la boîte d'envoi un dossier2026-10-12/qui ne contient qu'un dump en clair, et ce dossier n'est pas créé. En plus, un dossier trouvé vide journalisewarning: 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é.trap 'rm -f "$header_file"' EXIT, avecTERM/INT/HUPconvertis enexit(dash ne lance pas le trap EXIT sur un signal). Dans le nouveau cas, un fauxcurlenvoieSIGTERMau 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,
cmpvide etgit diff --statidentique après restauration)latest_epochcalculé surTODAY(la mutation de la revue)--prune-empty-dirsretiré-maxdepth 1retiréno .age fileretirétrap ... TERMretirétrap ... EXITretiréUne septième mutation, qui retirait la garde
[ -n "$cutoff" ] || break, est restée verte. Cette garde ne servait à rien : sanslatest, 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é.
Troisième revue adversariale (
f81a80e) — REQUEST_CHANGESRé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 / verificationen 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
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.latestest le plus grand nom daté reçu, etcutoffvautlatest - 29 jours. Sonde :$DESTcontient2026-09-20,2026-10-01et2026-10-10, puis le VPS livre2099-01-01/z.age.Le code de sortie vaut 0 et le rapport dit
ok:true. Avec l'ancrage surTODAY, 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 -rtremplace 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.agecontientgood-backup, et la source en livre une autre version. Sans option, le fichier local devientgarbage. Avec--ignore-existing, il restegood-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 passeis_dated, devientlatest, puisdate -déchoue sousset -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 /backupsfinirait 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.
min(latest, TODAY). Le cas « VPS arrêté » garde son comportement, puisquelatest < TODAY, et un dossier futur ne fait plus reculer la fenêtre. Validé sur la copie : avec ces trois lignes avantlatest_epoch, la sonde (a) garde les quatre dossiers et les 19 cas existants restent verts (20/20).--ignore-existingsur rsync : un fichier rapatrié devient immuable. Il n'y a pas de risque de figer un fichier tronqué, carbackup-postgres.shécrit dans$dst.tmppuis renomme, et le.tmpest exclu par le filtre. La seule contrepartie est qu'une seconde exécution du VPS le même jour ne serait pas reprise.date -u -d "$name" +%Fle 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 commelatestet jamais supprimé.latestcomme ancre, retirer--ignore-existing(cas avec le vrai rsync, ou argv), retirer la vérification du nom.Suggestions (non bloquantes)
ok:truesur ce dossier pour toujours. Choisirlatestparmi les noms<= TODAYréglerait aussi ce point et simplifierait l'ancre.OnCalendar=dailytombe à 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. UnOnCalendar=*-*-* 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)
latest_epochcalculé surTODAYtrap ... EXITretiré--exclude='*'retiréRETENTION_DAYS - 1→RETENTION_DAYS--prune-empty-dirsretiré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 aucuncloses(Refs seulement), et les commits suivent le format conventionnel.Actions faites
pr59/,orig.sh) et le dossier de la sonde rsync ont été supprimés.maximus/la-compagnie-maximus#220, issue parapluie d'un autre dépôt, toujours enstatus:review. À basculer à la main, comme aux deux passes précédentes.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
latestest 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 ancremin(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 livre2099-01-01/z.age). Les quatre dossiers restent, le passage annoncelatest=2026-10-10 files=1, et aucune rotation n'a lieu.--ignore-existing. Le cas du vrai rsync part d'un2026-10-10/globals.sql.agelocal qui contientgood-backup, alors que la boîte d'envoi en sert ungarbage. Le fichier local gardegood-backup.is_datedexige l'aller-retourdate -u -d "$name" +%F, qui doit rendre le nom à l'identique. Sondé : GNU refuse2026-13-45et2026-02-30, alors que busybox refuse le premier mais convertit le second en2026-03-02, et l'aller-retour l'écarte. Un nom invalide est journalisé et ignoré, et le passage continue. Nouveau cas :2026-13-45et2026-02-30restent sur le poste et ne sont pas comptés,2026-01-01est bien retiré, le passage sort en 0 et le rapport vautok:true.NNNN-NN-NNsont 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
PATHréduit à busybox, avec le même résultat.Mutations (copie de sauvegarde,
cmpvide etgit diff --statidentique après restauration)latest(l'équivalent de « remettrelatestcomme ancre »)--ignore-existingretiré de la ligne rsyncdateretiré deis_datedlatestis_datedretiré de la rotationLa 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).
Mergée en fast-forward sur
5208957, à la demande de Max, avec la CI verte sur cette tête.5208957a é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é, enrun-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=9et 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 jetonBACKUPS_INGEST_TOKENune fois #58 déployé.