feat(agent): push workstation metrics to /hosts/<id> from cron #22

Closed
maximus wants to merge 1 commit from issue-15-local-agent into issue-14-hosts-tests
Owner

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

Fichier Role
agent/push-metrics.js Collecte via collectMetrics(), construit le payload, POST une fois, sort
agent/run-push.sh Wrapper cron : source le fichier env, verifie l'install, exec node
agent/README.md Installation sur un poste : copie figee, fichier env, essai a blanc, crontab, codes de sortie
__tests__/agent.test.js 50 cas

Ce 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.sh est destine a etre pipe dans logger, donc tout ce que le processus imprime finit dans /var/log/syslog et journald definitivement. Le chemin d'erreur ne rend donc que err.code et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete, jamais un corps de reponse. Et pas de set -x dans le wrapper : le shell echoerait le token en sourcant le fichier d'env. Le cout est reel et assume — un 400 dit que le payload a ete refuse, pas pourquoi ; la raison est dans les logs serveur, et --dry-run montre 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 a err.message, String(err), err.stack et 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 que logger avalerait.

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 snapshot collectMetrics() 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 faux cpu.serial).

Essai a blanc reel

Chaine complete rejouee hors vitest, contre un serveur local ephemere montant le vrai handler d'index.js sur 127.0.0.1:3987 — la production n'a pas ete touchee. Copie figee montee comme le README le decrit (metrics.js a la racine, agent/ en dessous), fichier env en 600, appel via run-push.sh :

$ run-push.sh 2>&1 | logger -t host-agent
host-agent: ok id=thinkpad status=204 hostname=max-ThinkPad-T490    # exit 0

$ curl -H "Authorization: Bearer …" http://127.0.0.1:3987/hosts
id: thinkpad | hostname: max-ThinkPad-T490 | online: true | ageSeconds: 5

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. grep du token dans les deux logs d'echec : 0 occurrence.

Decisions

  • [HIGH] agent/ reste hors de l'image. Le COPY du Dockerfile n'est pas touche, et un test epingle qu'il copie les fichiers un par un et ne nomme jamais le repertoire (ni un COPY . . qui l'embarquerait sans le nommer).
  • [MEDIUM] La copie figee reproduit la disposition du depot (metrics.js un 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 pile MODULE_NOT_FOUND.
  • [MEDIUM] Codes de sortie separes — 1 = install a reparer (env, HOST_ID malforme, node introuvable, metrics.js absent), 2 = push non livre (reseau ou non-2xx). Un 1 demande un humain sur le poste ; un 2 se repare souvent tout seul au battement suivant. HOSTS_API_URL est donc valide des readConfig() pour tomber en 1, pas en 2.
  • [MEDIUM] Un fichier env dont le mode n'est pas 600 declenche un avertissement, pas un refus : un bit de permission ne merite pas de faire taire le heartbeat de tout le poste.
  • [MEDIUM] --dry-run ajoute (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.
  • [MEDIUM] HOST_ID valide cote client avec la meme regex que le serveur : une faute de frappe repond un message lisible au lieu d'un 404 opaque.
  • [MEDIUM] CLAUDE.md et README.md mis a jour (compte de tests remis a la realite — il annoncait encore 61 —, pointeur vers agent/README.md, gotchas agent/ hors image et interdiction de set -x).

Ecart signale

agent/README.md insiste sur le point que l'issue demande : deux postes partageant le meme HOST_ID s'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 le hostname de GET /hosts qui 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 sur uptime — le snapshot est desormais construit une fois et compare a lui-meme.


Generated autonomously by /autopilot run of 2026-08-16

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 | Fichier | Role | |---|---| | `agent/push-metrics.js` | Collecte via `collectMetrics()`, construit le payload, POST une fois, sort | | `agent/run-push.sh` | Wrapper cron : source le fichier env, verifie l'install, `exec node` | | `agent/README.md` | Installation sur un poste : copie figee, fichier env, essai a blanc, crontab, codes de sortie | | `__tests__/agent.test.js` | 50 cas | ## Ce 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.sh` est destine a etre pipe dans `logger`, donc tout ce que le processus imprime finit dans `/var/log/syslog` et journald definitivement. Le chemin d'erreur ne rend donc **que** `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete, jamais un corps de reponse. Et pas de `set -x` dans le wrapper : le shell echoerait le token en sourcant le fichier d'env. Le cout est reel et assume — un `400` dit que le payload a ete refuse, pas pourquoi ; la raison est dans les logs serveur, et `--dry-run` montre 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 a `err.message`, `String(err)`, `err.stack` et 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 que `logger` avalerait. 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 snapshot `collectMetrics()` 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 faux `cpu.serial`). ## Essai a blanc reel Chaine complete rejouee hors vitest, contre un serveur local ephemere montant le vrai `handler` d'`index.js` sur `127.0.0.1:3987` — la production n'a pas ete touchee. Copie figee montee comme le README le decrit (`metrics.js` a la racine, `agent/` en dessous), fichier env en `600`, appel via `run-push.sh` : ``` $ run-push.sh 2>&1 | logger -t host-agent host-agent: ok id=thinkpad status=204 hostname=max-ThinkPad-T490 # exit 0 $ curl -H "Authorization: Bearer …" http://127.0.0.1:3987/hosts id: thinkpad | hostname: max-ThinkPad-T490 | online: true | ageSeconds: 5 ``` 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. `grep` du token dans les deux logs d'echec : 0 occurrence. ## Decisions - **[HIGH]** `agent/` reste hors de l'image. Le `COPY` du `Dockerfile` n'est pas touche, et un test epingle qu'il copie les fichiers un par un et ne nomme jamais le repertoire (ni un `COPY . .` qui l'embarquerait sans le nommer). - **[MEDIUM]** La copie figee reproduit la disposition du depot (`metrics.js` un 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 pile `MODULE_NOT_FOUND`. - **[MEDIUM]** Codes de sortie separes — 1 = install a reparer (env, `HOST_ID` malforme, node introuvable, `metrics.js` absent), 2 = push non livre (reseau ou non-2xx). Un `1` demande un humain sur le poste ; un `2` se repare souvent tout seul au battement suivant. `HOSTS_API_URL` est donc valide des `readConfig()` pour tomber en 1, pas en 2. - **[MEDIUM]** Un fichier env dont le mode n'est pas `600` declenche un avertissement, pas un refus : un bit de permission ne merite pas de faire taire le heartbeat de tout le poste. - **[MEDIUM]** `--dry-run` ajoute (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. - **[MEDIUM]** `HOST_ID` valide cote client avec la meme regex que le serveur : une faute de frappe repond un message lisible au lieu d'un 404 opaque. - **[MEDIUM]** `CLAUDE.md` et `README.md` mis a jour (compte de tests remis a la realite — il annoncait encore 61 —, pointeur vers `agent/README.md`, gotchas `agent/` hors image et interdiction de `set -x`). ## Ecart signale `agent/README.md` insiste sur le point que l'issue demande : deux postes partageant le meme `HOST_ID` s'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 le `hostname` de `GET /hosts` qui 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 sur `uptime` — le snapshot est desormais construit une fois et compare a lui-meme. --- Generated autonomously by /autopilot run of 2026-08-16
maximus added 1 commit 2026-08-16 16:23:03 +00:00
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.

run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.

50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.

agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.

Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.

Resolves #15
maximus added the
autopilot:pending-human
label 2026-08-16 16:23:19 +00:00
Author
Owner

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

  • Trois constructeurs de message a template fixe, aucun n'accepte d'objet d'erreur.
  • Tous les sites de log interpolent err.message seulement — jamais l'objet, jamais le sac d'options qui porte l'en-tete Authorization.
  • req.on("error") et res.on("error") ne transmettent que err.code.
  • ERR_INVALID_CHAR synchrone (newline en fin de token dans le fichier d'env — l'erreur d'installation classique) converti avant de pouvoir atteindre un appel console.
  • Corps de reponse draine par res.resume(), jamais lu, jamais journalise.
  • run-push.sh : pas de set -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 dans err.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 le sanitizeSnapshot() 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 toujours agent/ (affirme par un test de cette PR).

Suggestions (non bloquantes)

  1. HOST_AGENT_TIMEOUT_MS est mort sous le chemin d'installation supporte. readConfig() le lit depuis env, mais run-push.sh source le fichier d'env et n'exporte que HOSTS_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 de agent/README.md, donc rien ne promet actuellement qu'il fonctionne. Soit l'ajouter a la liste d'export, soit retirer le bouton.

  2. Le garde du timer peut laisser la promesse pendante. if (settled || !req) return; : si le timer se declenchait avec req encore 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 un finish(reject, transportFailure("ETIMEDOUT")) dans cette branche serait une assurance gratuite.


Revue adversariale — maillon 5/5, revu contre sa base issue-14-hosts-tests.

## 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 - Trois constructeurs de message a template fixe, aucun n'accepte d'objet d'erreur. - Tous les sites de log interpolent `err.message` seulement — jamais l'objet, jamais le sac d'options qui porte l'en-tete `Authorization`. - `req.on("error")` et `res.on("error")` ne transmettent que `err.code`. - `ERR_INVALID_CHAR` synchrone (newline en fin de token dans le fichier d'env — l'erreur d'installation classique) converti avant de pouvoir atteindre un appel console. - Corps de reponse draine par `res.resume()`, jamais lu, jamais journalise. - `run-push.sh` : pas de `set -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 dans `err.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 le `sanitizeSnapshot()` 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 toujours `agent/` (affirme par un test de cette PR). ### Suggestions (non bloquantes) 1. **`HOST_AGENT_TIMEOUT_MS` est mort sous le chemin d'installation supporte.** `readConfig()` le lit depuis `env`, mais `run-push.sh` source le fichier d'env et n'exporte que `HOSTS_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 de `agent/README.md`, donc rien ne promet actuellement qu'il fonctionne. Soit l'ajouter a la liste d'export, soit retirer le bouton. 2. **Le garde du timer peut laisser la promesse pendante.** `if (settled || !req) return;` : si le timer se declenchait avec `req` encore 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 un `finish(reject, transportFailure("ETIMEDOUT"))` dans cette branche serait une assurance gratuite. --- *Revue adversariale — maillon 5/5, revu contre sa base `issue-14-hosts-tests`.*
Author
Owner

Mergee localement sur main en fast-forward (pile chainee : l API de merge Forgejo ne peut pas traiter une pile dont la base n est pas main). Tip integre : e181a96. Forgejo ne detecte pas un merge ff local, donc cette PR est fermee a la main — le code EST sur main.

Mergee localement sur `main` en fast-forward (pile chainee : l API de merge Forgejo ne peut pas traiter une pile dont la base n est pas `main`). Tip integre : `e181a96`. Forgejo ne detecte pas un merge ff local, donc cette PR est fermee a la main — le code EST sur `main`.
maximus closed this pull request 2026-08-16 18:27:39 +00:00

Pull request closed

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#22
No description provided.