console des runners : le code sur le disque n'est pas le code servi

Les trois consoles portaient le moteur du jour et servaient celui du 16 septembre. Le
clone du genome ne notifiait rien et la tache de service demande state: started, qui ne
fait rien quand le service tourne deja ; inventory_gui.py lit son code au demarrage, et
seulement la. Le role retient desormais si le MOTEUR a avance — designe par
serveur_ops_depot_moteur, jamais un chemin recopie — et redemarre la console dans ce seul
cas : un plan qui avance ne coupe pas les pages ouvertes.

La sonde console-ops criait au vestibule ouvert sur les deux runners de locataire, sur
des consoles fermees. Elle frappait la boucle locale, seul endroit d'ou un 200 est le
resultat attendu en mode oidc, ou nginx est reduit a 127.0.0.1 pour qu'on ne contourne
pas la passerelle SSO. Elle connait maintenant son mode : en locale le verdict ne bouge
pas, en oidc la serrure mesuree est l'adresse d'ecoute. Le refus de la passerelle reste
l'affaire de la sonde passerelle de serveur_oauth2_proxy, sur le meme hote.

Valide : --syntax-check sur playbooks/groupes/serveur_ops.yml, ansible-lint sur le role
(profil production, 0 echec, 0 avertissement, 14 fichiers), le gabarit rendu dans ses deux
modes sans reste Jinja et bash -n propre, le filtre d'ecoute eprouve sur cinq formes
d'adresses, l'expression du when sur cinq cas dont le mode check. Le role est applique aux
trois runners, failed=0, et la sonde rejouee sur chacun rend 0.

Limite : le redemarrage automatique ne s'est pas declenche, les clones etant deja au
niveau de la forge — la tache s'est correctement abstenue. La preuve du declenchement
viendra au prochain genome pousse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-20 15:27:50 -04:00
parent 6367d851e8
commit f9a20b0870
3 changed files with 155 additions and 12 deletions

View file

@ -1,5 +1,73 @@
# CHANGELOG — Set-OPS
## 2026-09-20 (4) — Le code sur le disque n'est pas le code servi
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Les trois runners
portaient un moteur périmé, et leurs consoles servaient un moteur plus vieux encore.
Deux causes distinctes, l'une dans la chaîne de distribution, l'autre dans le rôle.
### Une forge en retard, et trois runners qui ne tirent que lorsqu'on les rejoue
Mesure du 20 septembre : la forge du site portait `19bbadf` (16 septembre) pour les six
dépôts, le poste `6367d85`. Le runner du site était à `504840f` — vingt-trois commits en
arrière — et les deux runners de locataire à `8456d29`, quinze en arrière. Aucun écart de
contenu : chaque révision trouvée était un ancêtre du poste, donc du retard seul.
`make genome-etat` le disait déjà, et nommait le remède. Les commits manquants touchaient
`scripts/inventory_gui.py`, dont *« la console dit sa portée »* et *« la page cesse de
proposer ce que le serveur refuse »* — ce que l'exploitant voyait à l'écran.
`make genome-pousser` remet la forge au niveau du poste ; rejouer `serveur_ops` remet les
clones au niveau de la forge. **Rien ne tire tout seul** : aucune minuterie ne rafraîchit
un runner, et c'est assumé, mais l'écart ne se signalait nulle part entre deux rejeux.
### Le handler existait, il n'était branché que sur le fichier d'unité
Après les déploiements, les trois consoles portaient `6367d85` sur leur disque et
servaient toujours le code chargé le 16 septembre à 16h36. La cause tient en deux lignes :
le clone du génome n'était enregistré que pour ses tentatives, sans `notify`, et la tâche
de service demande `state: started`, qui ne fait rien quand le service tourne déjà.
`python3 scripts/inventory_gui.py` lit son code au démarrage, et seulement là — le même
piège que le certificat renouvelé qu'un nginx non rechargé continue de servir périmé.
Le rôle retient désormais si le **moteur** a avancé — `serveur_ops_depot_moteur`, jamais
un chemin recopié — et redémarre la console dans ce seul cas. Un plan qui avance ne coupe
pas les pages ouvertes. L'expression du `when` est éprouvée sur cinq cas, dont le mode
`--check` où la boucle ne rend aucun résultat.
### Une sonde qui ignore son mode fabrique une panne permanente
`console-ops` rendait `exit 2` sur les deux runners de locataire — *« LE VESTIBULE EST
OUVERT : deployer, creer et raser sont a la portee de qui trouve l'adresse »* — sur des
consoles parfaitement fermées. Elle frappait `127.0.0.1:8090`, le seul endroit d'où, en
mode `oidc`, un 200 est le résultat **attendu** : nginx y est réduit à la boucle locale
précisément pour qu'on ne puisse pas contourner la passerelle SSO. Mesure du jour :
`ss` et la configuration déployée concordent, `oauth2-proxy` écoute sur `*:4180` et rend
302 vers Keycloak à une requête anonyme.
La sonde connaît maintenant son mode. En `locale`, nginx **est** le vestibule et doit
refuser : le verdict ne change pas. En `oidc`, la serrure n'est pas un code HTTP mais une
adresse d'écoute — toute écoute du port public hors de la boucle locale est la panne, et
le refus de la passerelle reste mesuré par la sonde `passerelle` de
`serveur_oauth2_proxy`, posée sur le même hôte. Deux sondes, deux serrures, aucune qui
devine le port de l'autre.
**Une panne permanente finit par ne plus être lue.** Un instrument qui ne sait pas d'où
il mesure invente des défauts, et use la confiance qu'on met dans les vrais.
### Ce qui a été vérifié, et ce qui ne l'a pas été
`--syntax-check` sur `playbooks/groupes/serveur_ops.yml` et `ansible-lint` sur le rôle
(profil `production`, 0 échec, 0 avertissement, 14 fichiers). Le gabarit de la sonde est
rendu dans ses deux modes, sans reste Jinja, et passe `bash -n` ; son filtre d'écoute est
éprouvé sur cinq formes d'adresses réelles. Le rôle est appliqué aux trois runners et la
sonde rejouée sur chacun.
**Le redémarrage automatique n'a pas été observé en conditions réelles** : les clones
étaient déjà au niveau de la forge, la tâche s'est donc correctement abstenue. La preuve
du déclenchement viendra au prochain génome poussé. Les trois consoles ont été redémarrées
à la main ce jour-là pour servir le code courant. Aucune preuve du harnais n'a été
rejouée, aucune voûte ouverte, aucune VM créée ou détruite.
## 2026-09-20 (3) — La capacité du système ne se déduit pas de la méthode
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Le premier comparatif

View file

@ -300,6 +300,20 @@
delay: 6
until: serveur_ops_clone is succeeded
# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20).
#
# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas
# `inventory_gui.py` ; le redemarrer alors couperait les pages ouvertes pour rien. Le
# dossier du moteur est celui que `serveur_ops_depot_moteur` derive de la liste des
# depots — jamais un chemin recopie ici.
- name: Retenir si le moteur du génome a avancé
ansible.builtin.set_fact:
serveur_ops_moteur_avance: >-
{{ (serveur_ops_clone.results | default([]))
| selectattr('item.dest', 'equalto', serveur_ops_depot_moteur)
| selectattr('changed', 'defined') | selectattr('changed')
| list | length > 0 }}
# --- LES COLLECTIONS, SANS DEPENDRE DE GALAXY ---------------------------------
#
# UN POSTE D'EXPLOITATION QUI APPELLE galaxy.ansible.com POUR SE CONSTRUIRE N'EST PAS
@ -675,6 +689,28 @@
- serveur_ops_gui_actif | bool
- not ansible_check_mode
# LE CODE NEUF SUR LE DISQUE N'EST PAS LE CODE SERVI (2026-09-20).
#
# `state: started` ne fait rien quand le service tourne deja, et le clone du genome ne
# notifiait rien. Un runner pouvait donc porter le moteur du jour et servir, depuis sa
# memoire, celui d'il y a quatre jours. Mesure de ce jour-la sur les trois runners :
# moteur 6367d85 sur le disque, console demarree le 16 septembre a 16h36. Le handler
# « Redemarrer la console Set-OPS » existait deja ; il n'etait branche que sur le
# fichier d'unite, qui lui ne changeait pas.
#
# LE PIEGE EST CONNU AILLEURS : un certificat renouvele qu'un nginx non recharge continue
# de servir perime. Ecrire ne suffit pas — il faut que le consommateur relise.
# `python3 scripts/inventory_gui.py` lit son code au demarrage, et seulement la.
- name: Redémarrer la console quand le moteur a avancé
ansible.builtin.systemd:
name: setops-gui.service
state: restarted
daemon_reload: true
when:
- serveur_ops_gui_actif | bool
- serveur_ops_moteur_avance | default(false) | bool
- not ansible_check_mode
# UN RUNNER QUI CESSE DE PUBLIER SA CONSOLE DOIT L'ETEINDRE. Sans ce retrait, retirer
# l'exposition du plan laisserait le service tourner et le vestibule ouvert — la console
# resterait joignable alors que plus rien ne la declare.

View file

@ -6,10 +6,33 @@
# PAS UNE ENVELOPPE DE GREFFON, ET C'EST ASSUME. `check_http` sait dire « ce service
# repond ». La verite d'ici est l'inverse : « ce service REFUSE-t-il encore ceux qui
# n'ont pas de quoi entrer ». Aucun greffon standard ne pense a mesurer une serrure.
#
# ═══ LA SERRURE N'EST PAS LA MEME DES DEUX COTES (2026-09-20) ═══
#
# Cette sonde a crie au trou de securite pendant cinq jours sur les deux runners de
# locataire : « LE VESTIBULE EST OUVERT », HTTP 200, sur des consoles qui etaient
# parfaitement fermees. Elle frappait `127.0.0.1:{{ serveur_ops_gui_port_public }}` —
# le seul endroit d'ou, en mode `oidc`, un 200 est le resultat ATTENDU.
#
# `locale` — nginx EST le vestibule. Il ecoute pour l'edge, et c'est lui qui demande
# le mot de passe. Un 200 anonyme est alors une porte ouverte : une panne.
# `oidc` — la passerelle SSO est le vestibule, et nginx est reduit a la boucle
# locale pour qu'on ne puisse pas la contourner. Un 200 depuis la boucle
# locale prouve seulement que le relais fonctionne. Ce qu'il faut mesurer
# ici, c'est que PERSONNE D'AUTRE que cette boucle n'atteint ce port.
#
# L'INSTRUMENT DOIT DIRE D'OU IL MESURE. Une sonde qui ne connait pas le mode du service
# qu'elle surveille fabrique des pannes, et une panne permanente finit par ne plus etre
# lue du tout.
#
# CE QU'ELLE NE MESURE PAS, ET POURQUOI. En `oidc`, le refus de la passerelle elle-meme
# est l'affaire de la sonde « passerelle » de `serveur_oauth2_proxy`, posee sur ce meme
# hote. Deux sondes, deux serrures, aucune qui devine le port de l'autre.
set -uo pipefail
GUI={{ serveur_ops_gui_ecoute }}:{{ serveur_ops_gui_port }}
VESTIBULE=127.0.0.1:{{ serveur_ops_gui_port_public }}
PORT_PUBLIC={{ serveur_ops_gui_port_public }}
NOM='{{ serveur_ops_hostname }}'
MODE='{{ serveur_ops_gui_auth }}'
@ -20,28 +43,44 @@ if [[ "${code}" != "200" ]]; then
exit 2
fi
# 2. LE VESTIBULE REFUSE-T-IL ? On frappe SANS justificatif, avec le bon `Host` : c'est
# ainsi que nginx choisit son vhost, et sans cet en-tete on mesurerait le serveur par
# defaut — c'est-a-dire autre chose.
# 2. LE VESTIBULE. On frappe SANS justificatif, avec le bon `Host` : c'est ainsi que nginx
# choisit son vhost, et sans cet en-tete on mesurerait le serveur par defaut — c'est-a-dire
# autre chose.
vu=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
-H "Host: ${NOM}" "http://${VESTIBULE}/" 2>/dev/null)
if [[ "${MODE}" == "oidc" ]]; then
# EN `oidc`, LA SERRURE EST UNE ADRESSE D'ECOUTE, PAS UN CODE HTTP.
#
# `ss -H -lnt` rend une ligne par ecoute, sans en-tete ; la 4e colonne porte l'adresse.
# Tout ce qui n'est pas la boucle locale est une seconde porte sur la console, ouverte
# a qui atteint l'hote — `deployer`, `creer` et `raser` sans passer par Keycloak.
hors_boucle=$(ss -H -lnt "sport = :${PORT_PUBLIC}" 2>/dev/null \
| awk '{print $4}' | grep -vE '^(127\.0\.0\.1|\[::1\]):' || true)
if [[ -n "${hors_boucle}" ]]; then
echo "LE VESTIBULE ECOUTE HORS DE LA BOUCLE LOCALE (${hors_boucle//$'\n'/, }) : en mode oidc la passerelle SSO est la seule porte, et ce port-la la contourne."
exit 2
fi
if [[ "${vu}" == "200" ]]; then
echo "Console vivante, vestibule (oidc) reduit a la boucle locale : le relais repond ici, et ce port n'est joignable de nulle part ailleurs."
exit 0
fi
echo "Le relais local ne sert plus la console : HTTP ${vu:-aucune reponse} sur ${VESTIBULE}. La passerelle SSO n'a plus rien a relayer."
exit 2
fi
# EN `locale`, NGINX EST LE VESTIBULE : c'est lui qui doit refuser.
#
# UN 200 ICI EST LA PIRE DES REPONSES. Il veut dire que le vestibule laisse entrer : la
# console est alors ouverte a quiconque atteint l'adresse, avec `deployer`, `creer` et
# l'edition du plan. On le traite comme une panne, parce que c'en est une.
vu=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
-H "Host: ${NOM}" "http://${VESTIBULE}/" 2>/dev/null)
case "${vu}" in
401|403)
echo "Console vivante, vestibule (${MODE}) en place : une requete anonyme rend HTTP ${vu}."
exit 0
;;
30[12478])
# Une passerelle SSO renvoie vers son fournisseur d'identite : c'est un refus, dit
# autrement. On l'accepte seulement quand c'est le mode declare — en `locale`, une
# redirection signifierait que le `auth_basic` n'est plus applique.
if [[ "${MODE}" != "locale" ]]; then
echo "Console vivante, vestibule (${MODE}) en place : une requete anonyme est renvoyee (HTTP ${vu})."
exit 0
fi
# Une redirection en mode locale signifie que le `auth_basic` n'est plus applique.
echo "LE VESTIBULE NE DEMANDE PLUS RIEN : en mode locale, une requete anonyme devrait rendre 401, elle rend ${vu}."
exit 2
;;