fix(deployer) : le service peut écrire dans sa destination, et le déploiement le mesure #111

Merged
foXaCe merged 1 commit from passe-20261018-064841-86b4d7bc into main 2026-10-18 09:05:48 +02:00
Owner

Le déploiement mesurait une seule chose au sujet de son service : qu'il puisse lire ce dont
il a besoin (le sel du miroir, FOXA-339). Il ne mesurait pas qu'il puisse écrire chez lui —
et c'est exactement là qu'un défaut vivait, invisible par construction.

La mesure

/opt/nuvio-tvexchange sur le LXC 125, le 2026-10-18 :

avant :  drwxr-xr-x  10001:10001        /opt/nuvio-tvexchange
après :  drwxr-xr-x  nuvio-tvx:nuvio-tvx

10001 est un uid SANS NOM (getent passwd 10001 ne rend rien), et le service tourne en
nuvio-tvx (uid 997). Le dossier était donc lisible par tous et inscriptible par personne
d'autre que ce propriétaire inexistant
. Le service grave ses sessions de la cabine dedans :

2026-10-18T06:06:45Z CABINE-SESSIONS-NON-GRAVEES PermissionError
2026-10-18T06:29:25Z CABINE-SESSIONS-NON-GRAVEES PermissionError
2026-10-18T06:29:25Z CABINE-SESSIONS-NON-GRAVEES PermissionError

et le fichier n'existait pas du tout, au lieu d'exister vide. Le commentaire du code dit la
conséquence : « une session non gravée tombera au prochain redémarrage ». Toute session tombe
donc à la première relance du service.

Pourquoi rien ne pouvait le voir

Le script ne chowne pas la racine de la destination, et c'est une décision écrite
(ordre_pose_droits : « ./ — la destination elle-même — est écarté : elle existe avant le dépôt
et n'en fait pas partie »). Un propriétaire de travers sur cette racine est donc invisible au
dépôt, et y reste : aucune livraison ne le verra, aucune ne le réparera. Et la preuve résidente ne
regardait que la lecture. Sur cette cible, le déploiement répond même « déjà déployé » et ne touche
à rien — le défaut pouvait durer des semaines sans qu'aucune passe n'ait à le dire.

Même famille que le sel du miroir (FOXA-339) : quelque chose dont le service a besoin, hors de ce
que le dépôt compare.

Le correctif

ordre_ecriture_par_le_service() imprime le fragment au lieu de l'exécuter — comme la pose des
droits — pour qu'un test puisse le jouer sur une arborescence témoin :

  • mesurée avec runuser, en tant que le service, jamais sous root : une sonde jouée sous root
    rend « conforme » sur un dossier que le service ne peut pas ouvrir, et c'est ce que faisaient les
    sondes du miroir, jouées sous root pendant que la substitution était morte ;
  • elle n'écrit rien — test -w puis test -x sont des appels système, pas une lecture
    d'ls -l — parce qu'elle se joue aussi en --repetition, qui promet qu'aucun octet ne sera écrit
    sur le LXC ; un fichon créé puis effacé salirait la destination si l'effacement échouait, et
    l'écart de contenu du dépôt suivant le compterait (leçon des sept .pyc du 2026-08-18) ;
  • trois mots, tous positifs : ECRITURE-POSSIBLE, ECRITURE-REFUSEE, et ECRITURE-NON-MESUREE
    quand runuser manque — un contrôle non joué (code 2), jamais un échec déguisé ;
  • le refus nomme la réparation, et le piège qu'elle ne doit pas déclencher : le dossier seul,
    jamais -R (un -R a mis la cabine hors service onze heures, le 2026-08-18).

Les preuves

Sept tests, éprouvés par réinjection : chacun rougit sur la mutation qu'il nomme.

mutation réinjectée test qui rougit
le garde reste dans le fichier mais n'est plus joué test_la_preuve_residente_exige_que_le_service_puisse_ecrire_dans_sa_destination
la mesure ne change plus d'identité (jouée sous root) test_l_ecriture_est_mesuree_en_tant_que_le_service_et_jamais_sous_root
la sonde écrit un fichon avant de l'effacer test_la_sonde_d_ecriture_n_ecrit_rien
le fragment rend toujours un oui test_une_destination_absente_n_est_jamais_un_feu_vert
un outil absent est rendu comme un refus test_un_outil_de_mesure_absent_ne_se_confond_ni_avec_un_oui_ni_avec_un_non
return 3 (non joué) confondu avec un échec test_la_preuve_residente_exige_que_le_service_puisse_ecrire_dans_sa_destination

⚠ La sonde qui écrit n'est PAS attrapée par le résidu : elle efface derrière elle et l'arbre
témoin est identique après. Elle l'est par un faux rm qui journalise et refuse — l'écho du
faux chown du 2026-08-18 : on mesure l'intention effective, pas l'absence de trace. C'est la
première version de ce test qui était verte sur cette mutation ; elle a été corrigée, pas
commentée.

Cas dit non jouable plutôt que simulé : sous root, test -w rend vrai partout. Le test se
saute avec ce motif quand os.geteuid() == 0 ; les cas négatifs qui mordent sur tous les bancs
(chemin absent, runuser qui refuse, outil absent) portent la mesure.

Porte

./run_tests.sh -q — 2124 passés, 14 sautés, 0 échec. Sur le même banc, la référence passe de
2117 à 2124 : les sept tests de ce correctif. verifier-parite-webui.sh et le garde d'octets
sont verts (aucun fichier de la cabine n'est touché ici).

Ce que cette PR ne porte pas

Les récits et les comptes de JOURNAL.md / CLAUDE.md. La référence de porte se remesure sur le
banc en vigueur (RG78) et l'écart se déclare ; ce n'est pas à cette PR de choisir le banc. Je les
porte à la demande, dans la même passe ou après la fusion.

Le correctif est déjà appliqué sur la cabine

chown nuvio-tvx:nuvio-tvx /opt/nuvio-tvexchange — le dossier seul, sans -R — puis l'écriture
telle que le service la fait, jouée en nuvio-tvx :

création + remplacement dans le dossier : OK

Le service n'a pas été redémarré (NRestarts=0, démarré le 2026-10-17 à 19:13). Ce garde-ci
est là pour que la prochaine fois, la livraison le dise.

Le déploiement mesurait une seule chose au sujet de son service : qu'il puisse **lire** ce dont il a besoin (le sel du miroir, `FOXA-339`). Il ne mesurait pas qu'il puisse **écrire** chez lui — et c'est exactement là qu'un défaut vivait, invisible par construction. ## La mesure `/opt/nuvio-tvexchange` sur le LXC 125, le 2026-10-18 : ``` avant : drwxr-xr-x 10001:10001 /opt/nuvio-tvexchange après : drwxr-xr-x nuvio-tvx:nuvio-tvx ``` `10001` est un uid **SANS NOM** (`getent passwd 10001` ne rend rien), et le service tourne en `nuvio-tvx` (uid 997). Le dossier était donc lisible par tous et **inscriptible par personne d'autre que ce propriétaire inexistant**. Le service grave ses sessions de la cabine dedans : ``` 2026-10-18T06:06:45Z CABINE-SESSIONS-NON-GRAVEES PermissionError 2026-10-18T06:29:25Z CABINE-SESSIONS-NON-GRAVEES PermissionError 2026-10-18T06:29:25Z CABINE-SESSIONS-NON-GRAVEES PermissionError ``` et le fichier n'existait **pas du tout**, au lieu d'exister vide. Le commentaire du code dit la conséquence : *« une session non gravée tombera au prochain redémarrage »*. Toute session tombe donc à la première relance du service. ## Pourquoi rien ne pouvait le voir Le script **ne chowne pas la racine de la destination**, et c'est une décision écrite (`ordre_pose_droits` : « `./` — la destination elle-même — est écarté : elle existe avant le dépôt et n'en fait pas partie »). Un propriétaire de travers sur cette racine est donc invisible au dépôt, et y reste : aucune livraison ne le verra, aucune ne le réparera. Et la preuve résidente ne regardait que la lecture. Sur cette cible, le déploiement répond même « déjà déployé » et ne touche à rien — le défaut pouvait durer des semaines sans qu'aucune passe n'ait à le dire. Même famille que le sel du miroir (`FOXA-339`) : quelque chose dont le service a besoin, hors de ce que le dépôt compare. ## Le correctif `ordre_ecriture_par_le_service()` **imprime** le fragment au lieu de l'exécuter — comme la pose des droits — pour qu'un test puisse le jouer sur une arborescence témoin : - mesurée avec `runuser`, **en tant que le service**, jamais sous root : une sonde jouée sous root rend « conforme » sur un dossier que le service ne peut pas ouvrir, et c'est ce que faisaient les sondes du miroir, jouées sous root pendant que la substitution était morte ; - elle **n'écrit rien** — `test -w` puis `test -x` sont des appels système, pas une lecture d'`ls -l` — parce qu'elle se joue aussi en `--repetition`, qui promet qu'aucun octet ne sera écrit sur le LXC ; un fichon créé puis effacé salirait la destination si l'effacement échouait, et l'écart de contenu du dépôt suivant le compterait (leçon des sept `.pyc` du 2026-08-18) ; - trois mots, tous positifs : `ECRITURE-POSSIBLE`, `ECRITURE-REFUSEE`, et `ECRITURE-NON-MESUREE` quand `runuser` manque — un contrôle non joué (code 2), jamais un échec déguisé ; - le refus nomme la réparation, et le piège qu'elle ne doit pas déclencher : le dossier **seul**, jamais `-R` (un `-R` a mis la cabine hors service onze heures, le 2026-08-18). ## Les preuves Sept tests, éprouvés **par réinjection** : chacun rougit sur la mutation qu'il nomme. | mutation réinjectée | test qui rougit | |---|---| | le garde reste dans le fichier mais n'est plus joué | `test_la_preuve_residente_exige_que_le_service_puisse_ecrire_dans_sa_destination` | | la mesure ne change plus d'identité (jouée sous root) | `test_l_ecriture_est_mesuree_en_tant_que_le_service_et_jamais_sous_root` | | la sonde écrit un fichon avant de l'effacer | `test_la_sonde_d_ecriture_n_ecrit_rien` | | le fragment rend toujours un oui | `test_une_destination_absente_n_est_jamais_un_feu_vert` | | un outil absent est rendu comme un refus | `test_un_outil_de_mesure_absent_ne_se_confond_ni_avec_un_oui_ni_avec_un_non` | | `return 3` (non joué) confondu avec un échec | `test_la_preuve_residente_exige_que_le_service_puisse_ecrire_dans_sa_destination` | ⚠ **La sonde qui écrit n'est PAS attrapée par le résidu** : elle efface derrière elle et l'arbre témoin est identique après. Elle l'est par un **faux `rm`** qui journalise et refuse — l'écho du faux `chown` du 2026-08-18 : on mesure l'intention effective, pas l'absence de trace. *C'est la première version de ce test qui était verte sur cette mutation ; elle a été corrigée, pas commentée.* Cas **dit non jouable** plutôt que simulé : sous root, `test -w` rend vrai partout. Le test se saute avec ce motif quand `os.geteuid() == 0` ; les cas négatifs qui mordent sur tous les bancs (chemin absent, `runuser` qui refuse, outil absent) portent la mesure. ## Porte `./run_tests.sh -q` — **2124 passés, 14 sautés, 0 échec**. Sur le même banc, la référence passe de **2117 à 2124** : les sept tests de ce correctif. `verifier-parite-webui.sh` et le garde d'octets sont verts (aucun fichier de la cabine n'est touché ici). ## Ce que cette PR ne porte pas Les récits et les comptes de `JOURNAL.md` / `CLAUDE.md`. La référence de porte se remesure sur le banc en vigueur (`RG78`) et l'écart se déclare ; ce n'est pas à cette PR de choisir le banc. Je les porte à la demande, dans la même passe ou après la fusion. ## Le correctif est déjà appliqué sur la cabine `chown nuvio-tvx:nuvio-tvx /opt/nuvio-tvexchange` — le dossier seul, sans `-R` — puis l'écriture telle que le service la fait, jouée **en `nuvio-tvx`** : ``` création + remplacement dans le dossier : OK ``` Le service n'a **pas** été redémarré (`NRestarts=0`, démarré le 2026-10-17 à 19:13). Ce garde-ci est là pour que la prochaine fois, la livraison le dise.
fix(deployer): le déploiement mesure que le service peut écrire dans sa destination
All checks were successful
Porte / porte (pull_request) Successful in 1m11s
f763d17722
Défaut mesuré le 2026-10-18 sur le LXC 125 : `/opt/nuvio-tvexchange` appartenait à l'uid `10001`
— un uid SANS NOM — alors que le service tourne en `nuvio-tvx`. Mode `drwxr-xr-x` : inscriptible
par le seul propriétaire, qui n'existe pas. Le service gravait ses sessions de la cabine dans
`cabine_sessions.json` sans y arriver — `CABINE-SESSIONS-NON-GRAVEES PermissionError`, trois
traces, dont deux à l'instant où quelqu'un ouvrait la cabine — et le fichier n'existait pas du
tout, au lieu d'exister vide. Conséquence : toute session tombe à la première relance du service.

Le défaut était invisible par construction : le script ne chowne PAS la racine de la destination
(décision écrite dans `ordre_pose_droits` — « `./` — la destination elle-même — est écarté »), et
la preuve résidente ne regardait que la LECTURE. Même famille que le sel du miroir (`FOXA-339`) :
quelque chose dont le service a besoin, hors de ce que le dépôt compare. Nul n'aurait pu le voir :
sur cette cible, le déploiement répond « déjà déployé » et ne touchait à rien.

La preuve résidente mesure donc l'écriture, par `ordre_ecriture_par_le_service()` — une fonction
qui IMPRIME le fragment, pour qu'un test puisse le jouer sur une arborescence témoin, comme le
fait la pose des droits :

  · mesurée avec `runuser`, EN TANT QUE le service, jamais sous root : une sonde jouée sous root
    rend « conforme » sur un dossier que le service ne peut pas ouvrir — c'est ce que faisaient
    les sondes du miroir, jouées sous root pendant que la substitution était morte ;
  · elle n'écrit RIEN — `test -w` est un appel système, pas une lecture d'`ls -l` — parce qu'elle
    se joue aussi en `--repetition`, qui promet qu'aucun octet ne sera écrit sur le LXC ; un
    fichon créé puis effacé salirait la destination si l'effacement échouait, et l'écart de
    contenu du dépôt suivant le compterait (leçon des sept `.pyc` du 2026-08-18) ;
  · trois mots, tous positifs : `ECRITURE-POSSIBLE`, `ECRITURE-REFUSEE`, et `ECRITURE-NON-MESUREE`
    quand `runuser` manque — un contrôle non joué (code 2), jamais un échec de la cible ;
  · le refus nomme la réparation, et le piège qu'elle ne doit pas déclencher.

Sept tests, éprouvés par réinjection : chacun rougit sur la mutation qu'il nomme (garde laissé
dans le fichier mais non joué, sonde jouée sous root, sonde qui écrit, fragment toujours vert,
outil absent rendu comme un refus). La sonde qui écrit est attrapée par un faux `rm` qui
journalise et refuse — l'écho du faux `chown` du 2026-08-18.

Porte : `./run_tests.sh -q` — 2124 passés, 14 sautés, 0 échec (2117 + 7). Les récits et comptes
de `JOURNAL.md` / `CLAUDE.md` ne sont pas inclus : ils appartiennent à la fusion.
foXaCe force-pushed passe-20261018-064841-86b4d7bc from f763d17722
All checks were successful
Porte / porte (pull_request) Successful in 1m11s
to 611dca7494
All checks were successful
Porte / porte (pull_request) Successful in 1m18s
2026-10-18 08:50:51 +02:00
Compare
Author
Owner

Le premier client réel de ce garde : le déploiement de 84a6c3e, deux heures après l'ouverture de la PR

En déployant FOXA-2959 (--cible tvexchange, BAK-FOXA-2959), la racine de la destination est
repassée à l'uid du poste qui déploie. Mesuré :

avant : drwxr-xr-x nuvio-tvx nuvio-tvx  /opt/nuvio-tvexchange   (réparé à la main plus tôt)
après : drwxr-xr-x depotmaj  depotmaj   /opt/nuvio-tvexchange   (uid 1000 = l'uid du poste)

La gravure des sessions de la cabine était donc re-cassée par le déploiement lui-même :

$ runuser -u nuvio-tvx -- sh -c 'printf x > /opt/nuvio-tvexchange/.preuve.$$'
sh: 1: cannot create /opt/nuvio-tvexchange/.preuve.2212369: Permission denied

Le mécanisme

tar -xzf s'exécute en root dans le conteneur et restaure par défaut le propriétaire inscrit
dans l'archive. L'archive est créée sur le poste (tar -czf - -C "$src" .), donc ./ y porte
l'uid du poste — 1000, qui est depotmaj dans le conteneur et stephane sur ce poste.
ordre_pose_droits chowne tous les chemins listés par l'archive sauf ./, la destination
elle-même, qui garde donc cet uid. Personne ne le voit : les fichiers déposés, eux, sont bien à
nuvio-tvx.

Ce n'est donc pas un accident de parcours : chaque déploiement de tvexchange re-casse la
racine
, et le service perd le droit d'écrire dans son propre dossier jusqu'à la réparation
manuelle. La racine 10001 mesurée le 2026-10-18 était le même défaut, avec l'uid d'un poste
antérieur.

Ce que ce garde aurait dit, mesuré

Avec lui en place, ce déploiement aurait rendu ceci au lieu d'un VERT :

    le service nuvio-tvx ne peut PAS écrire dans /opt/nuvio-tvexchange :
    ce qu'il grave ne survit à rien — ses sessions tombent à la première relance.
    Réparation : chown 'nuvio-tvx' '/opt/nuvio-tvexchange' — le dossier SEUL, jamais -R.
    ÉCHEC — tvexchange tourne dans une destination qu'il ne peut pas écrire.

C'est le seul endroit du dépôt qui interroge le service qui tourne, et il l'aurait dit.

Réparé, et reprouvé

$ chown nuvio-tvx:nuvio-tvx /opt/nuvio-tvexchange      # le dossier seul, jamais -R
$ runuser -u nuvio-tvx -- sh -c 'printf x > "$t" && mv "$t" "$f" && rm -f "$f"'
creation OK / remplacement OK / nettoyage OK

Aucun résidu de sonde, service non redémarré (NRestarts=0), /healthz sert 84a6c3e.

Reste à trancher — et ça ne se tranche pas ici

Le garde constate ; il ne corrige pas la cause. Faire chown la racine de la destination par le
déploiement (nature resident seulement, dossier seul) est un changement de périmètre du
chown
— et le périmètre de la pose des droits est exactement ce qui a coûté onze heures le
2026-08-18. Ça ne se fait pas dans une passe qui n'en a pas le mandat : je le porte séparément, et
seulement avec l'accord de l'opérateur.

## Le premier client réel de ce garde : le déploiement de `84a6c3e`, deux heures après l'ouverture de la PR En déployant `FOXA-2959` (`--cible tvexchange`, `BAK-FOXA-2959`), la racine de la destination est repassée à l'uid du **poste qui déploie**. Mesuré : ``` avant : drwxr-xr-x nuvio-tvx nuvio-tvx /opt/nuvio-tvexchange (réparé à la main plus tôt) après : drwxr-xr-x depotmaj depotmaj /opt/nuvio-tvexchange (uid 1000 = l'uid du poste) ``` La gravure des sessions de la cabine était donc **re-cassée par le déploiement lui-même** : ``` $ runuser -u nuvio-tvx -- sh -c 'printf x > /opt/nuvio-tvexchange/.preuve.$$' sh: 1: cannot create /opt/nuvio-tvexchange/.preuve.2212369: Permission denied ``` ### Le mécanisme `tar -xzf` s'exécute **en root** dans le conteneur et restaure par défaut le propriétaire inscrit dans l'archive. L'archive est créée sur le poste (`tar -czf - -C "$src" .`), donc `./` y porte **l'uid du poste** — `1000`, qui est `depotmaj` dans le conteneur et `stephane` sur ce poste. `ordre_pose_droits` chowne tous les chemins listés par l'archive **sauf `./`**, la destination elle-même, qui garde donc cet uid. Personne ne le voit : les fichiers déposés, eux, sont bien à `nuvio-tvx`. Ce n'est donc pas un accident de parcours : **chaque déploiement de `tvexchange` re-casse la racine**, et le service perd le droit d'écrire dans son propre dossier jusqu'à la réparation manuelle. La racine `10001` mesurée le 2026-10-18 était le même défaut, avec l'uid d'un poste antérieur. ### Ce que ce garde aurait dit, mesuré Avec lui en place, ce déploiement aurait rendu ceci au lieu d'un VERT : ``` le service nuvio-tvx ne peut PAS écrire dans /opt/nuvio-tvexchange : ce qu'il grave ne survit à rien — ses sessions tombent à la première relance. Réparation : chown 'nuvio-tvx' '/opt/nuvio-tvexchange' — le dossier SEUL, jamais -R. ÉCHEC — tvexchange tourne dans une destination qu'il ne peut pas écrire. ``` C'est le seul endroit du dépôt qui interroge le service qui tourne, et il l'aurait dit. ### Réparé, et reprouvé ``` $ chown nuvio-tvx:nuvio-tvx /opt/nuvio-tvexchange # le dossier seul, jamais -R $ runuser -u nuvio-tvx -- sh -c 'printf x > "$t" && mv "$t" "$f" && rm -f "$f"' creation OK / remplacement OK / nettoyage OK ``` Aucun résidu de sonde, service non redémarré (`NRestarts=0`), `/healthz` sert `84a6c3e`. ### Reste à trancher — et ça ne se tranche pas ici Le garde **constate** ; il ne corrige pas la cause. Faire chown la racine de la destination par le déploiement (nature `resident` seulement, dossier seul) est un changement de **périmètre du `chown`** — et le périmètre de la pose des droits est exactement ce qui a coûté onze heures le 2026-08-18. Ça ne se fait pas dans une passe qui n'en a pas le mandat : je le porte séparément, et seulement avec l'accord de l'opérateur.
foXaCe merged commit 80eb8eb86f into main 2026-10-18 09:05:48 +02:00
foXaCe deleted branch passe-20261018-064841-86b4d7bc 2026-10-18 09:05:48 +02:00
Sign in to join this conversation.
No reviewers
No labels
montee-majeure
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
foXaCe/backend!111
No description provided.