durcissement : cloud-init nait avec la VM et ne lui survit pas

cloud-init n est pas un logiciel d installation : c est une SOURCE DE
VERITE EXTERNE. Il se reveille a chaque demarrage et relit le lecteur
attache par l hyperviseur, qui peut redefinir comptes, cles SSH, mots de
passe et reseau. Sur une machine que le plan possede, c est un second
maitre — que le plan ne decrit pas, que make valider ne mesure pas, et
qui parle en premier.

Sa tache est finie a la premiere seconde : c est parce qu il a REUSSI a
poser l adresse et les cles qu Ansible a pu entrer.

TROIS MOITIES, ET ELLES SE DEFONT SEPAREMENT.
 - le GABARIT le garde : sans lui un clone n a ni adresse ni nom ;
 - le SOCLE ne l installe plus : le garder produisait un va-et-vient a
   chaque deploiement, deux changed par passage, idempotence perdue ;
 - le DURCISSEMENT le retire (roles/cloud_init_retrait, en dernier).
P63 garde les trois, plus le CONTENU du role : une coquille vide passerait
les trois premiers controles sans rien fermer. Quatre controles negatifs
rejoues.

CE QUI REND LE RETRAIT SUR EST MESURE, PAS SUPPOSE (obs-01, 2026-09-09) :
/etc/network/interfaces.d/50-cloud-init n appartient a aucun paquet — dpkg
-S ne le trouve pas — et le postrm ne le nomme jamais, meme en purge.
L adresse survit. Le role le verifie quand meme, avant et apres, et n
accuse que si le retrait l a emporte : une VM qui perd ce fichier ne se
plaint pas, elle repart sans adresse et plus personne ne peut entrer.

DEUX CHOIX DITS FRANCHEMENT. cloud-guest-utils reste (growpart : ni
service, ni port, ni source de donnees). Les ~29 paquets orphelins ne sont
pas retires par defaut : autoremove deciderait a partir des drapeaux dpkg,
et un durcissement ne doit pas pouvoir surprendre.

NON DEPLOYE : le code est ecrit, valide et prouve ; il n a pas ete
applique a la flotte. Essai a blanc sur obs-01 : cloud-init a retirer,
configuration reseau intacte.

make prouver : CONFORME, 62 OK, 0 echec, 1 saute (63 preuves).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-09 09:25:42 -04:00
parent 765d97b5c9
commit 53d7b4c4c2
13 changed files with 439 additions and 9 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 62 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 63 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**
@ -547,8 +547,8 @@ Il peut contenir :
- compte technique `ansible` ;
- sudo NOPASSWD pour `ansible` lorsque requis ;
- `qemu-guest-agent` ;
- `cloud-init` ;
- `cloud-guest-utils` ;
- `cloud-init` — **au gabarit seulement** : retiré par `serveur_durci` une fois la VM née (D-85) ;
- `cloud-guest-utils` (`growpart`) — conservé : ni service, ni source de données ;
- chrony ;
- outils de diagnostic de base ;
- durcissement raisonnable ;
@ -636,6 +636,14 @@ Proxmox + cloud-init : identité initiale de la VM
Set-OPS + Ansible : configuration réelle du serveur
```
**Et cloud-init ne survit pas à cette première seconde** (D-85, 2026-09-09). Il se réveille
à *chaque* démarrage et relit le lecteur attaché par l'hyperviseur — lequel peut redéfinir
comptes, clés SSH, mots de passe et réseau. Sur une machine que le plan possède, c'est un
**second maître**, que le plan ne décrit pas. Le groupe `serveur_durci` le retire donc
(`cloud_init_retrait`), et le socle ne l'installe plus : le garder aux deux endroits
produisait un va-et-vient à chaque déploiement. **Le gabarit, lui, le garde** — sans lui un
clone n'a ni adresse ni nom. **P63** garde les trois moitiés.
---
## Nettoyage avant template

View file

@ -1,5 +1,82 @@
# CHANGELOG — Set-OPS
## 2026-09-09 — cloud-init nait avec la VM et ne lui survit pas
**63 preuves (P01-P63). `make prouver` : CONFORME, 62 OK, 0 echec, 1 saute.**
### Le second maitre
cloud-init n'est pas un logiciel d'installation : c'est une **source de verite externe**.
Il ne s'arrete pas apres la premiere seconde — il se reveille a CHAQUE demarrage et relit
le lecteur cloud-init attache par l'hyperviseur, lequel peut redefinir les comptes, les
cles SSH autorisees, les mots de passe et le reseau.
Sur une machine que le plan possede desormais, c'est un second maitre : le plan ne le
decrit pas, `make valider` ne le mesure pas, et il parle en premier.
Sa tache est pourtant finie a la premiere seconde. C'est precisement parce qu'il a REUSSI
a poser l'adresse, le nom et les cles d'hote qu'Ansible a pu entrer.
### Trois moities, et elles se defont separement
1. le **gabarit** le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas.
2. le **socle** ne l'installe plus. Le garder produisait un va-et-vient a chaque
deploiement — le socle installe, le durcissement retire, deux `changed` par passage,
l'idempotence perdue et `make valider` bruyant pour rien.
3. le **durcissement** le retire (`roles/cloud_init_retrait`, dernier role de
`serveur_durci`, apres que tout le reste soit pose).
Une seule des trois qui bouge et la decision devient son contraire en silence : un gabarit
sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le pouvoir a chaque
passage ; un durcissement qui ne le retire plus laisse le second maitre en place. **P63**
garde les trois, plus le contenu du role — un role vide passerait les trois premiers
controles sans rien fermer. Les quatre controles negatifs ont ete rejoues.
### Ce qui rend le retrait sur est MESURE, pas suppose
L'adresse d'une VM vit dans `/etc/network/interfaces.d/50-cloud-init`. Le risque evident
etait que le retrait l'emporte — une machine qui perd ce fichier ne se plaint pas : elle
repart au prochain demarrage sans adresse, et plus personne ne peut entrer pour le
constater. Mesure du 2026-09-09 sur `obs-01` :
dpkg -S /etc/network/interfaces.d/50-cloud-init -> aucun paquet ne le possede
/var/lib/dpkg/info/cloud-init.postrm -> ne nomme jamais interfaces.d
Le fichier survit donc, meme en `purge`, et `interfaces` fait toujours
`source interfaces.d/*`. Son en-tete annonce que les modifications « ne persistent pas au
redemarrage » : c'etait vrai TANT QUE cloud-init pouvait le reecrire. Le paquet parti,
plus personne ne le reecrit.
**Le role le verifie quand meme**, avant et apres, et n'accuse que si le retrait l'a
emporte — une machine qui n'a jamais eu ce fichier ne doit pas faire echouer le
durcissement. Essai a blanc sur `obs-01` : `cloud-init*` a retirer, configuration reseau
intacte, drapeau pose.
### Deux choix dits franchement
**`cloud-guest-utils` reste.** C'est `growpart` : ni service, ni port, ni source de
donnees. Le retirer ne fermerait aucune surface, et il faudrait le reinstaller au premier
agrandissement de disque.
**Les orphelins ne sont pas retires par defaut.** Le retrait laisse ~29 paquets qui
n'etaient la que pour cloud-init (`python3-jsonschema`, `python3-jinja2`,
`python3-oauthlib`...). Ce sont des bibliotheques : elles pesent, elles n'ouvrent rien.
`autoremove` calculerait exactement quoi retirer — a partir des drapeaux « installe
manuellement » de dpkg, et une machine dont ces drapeaux ont derive y perdrait autre
chose. Un durcissement ne doit pas pouvoir surprendre. `cloud_init_retrait_autoremove`
existe pour qui veut, en connaissance de cause.
### Et un drapeau, pour le retour par dependance
`cloud-init` peut revenir en recommandation d'un autre paquet.
`/etc/cloud/cloud-init.disabled` est lu par cloud-init lui-meme au demarrage et l'arrete
avant qu'il ne lise la moindre source de donnees.
### Non deploye
Le code est ecrit, valide et prouve ; **il n'a pas ete applique a la flotte**. Retirer un
paquet sur quatorze VM de production est une action a confirmer, pas a supposer.
## 2026-09-08 (4) — Les SIX registres ont un formulaire genere
**62 preuves. `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.**

View file

@ -0,0 +1,99 @@
# Preuve de conformite — Set-OPS — 2026-09-09
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (62 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 99 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 80 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 66 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (35 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 54 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (63 preuves, 66 roles, 40 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `2887b57` (publie le 2026-09-08). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-09._

View file

@ -23,12 +23,12 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 65 | `roles/*/` |
| README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 66 | `roles/*/` |
| README de rôles | 66 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 39 | `docs/*.md` |
| pièces d'audit | 39 | `docs/audit/*` |
| pièces d'audit | 40 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 81 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions en vigueur | 82 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
## 1. À lire d'abord (dans l'ordre)

View file

@ -58,6 +58,7 @@ sont les seules vérifiables.
| **D-82** | **Patient 0 n'est le parent de personne.** Il est la **mise en œuvre de référence** du modèle `origine` — le plus petit écosystème complet — et un pair de la famille du génome, pas sa racine | trois faits l'ont retiré un par un : D-81 a donné l'autorité du génome à la forge du SITE (son dernier lecteur corrigé le 2026-08-26, Technolibre le 08-31) ; le dénominateur commun vit dans les modèles depuis le 08-24 ; et **l'ancêtre était locataire de son enfant** — index 29 sur la fabric de `SITE-Chezlepro`, qui descend de lui. Ce qu'il devait éliminer — le SPOF `eregion`, hors flotte — n'a PAS été éliminé mais **promu** : le poste y pousse, la forge du site en tire. Cette dette appartient désormais au SITE, et la nommer est le minimum : *un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint* | `OPS-Patient0/README.md`, `docs/filiation-emancipation.md` | — |
| **D-83** | **Patient 0 a été retiré** — ses machines n'existent plus (constaté le 2026-09-06) | D-82 lui avait laissé une raison d'être : la mise en œuvre de référence du modèle `origine`, et un **témoin** de plus du génome. Le retrait solde la première et **abaisse la seconde de trois copies vivantes à deux** (`eregion`, la forge du site). Ce qu'il devait éliminer — le SPOF `eregion` — reste entier, et sans lui il n'y a plus de miroir indépendant pour l'absorber. **Son plan reste sur disque et la fédération lui réserve toujours l'index 29** : tant que ce n'est pas tranché, le site ouvre SSH, apt, DNS et HTTPS à `10.29.0.0/16` — un périmètre vide | `OPS-Patient0/`, `SITE-Chezlepro/flux-genere/` | **P21** (index), **P23** |
| **D-84** | **Le plan de contrôle reste gelé — c'est la CARTE DES SEUILS qui était fausse** | La question « et si on retirait le gel ? » a mis à l'épreuve les cinq seuils de `positionnement.md`, et deux ne tenaient pas. **RBAC** : couvert depuis que trois classes d'acteurs aux pouvoirs disjoints existent — poste, runner de site, runners de tenant — séparés **cryptographiquement** (une voûte, une clé, 2026-08-28) et non par une table de permissions qu'une faille applicative contournerait ; adopter AWX pour ce besoin serait **régresser**. **IPAM** : sans objet par construction — rien ne s'alloue, tout dérive du seed, et P20/P21/P23/P28/P33 tiennent déjà ce qu'un IPAM vérifierait *a posteriori*. Les deux lignes sont retirées du tableau : les garder aurait fait adopter un outil pour un besoin déjà rempli. **Et un seuil manquait** — l'**émancipation** : le GUI est mono-utilisateur (`127.0.0.1` + jeton), or la trajectoire mène à plusieurs humains aux portées disjointes, sur des machines qui ne sont pas les nôtres. Ce seuil n'appelle pas AWX, il appelle une décision non prise. *Un seuil qu'on ne nomme pas est un seuil qu'on franchit sans le voir.* Corollaire consigné : le gel porte sur les **fonctions**, jamais sur les **vues** — montrer à l'écran ce que le moteur sait déjà ne franchit aucun seuil | `positionnement.md` §3, §4, §5 | — |
| **D-85** | **cloud-init naît avec la VM et ne lui survit pas** | cloud-init n'est pas un logiciel d'installation : c'est une **source de vérité externe**. À chaque démarrage il relit le lecteur attaché par l'hyperviseur, qui peut redéfinir comptes, clés SSH autorisées, mots de passe et réseau. Sur une machine que le plan possède, c'est un **second maître** — que le plan ne décrit pas, que `make valider` ne mesure pas, et qui gagne parce qu'il parle en premier. Sa tâche est pourtant finie à la première seconde : c'est parce qu'il a **réussi** à poser l'adresse et les clés qu'Ansible a pu entrer. **La décision a trois moitiés, et elles se défont séparément** : le **gabarit** le garde (sans lui un clone ne naît pas — P56) ; le **socle** ne l'installe plus (le garder produisait un va-et-vient à chaque déploiement : le socle installe, le durcissement retire, deux `changed` par passage) ; le **durcissement** le retire (`cloud_init_retrait`). **Ce qui rend le retrait sûr est mesuré, pas supposé** (2026-09-09, `obs-01`) : `/etc/network/interfaces.d/50-cloud-init` n'appartient à aucun paquet — `dpkg -S` ne le trouve pas — et le `postrm` ne le nomme jamais, même en `purge`. L'adresse survit. Le rôle le **vérifie** malgré tout, avant et après : une VM qui perd ce fichier ne se plaint pas, elle repart sans adresse et plus personne ne peut entrer pour le constater | `roles/cloud_init_retrait/`, `serveur_durci.yml` | **P63** |
| **D-81** | **La forge du SITE fait autorité pour le génome.** Toute autre copie — y compris celle d'où le moteur a été poussé jusqu'ici — est un **miroir**. Le poste de l'exploitant ne route pas jusqu'à elle : c'est le **runner du site** qui publie, par `make genome-pousser` | un écosystème se reproduit depuis la forge de son site : c'est de là qu'il clone son moteur, ses plans, ses modèles. Si l'autorité est ailleurs, cette forge devient un cache qu'on croit à jour — et le 2026-08-26 elle était **quatre commits en arrière** sans que rien ne le signale, dont le correctif qui désarme le pare-feu Proxmox. **Un écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.** Le poste n'a de patte que sur l'administration, et on ne perce pas de chemin pour lui : le runner existe pour ce travail | `playbooks/maintenance/genome_pousser.yml`, `scripts/genome_colis.py`, `Makefile` §genome-pousser | — |
| **D-13** | Un **hébergeur** sert plusieurs **tenants** et a son tenant par défaut | Chezlepro est les deux à la fois, ce qui masquait la distinction | `frontiere-opnsense.md` §2 | — |
| **D-14** | `underlay.yml` appartient à l'**hébergeur**, monté par symlink | ce sont ses commutateurs, ses câbles ; le moteur est générique, un tenant n'en possède pas | `sdn-evpn.md`, `underlay.yml.example` | — |

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 62 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 63 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -146,7 +146,12 @@
- hosts_statiques
- common_packages
- qemu_guest_agent
- cloud_init
# `cloud_init` N'EST PLUS APPLIQUE ICI (2026-09-09). Il reste au GABARIT, qui en a
# besoin : il est le seul chemin vers la premiere seconde d'un clone (P56). Mais sur
# une machine deja nee, le reinstaller n'a plus d'objet — et le durcissement le
# retire juste apres. Les garder tous les deux produisait un va-et-vient a chaque
# deploiement : le socle installe, le durcissement retire, deux `changed` par passage,
# et l'idempotence perdue. P63 garde les trois moities de cette decision.
- sudo_ansible
- chrony
- ssh_baseline

View file

@ -29,3 +29,13 @@
- journald
- ssh_hardening
- nftables_baseline
# EN DERNIER, ET C'EST LE POINT. cloud-init a donne a la VM son adresse, son nom et
# ses cles d'hote a la premiere seconde ; c'est parce qu'il a REUSSI qu'Ansible a pu
# entrer. Mais il se reveille a CHAQUE demarrage et relit le lecteur attache par
# l'hyperviseur, qui peut redefinir comptes, cles SSH, mots de passe et reseau. Sur
# une machine que le plan possede desormais, c'est un second maitre — que le plan ne
# decrit pas, et qui parle en premier.
#
# Il est retire ICI et pas ailleurs : apres que tout le reste soit pose, pour que la
# machine ne depende plus de rien qu'il aurait fourni.
- cloud_init_retrait

View file

@ -0,0 +1,23 @@
# cloud_init_retrait
Retire `cloud-init` des machines **déployées**, dans le groupe `serveur_durci`.
**Pourquoi.** cloud-init est une source de vérité **externe** : à chaque démarrage il
relit le lecteur cloud-init attaché par l'hyperviseur, qui peut redéfinir comptes, clés
SSH, mots de passe et réseau. Sur une machine qu'Ansible possède, c'est un second maître
que le plan ne décrit pas et que `make valider` ne mesure pas.
**Pourquoi c'est sans risque.** Mesuré le 2026-09-09 sur `obs-01` :
`/etc/network/interfaces.d/50-cloud-init` n'appartient à aucun paquet (`dpkg -S` ne le
trouve pas) et le `postrm` de `cloud-init` ne le nomme jamais, même en `purge`. L'adresse
posée à la naissance survit. Le rôle le **vérifie** plutôt que de le supposer, et refuse
d'aller plus loin si le fichier a disparu.
**Ce qui n'est pas retiré.** `cloud-guest-utils` (`growpart`) : aucun service, aucune
source de données, aucun pouvoir. Le retirer ne fermerait rien.
**Le gabarit garde cloud-init** — il est le seul chemin vers la première seconde d'un
clone (P56). Le retrait n'intervient qu'après, sur la machine déployée. C'est pourquoi
`serveur_debian` ne l'applique plus : sinon le socle l'installerait et le durcissement le
retirerait à chaque passage, un va-et-vient à chaque déploiement. **P63** garde les trois
moitiés de cette décision.

View file

@ -0,0 +1,33 @@
---
# `cloud-guest-utils` n'est PAS retire par defaut. Il ne porte ni service, ni source de
# donnees, ni pouvoir d'ecrire sur la machine : c'est `growpart`, un utilitaire appele a
# la main quand on agrandit un disque. Le retirer ne fermerait aucune surface — et le
# jour ou l'on agrandit un disque, il faudrait le reinstaller.
cloud_init_retrait_paquets:
- cloud-init
# `purge` emporte `/etc/cloud`. C'est voulu : la configuration de cloud-init decrit QUI a
# le droit de reconfigurer la machine, et on ne garde pas une declaration de pouvoir pour
# un logiciel qu'on vient de retirer. Ce que le purge NE touche PAS est ce qui compte, et
# c'est mesure : `/etc/network/interfaces.d/50-cloud-init` n'appartient a aucun paquet
# (`dpkg -S` ne le trouve pas) et le `postrm` ne le nomme nulle part.
cloud_init_retrait_purge: true
# Le fichier que cloud-init a ecrit a la naissance et qui porte l'adresse de la machine.
# Nomme ici plutot qu'en dur : c'est LUI que le role verifie, et un chemin qu'on peut
# lire est un chemin qu'on peut corriger.
cloud_init_retrait_fichier_reseau: /etc/network/interfaces.d/50-cloud-init
# LES ORPHELINS, ET POURQUOI ON NE DECIDE PAS A LA PLACE DE L'OPERATEUR.
# Retirer cloud-init laisse ~29 paquets qui n'etaient la que pour lui (mesure du
# 2026-09-09 sur `obs-01` : python3-jsonschema, python3-jinja2, python3-oauthlib...).
# Ce sont des BIBLIOTHEQUES : aucun service, aucun port, aucune source de donnees. Elles
# pesent, elles n'ouvrent rien.
#
# `autoremove` calculerait exactement quoi retirer — mais il le calculerait a partir des
# drapeaux « installe manuellement » de dpkg, et une machine dont ces drapeaux ont derive
# y perdrait autre chose. Un durcissement ne doit pas pouvoir surprendre.
#
# Le defaut est donc `false` : on nomme ce qu'on retire. Passer a `true` est une decision
# d'exploitation, prise en connaissance de cause.
cloud_init_retrait_autoremove: false

View file

@ -0,0 +1,72 @@
---
# CE QUE CE ROLE FERME. cloud-init n'est pas un logiciel d'installation : c'est une SOURCE
# DE VERITE EXTERNE, qui se reveille a CHAQUE demarrage et relit le lecteur cloud-init
# attache par l'hyperviseur. Ce lecteur peut redefinir les comptes, les cles SSH
# autorisees, les mots de passe et le reseau. Sur une machine qu'Ansible possede
# desormais, c'est un second maitre — que le plan ne decrit pas, que `make valider` ne
# mesure pas, et qui gagne parce qu'il parle en premier.
#
# Sa tache est finie : il a donne a la VM son adresse, son nom et ses cles d'hote a la
# premiere seconde. C'est precisement parce qu'il a REUSSI qu'Ansible a pu entrer.
#
# POURQUOI LE RETIRER NE COUPE PAS LE RESEAU (mesure du 2026-09-09 sur `obs-01`) :
#
# /etc/network/interfaces.d/50-cloud-init -> dpkg -S : aucun paquet ne le possede
# /var/lib/dpkg/info/cloud-init.postrm -> ne nomme jamais interfaces.d
#
# Le fichier survit donc au retrait, `interfaces` fait toujours `source interfaces.d/*`,
# et l'adresse derivee du plan reste posee. Son en-tete annonce que les modifications
# « ne persistent pas au redemarrage » : c'etait vrai TANT QUE cloud-init pouvait le
# reecrire. Une fois le paquet parti, plus personne ne le reecrit — l'avertissement
# devient caduc, et le fichier devient la configuration.
#
# ON LE MESURE QUAND MEME. Une machine qui perd ce fichier ne se plaint pas : elle repart
# au prochain demarrage sans adresse, et plus personne ne peut entrer pour le constater.
# On releve donc son etat AVANT et APRES, et on n'accuse que si le retrait l'a emporte.
- name: Relever la configuration réseau AVANT le retrait
ansible.builtin.stat:
path: "{{ cloud_init_retrait_fichier_reseau }}"
register: cloud_init_retrait_reseau_avant
- name: Retirer cloud-init (sa tâche est finie à la première seconde)
ansible.builtin.apt:
name: "{{ cloud_init_retrait_paquets }}"
state: absent
purge: "{{ cloud_init_retrait_purge | bool }}"
autoremove: "{{ cloud_init_retrait_autoremove | bool }}"
- name: Relever la configuration réseau APRÈS le retrait
ansible.builtin.stat:
path: "{{ cloud_init_retrait_fichier_reseau }}"
register: cloud_init_retrait_reseau_apres
# La faute exacte, et elle seule : le fichier etait la, il n'y est plus. Une machine qui
# n'en a jamais eu (reseau tenu autrement) ne doit pas faire echouer le durcissement.
- name: Refuser si le retrait a emporté la configuration réseau
ansible.builtin.assert:
that:
- not (cloud_init_retrait_reseau_avant.stat.exists
and not cloud_init_retrait_reseau_apres.stat.exists)
fail_msg: >-
{{ cloud_init_retrait_fichier_reseau }} a disparu avec cloud-init. Cette machine
n'aurait plus d'adresse au prochain démarrage. NE PAS REDÉMARRER : restaurer le
fichier d'abord (la configuration attendue se dérive du plan).
success_msg: >-
{{ 'Configuration réseau intacte après le retrait.'
if cloud_init_retrait_reseau_apres.stat.exists
else 'Cette machine ne tient pas son réseau par cloud-init — rien à préserver.' }}
# UNE REINSTALLATION PAR DEPENDANCE NE DOIT PAS RENDRE LE POUVOIR. `cloud-init` peut
# revenir en recommandation d'un autre paquet ; ce drapeau est lu par cloud-init lui-meme
# au demarrage et l'arrete avant qu'il ne lise la moindre source de donnees.
- name: Poser le drapeau qui neutralise un cloud-init réinstallé
ansible.builtin.copy:
dest: /etc/cloud/cloud-init.disabled
owner: root
group: root
mode: "0644"
content: |
# Pose par le role cloud_init_retrait (groupe serveur_durci).
# cloud-init a fait son travail a la premiere seconde de cette VM ; il n'a plus a
# se reveiller. Si le paquet revient par dependance, ce fichier l'arrete.

View file

@ -2168,6 +2168,90 @@ def preuve_gabarit_minimal_et_repris() -> tuple[bool, str]:
f"repris par le socle ou le durcissement.")
def preuve_cloud_init_rendu_puis_retire() -> tuple[bool, str]:
"""cloud-init nait avec la VM, et ne lui survit pas.
LE POUVOIR QU'IL GARDE (decision du 2026-09-09). cloud-init n'est pas un logiciel
d'installation : c'est une SOURCE DE VERITE EXTERNE. A chaque demarrage il relit le
lecteur attache par l'hyperviseur, qui peut redefinir comptes, cles SSH autorisees,
mots de passe et reseau. Sur une machine que le plan possede, c'est un second maitre
— que le plan ne decrit pas, que `make valider` ne mesure pas, et qui gagne parce
qu'il parle en premier.
Sa tache est pourtant finie a la premiere seconde : c'est parce qu'il a REUSSI a poser
l'adresse et les cles qu'Ansible a pu entrer.
TROIS MOITIES, ET ELLES SE DEFONT SEPAREMENT :
1. le GABARIT le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas.
(P56 le tient deja comme indispensable ; on le redit ici parce que c'est la
moitie qu'on serait tente de retirer « pour faire propre ».)
2. le SOCLE ne l'installe plus. Le garder produisait un va-et-vient a chaque
deploiement — le socle installe, le durcissement retire, deux `changed` par
passage, l'idempotence perdue et `make valider` bruyant pour rien.
3. le DURCISSEMENT le retire. C'est la moitie qui porte l'intention ; sans elle,
les deux autres ne font que deplacer le probleme.
Une seule des trois qui bouge, et la decision devient son contraire en silence : un
gabarit sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le
pouvoir a chaque passage ; un durcissement qui ne le retire plus laisse le second
maitre en place sans que rien ne le dise.
"""
def roles_de(chemin: str) -> list[str]:
data = yaml.safe_load((RACINE / chemin).read_text(encoding="utf-8")) or []
out: list[str] = []
for play in data:
for r in (play.get("roles") or []):
nom = r if isinstance(r, str) else (r or {}).get("role")
if nom:
out.append(str(nom))
return out
gabarit = roles_de("playbooks/modeles_vm/debian13_proxmox_preparer.yml")
socle = roles_de("playbooks/groupes/serveur_debian.yml")
durci = roles_de("playbooks/groupes/serveur_durci.yml")
fautes = []
if "cloud_init" not in gabarit:
fautes.append("le GABARIT ne porte plus `cloud_init` — un clone naitrait sans "
"adresse ni nom d'hote, donc injoignable")
if "cloud_init" in socle:
fautes.append("le SOCLE applique encore `cloud_init` alors que le durcissement le "
"retire — va-et-vient a chaque deploiement, idempotence perdue")
if "cloud_init_retrait" not in durci:
fautes.append("le DURCISSEMENT n'applique plus `cloud_init_retrait` — la source de "
"verite externe reste en place, et rien ne le dit")
# Le role doit REELLEMENT retirer le paquet : une coquille vide passerait les trois
# controles ci-dessus tout en ne fermant rien.
defauts = RACINE / "roles" / "cloud_init_retrait" / "defaults" / "main.yml"
taches = RACINE / "roles" / "cloud_init_retrait" / "tasks" / "main.yml"
if not taches.is_file():
fautes.append("`roles/cloud_init_retrait/tasks/main.yml` est absent")
else:
corps = yaml.safe_load(taches.read_text(encoding="utf-8")) or []
retire = any((t.get("ansible.builtin.apt") or {}).get("state") == "absent"
for t in corps if isinstance(t, dict))
if not retire:
fautes.append("`cloud_init_retrait` ne retire aucun paquet (`state: absent` "
"absent) — le role existe mais ne ferme rien")
garde = any("assert" in cle for t in corps if isinstance(t, dict) for cle in t)
if not garde:
fautes.append("`cloud_init_retrait` ne verifie plus que la configuration "
"reseau survit au retrait — une VM sans adresse ne se plaint "
"pas, elle disparait")
if defauts.is_file():
d = yaml.safe_load(defauts.read_text(encoding="utf-8")) or {}
if "cloud-init" not in (d.get("cloud_init_retrait_paquets") or []):
fautes.append("`cloud_init_retrait_paquets` ne nomme plus `cloud-init`")
if fautes:
return False, "cloud-init :\n - " + "\n - ".join(fautes)
return True, ("cloud-init est au gabarit (la premiere seconde), absent du socle (pas "
"de va-et-vient), et retire par le durcissement — avec la garde qui "
"verifie que le reseau survit au retrait.")
def preuve_cle_du_site_bornee_au_runner() -> tuple[bool, str]:
"""La cle du SITE ne nait que sur le runner d'un tenant. Celle du tenant, partout chez lui.
@ -3011,6 +3095,8 @@ PREUVES: list[dict] = [
"refs": ["AFF-033"], "func": preuve_schema_du_plan},
{"id": "P62", "titre": "Schema du plan : il decrit tout ce que le MOTEUR accepte",
"refs": ["AFF-033"], "func": preuve_schema_couvre_les_validateurs},
{"id": "P63", "titre": "cloud-init nait avec la VM et ne lui survit pas", "refs": [],
"func": preuve_cloud_init_rendu_puis_retire},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],

View file

@ -39,6 +39,22 @@ Set-OPS + Ansible ──> configuration réelle du serveur
```
C'est *pour ça* que l'infra est reconstructible : cloner + reconfigurer = quelques minutes.
**Et cloud-init s'efface ensuite.** Il ne s'arrête pas après la première seconde : il se
réveille à *chaque* démarrage et relit le lecteur que l'hyperviseur a attaché à la VM — un
lecteur qui peut redéfinir les comptes, les clés SSH autorisées, les mots de passe et le
réseau. Sur une machine que le plan possède désormais, c'est un **second maître** : le plan
ne le décrit pas, `make valider` ne le mesure pas, et il parle en premier.
Le durcissement (`serveur_durci`) le **retire** donc, une fois qu'il a réussi — et c'est
parce qu'il a réussi qu'Ansible a pu entrer. Le gabarit, lui, le garde : sans lui, un clone
n'a ni adresse ni nom d'hôte.
*Pourquoi ça ne coupe pas le réseau* : l'adresse posée à la naissance vit dans
`/etc/network/interfaces.d/50-cloud-init`, un fichier qui **n'appartient à aucun paquet** —
`dpkg -S` ne le trouve pas, et le `postrm` ne le nomme jamais, même en `purge` (mesuré le
2026-09-09). Le rôle le vérifie quand même, avant et après : une VM qui perd ce fichier ne
se plaint pas, elle repart sans adresse et plus personne ne peut entrer pour le voir.
---
## ③ Pourquoi c'est transférable