feat(agent): push workstation metrics to /hosts/<id> from cron #22
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#22
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-15-local-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?
Ferme #15. Base :
issue-14-hosts-tests(5e et dernier maillon de la pile — a merger apres #14).L'agent local 0-dependance qui collecte les metriques du poste et les pousse vers
POST /hosts/<id>. La mise en service reelle (fichier env, crontab, copie figee sur le ThinkPad) reste l'issue #16 : ici, seulement le code et sa documentation.Contenu
agent/push-metrics.jscollectMetrics(), construit le payload, POST une fois, sortagent/run-push.shexec nodeagent/README.md__tests__/agent.test.jsCe que l'agent refuse de faire
Aucune file, aucun rejeu. Une execution = une tentative. Un push qui echoue est journalise et abandonne : le battement suivant est dans 5 minutes, et un heartbeat vieux de 5 minutes decrit une machine qui n'existe plus. Rien n'est spoule, donc rien ne peut etre rejoue pour faire afficher au dashboard un passe qui n'est plus vrai. Timeout de 10 s en plafond de temps mural sur tout l'echange (DNS, connexion, TLS, reponse).
Aucun secret dans les logs.
run-push.shest destine a etre pipe danslogger, donc tout ce que le processus imprime finit dans/var/log/sysloget journald definitivement. Le chemin d'erreur ne rend donc queerr.codeet le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete, jamais un corps de reponse. Et pas deset -xdans le wrapper : le shell echoerait le token en sourcant le fichier d'env. Le cout est reel et assume — un400dit que le payload a ete refuse, pas pourquoi ; la raison est dans les logs serveur, et--dry-runmontre le payload exact.Le test de non-fuite
Critere explicite de l'issue.
expectNoTokenLeak()decoupe le token en tous ses fragments de 5 caracteres et plus et verifie qu'aucun n'apparait dans la sortie. Applique aerr.message,String(err),err.stacket aux deux rendus JSON de l'erreur, sur les quatre chemins d'echec (HTTP non-2xx, ECONNREFUSED, timeout, token invalide) — et surtout sur la vraie sortie de processus : deux cas lancent l'agent comme un vrai programme (execFile) contre un serveur en 500 puis contre un port mort, et fouillent le stdout/stderr reels, c'est-a-dire exactement ce queloggeravalerait.Preuve que le filet mord : en remplacant le handler d'erreur par
reject(new Error(\push failed: ${err.message} opts=${JSON.stringify({headers: {Authorization: "Bearer " + token}})}`))` — la faute exacte decrite dans le caveat SECURITE de l'issue — 5 tests virent au rouge, dont celui qui inspecte la sortie du vrai processus. Mutation faite dans l'arbre de travail puis annulee ; la branche n'a jamais contenu le code fuyant.Contrat de payload
Verifie contre l'implementation reelle, pas contre une memoire : le payload construit passe par le
sanitizeSnapshot()du serveur et en ressort identique (toEqual), et un cas pousse un vrai snapshotcollectMetrics()a travers le vrai handler d'ingestion importe d'index.js— 204, fichier ecrit, relu champ pour champ. Une derive entre agent et serveur devient un test rouge au lieu d'un 400 a 3 h du matin sur le ThinkPad.Les champs sont copies un par un plutot que spread : un ajout futur a
collectMetrics()ne peut pas elargir en silence ce qui quitte le poste (un cas le verifie avec un fauxcpu.serial).Essai a blanc reel
Chaine complete rejouee hors vitest, contre un serveur local ephemere montant le vrai
handlerd'index.jssur127.0.0.1:3987— la production n'a pas ete touchee. Copie figee montee comme le README le decrit (metrics.jsa la racine,agent/en dessous), fichier env en600, appel viarun-push.sh:Chemins d'echec verifies de la meme facon : mauvais token ->
host-agent: push failed: HTTP 401(exit 2) ; serveur eteint ->host-agent: push failed: request error (code=ECONNREFUSED)(exit 2) ; fichier env absent -> exit 1 avec le chemin manquant.grepdu token dans les deux logs d'echec : 0 occurrence.Decisions
agent/reste hors de l'image. LeCOPYduDockerfilen'est pas touche, et un test epingle qu'il copie les fichiers un par un et ne nomme jamais le repertoire (ni unCOPY . .qui l'embarquerait sans le nommer).metrics.jsun cran au-dessus d'agent/) plutot que d'aplatir :require("../metrics.js")reste vrai, diff minimal. Si le fichier manque, l'agent le dit en une ligne au lieu de laisser cron poster une pileMODULE_NOT_FOUND.HOST_IDmalforme, node introuvable,metrics.jsabsent), 2 = push non livre (reseau ou non-2xx). Un1demande un humain sur le poste ; un2se repare souvent tout seul au battement suivant.HOSTS_API_URLest donc valide desreadConfig()pour tomber en 1, pas en 2.600declenche un avertissement, pas un refus : un bit de permission ne merite pas de faire taire le heartbeat de tout le poste.--dry-runajoute (collecte + affiche le payload, ne touche ni le reseau ni la config) pour rendre l'« essai a blanc » du README executable avant meme que le fichier env existe.HOST_IDvalide cote client avec la meme regex que le serveur : une faute de frappe repond un message lisible au lieu d'un 404 opaque.CLAUDE.mdetREADME.mdmis a jour (compte de tests remis a la realite — il annoncait encore 61 —, pointeur versagent/README.md, gotchasagent/hors image et interdiction deset -x).Ecart signale
agent/README.mdinsiste sur le point que l'issue demande : deux postes partageant le memeHOST_IDs'ecrasent mutuellement toutes les 5 minutes sans qu'aucune erreur ne soit levee nulle part — le serveur garde un fichier par id, dernier ecrivain gagne, et le dashboard affiche un poste en parfaite sante dont les chiffres viennent alternativement de deux machines. Le signal a surveiller est lehostnamedeGET /hostsqui change d'un tour a l'autre a id constant. Le README le documente ; rien dans le code ne peut le detecter, et c'est bien une propriete de mise en service (#16).Tests
244 verts (194 de la base + 50). 6 executions consecutives sans instabilite. Un flottement a ete trouve et corrige pendant l'ecriture : deux appels a
buildSnapshot()de part et d'autre d'une frontiere de seconde different de 1 suruptime— le snapshot est desormais construit une fois et compare a lui-meme.Generated autonomously by /autopilot run of 2026-08-16
Verdict : APPROVE
Sur la question qui comptait — le token d'ingestion peut-il fuiter dans syslog — c'est la partie la mieux construite de la pile.
Verifie : le chemin d'erreur ne porte pas le token
err.messageseulement — jamais l'objet, jamais le sac d'options qui porte l'en-teteAuthorization.req.on("error")etres.on("error")ne transmettent queerr.code.ERR_INVALID_CHARsynchrone (newline en fin de token dans le fichier d'env — l'erreur d'installation classique) converti avant de pouvoir atteindre un appel console.res.resume(), jamais lu, jamais journalise.run-push.sh: pas deset -x(avec le commentaire qui explique pourquoi, et un test qui l'affirme), affiche les NOMS de variables et non les valeurs, avertit si le mode n'est pas 600.Et le test ne se contente pas de l'inspection visuelle :
tokenFragments()construit chaque sous-chaine de 5 caracteres ou plus d'un token de 24 caracteres choisi sans mot du dictionnaire ni suite de cinq chiffres (pour qu'un numero de port ou un timestamp ne puisse pas ressembler a une fuite), puis affirme qu'aucune n'apparait danserr.message,String(err),err.stack, les deux rendus JSON, et le stdout+stderr reels de deux processus reellement lances. Non vacuous, et exhaustif.Egalement verifie : payload construit champ par champ (un ajout futur a
collectMetrics()ne peut pas elargir silencieusement ce qui quitte le poste — avec un test dedie) ; contrat croise contre lesanitizeSnapshot()du serveur et pousse a travers le vrai handler d'ingestion ; pas de file d'attente ni de rejeu, une seule tentative, affirme par comptage de requetes ; plafond de temps en wall-clock couvrant DNS+connect+TLS+reponse plutot que la seule inactivite de socket ; le Dockerfile exclut toujoursagent/(affirme par un test de cette PR).Suggestions (non bloquantes)
HOST_AGENT_TIMEOUT_MSest mort sous le chemin d'installation supporte.readConfig()le lit depuisenv, maisrun-push.shsource le fichier d'env et n'exporte queHOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID(ligne 65). Le poser dans~/.config/maximus-host-agent.env— l'endroit documente pour la configuration — n'a aucun effet : l'agent utilise toujours le defaut de 10 s. Il est aussi absent deagent/README.md, donc rien ne promet actuellement qu'il fonctionne. Soit l'ajouter a la liste d'export, soit retirer le bouton.Le garde du timer peut laisser la promesse pendante.
if (settled || !req) return;: si le timer se declenchait avecreqencore null, la promesse ne se resoudrait jamais et le run cron resterait suspendu jusqu'a ce qu'on le tue. Inatteignable aujourd'hui (client.request()assigne synchroniquement avant qu'un timer puisse tirer), mais unfinish(reject, transportFailure("ETIMEDOUT"))dans cette branche serait une assurance gratuite.Revue adversariale — maillon 5/5, revu contre sa base
issue-14-hosts-tests.Mergee localement sur
mainen fast-forward (pile chainee : l API de merge Forgejo ne peut pas traiter une pile dont la base n est pasmain). Tip integre :e181a96. Forgejo ne detecte pas un merge ff local, donc cette PR est fermee a la main — le code EST surmain.Pull request closed