From f9a20b087044cb7f2f173ec286a129e24fb7f911 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 20 Sep 2026 15:27:50 -0400 Subject: [PATCH] console des runners : le code sur le disque n'est pas le code servi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 68 +++++++++++++++++++ roles/serveur_ops/tasks/main.yml | 36 ++++++++++ .../templates/sonde-console-ops.sh.j2 | 63 +++++++++++++---- 3 files changed, 155 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d88fe87..360e301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/roles/serveur_ops/tasks/main.yml b/roles/serveur_ops/tasks/main.yml index de84792..1e94982 100644 --- a/roles/serveur_ops/tasks/main.yml +++ b/roles/serveur_ops/tasks/main.yml @@ -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. diff --git a/roles/serveur_ops/templates/sonde-console-ops.sh.j2 b/roles/serveur_ops/templates/sonde-console-ops.sh.j2 index 5d84bfc..e8fc2be 100644 --- a/roles/serveur_ops/templates/sonde-console-ops.sh.j2 +++ b/roles/serveur_ops/templates/sonde-console-ops.sh.j2 @@ -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 ;;