Compare commits

..

No commits in common. "35853ff5fb73541f052974490339945af0eb1d59" and "561c034eb447f6d86aab6a33f8f2e7c65cf214e3" have entirely different histories.

12 changed files with 14 additions and 863 deletions

View file

@ -1,129 +1,5 @@
# CHANGELOG — Set-OPS
## 2026-08-27 — P52 : matérialiser n'exige pas d'entrer chez le tenant
**52 preuves.** `creer-vm` confirmait son succès en attendant une réponse SSH. Le runner du
SITE matérialise le terrain de **tous** les tenants, mais la frontière lui refuse d'entrer
chez eux — c'est le sens même de leur isolation. La première VM qu'il a créée a donc été
déclarée en échec après 600 secondes alors qu'elle tournait, avec l'adresse exacte que le
plan lui destinait :
```
Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.
```
**La tentation était d'ouvrir le SSH du runner vers tous les tenants.** Ça aurait réparé la
mesure en détruisant ce qu'elle protège : une machine capable d'entrer chez chaque locataire
est précisément ce que cette architecture refuse d'avoir.
L'agent invité répond sans rien ouvrir — l'API des hyperviseurs est déjà le flux par lequel
la VM vient d'être créée, donc qui peut la créer peut la voir naître — et il **prouve
davantage**. « Quelque chose écoute sur le port 22 » ne dit ni quel système a démarré, ni si
cloud-init a posé la bonne adresse. Sur `ops-01` :
```
10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101
```
Ses échecs distinguent deux causes très différentes : « la machine démarre mais rapporte une
**autre** adresse — cloud-init, ou le pont sur lequel elle est posée », et « introuvable sur
la fabric ».
**Attendre la disponibilité a changé de main.** Guetter cloud-init et la libération de dpkg
appartient à qui va **configurer** : `deployer` le fait désormais, là où il se contentait
d'un `ping` unique. Il y gagne ce qu'il n'avait pas — attendre le verrou APT, faute de quoi
la première couche échouait dessus. Contrepartie assumée : un nom d'hôte erroné patiente au
lieu d'échouer vite ; échouer vite interdirait de chaîner création et déploiement, le geste
central d'une reconstruction.
*Une preuve qui exige un pouvoir que l'architecture refuse ne mesure pas le système : elle
mesure ce qu'il faudrait casser pour la satisfaire.*
## 2026-08-27 — P51 : quatre défauts de portabilité, révélés par le runner
**51 preuves.** Première matérialisation d'une VM de tenant depuis le runner du SITE. Elle a
échoué quatre fois d'affilée, sur quatre dépendances que le moteur ne déclarait nulle part.
Chacune fonctionnait chez le mainteneur pour une raison **différente**, et aucune de ces
raisons n'existe sur une autre machine.
**1. Versions des collections.** `requirements.yml` les nommait sans les épingler. Le runner
a reçu `community.general` 13.3.0 quand le poste porte la 10.3.0 — et la 11 a retiré les
modules Proxmox de cette collection (ils vivent désormais dans `community.proxmox`).
```
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
```
*Une dépendance non épinglée n'est pas une dépendance, c'est un pari sur l'état d'Internet à
la date du déploiement.*
**2. Installation en avant.** `ansible-galaxy` ne rétrograde pas : déclarer la 10.3.0 ne
suffisait pas à défaire une 13.3.0 déjà posée. `--force`. Un poste peut être en **avance**,
pas seulement en retard, et le dépôt doit faire autorité dans les deux sens.
**3. Emplacement.** Le `Makefile` pose `ANSIBLE_HOME ?= $(CURDIR)/.ansible` : sous `make`,
Ansible ne lit **que** `<moteur>/.ansible/collections`. Le rôle déposait dans
`<racine>/.ansible/collections` — à côté, jamais lu. Invisible chez le mainteneur, où les
collections viennent du paquet système, toujours dans le chemin quel que soit `ANSIBLE_HOME`.
Et le garde-fou d'installation suivait le **dépôt des archives** : changer la destination ne
le déclenchait pas. Il mesure désormais la destination — même piège qu'au 2026-08-24, déplacé
d'un cran.
**4. Bibliothèques Python.** Le venv recevait `ansible-core` et `pyyaml`, écrits en dur. Les
modules Proxmox tournent `delegate_to: localhost` et exigent `proxmoxer` **sur le
contrôleur**.
```
La bibliotheque Python proxmoxer est absente
```
Né d'ici : `requirements-python.txt`, avec sa frontière écrite — il ne déclare **que** ce qui
tourne sur le contrôleur. `python-ldap` et `psycopg2` s'exécutent sur leurs cibles ; le poste
du mainteneur ne les a pas et la flotte se déploie, ce qui prouve que la ligne est au bon
endroit.
**P51** garde les trois faiblesses de cette famille : une dépendance utilisée sans être
déclarée (le défaut du 2026-08-24, `ansible.posix`), une dépendance déclarée sans version, et
`proxmoxer` absent alors que des playbooks Proxmox existent. La preuve ne lit que les fichiers
de **tâches** : un `defaults/main.yml` porte `net.ipv4.ip_forward`, qu'un motif trop large
prendrait pour un module.
**Résultat :** `ops-01` matérialisée par le runner du site. L'agent invité rapporte
`eth0 10.17.19.41/24`, Debian 13 trixie — l'adressage dérivé du plan, appliqué.
**Migration connue, pas faite :** passer les quatre modules Proxmox à `community.proxmox`
permettra de suivre `community.general` au-delà de la 11.
*Un seul exécutant masque ces écarts indéfiniment ; un second les révèle tous en une soirée.
Même mécanique que le second tenant en août.*
## 2026-08-27 — P50 : le devis de la frontière sait refuser SANS consigner
**50 preuves.** L'outil ne savait qu'**autoriser** — `"action": "pass"` était en dur dans
l'émetteur. Une règle de silence ne pouvait donc pas naître du dépôt, et j'en avais posé deux
à la main sur le boîtier : exactement ce que ce projet refuse.
**Ce qui l'a motivé.** Le journal de la frontière écrivait 982 000 entrées par jour, dont
82 % un balayage Internet contre le port VNC et le reste du bavardage de découverte du réseau
local. Sa fenêtre utile était tombée à **quarante-quatre secondes**. J'y ai cherché la trace
d'un flux du site vers les hyperviseurs, je n'ai rien trouvé, et j'en ai conclu à tort
qu'aucune règle ne bloquait. **Un journal noyé ment aussi sûrement qu'un journal mort.**
Mesuré après déclaration : ~20 700/jour.
Une règle de silence ne change **aucun** comportement : ce qu'elle vise était déjà refusé par
le défaut. Elle ne supprime qu'une trace que personne ne lira.
**Trois pièces.** `cle_regle` accepte une action sans changer d'un octet la clé des règles
`pass` déjà posées — l'ajout naïf d'un champ les aurait toutes détruites pour les recréer à
l'identique, sur la frontière, en production ; le plan l'a confirmé : 7 à créer, 0 à retirer,
121 inchangées. `_corps_regle` lit l'action, la consignation et la **séquence** depuis le
devis : la séquence est ce qui rend un `block` sûr, puisque OPNsense évalue en `quick` et
qu'un blocage large émis avant les `pass` fermerait courrier, web et accès distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique règles et alias, **motif compris** —
une règle `block` muette sans raison écrite est indiscernable d'un oubli.
P50 garde ces deux dangers : un silence en séquence 1 échoue, un silence sans motif échoue.
## 2026-08-26 — P49 : le registre des flux avait dérivé sans bruit
**48 preuves.** `docs/registre-flux.md` est **généré** depuis les `roles/*/meta/flux.yml`,

View file

@ -355,17 +355,6 @@ appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<groupe>
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances-groupe --groupe $(GROUPE)
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" --limit '$(GROUPE):&$(GROUPE_HOTES_ACTIFS)'
# ATTENDRE PLUTOT QUE CONSTATER (2026-08-27). `deployer` faisait un `ping` unique
# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la
# materialisation par l'agent invite, seul geste que le runner du SITE ait le droit de
# faire chez un tenant — c'est ICI qu'il faut attendre : enchainer `creer-vm` puis
# `deployer` tomberait sinon sur une machine encore en cloud-init. On y gagne au passage
# ce que le `ping` unique ne faisait pas : attendre que `dpkg` se libere, faute de quoi
# la premiere couche echoue sur un verrou APT.
#
# CONTREPARTIE ASSUMEE : un nom d'hote errone ne fait plus echouer tout de suite, il
# patiente ATTENTE_HOTE secondes. Echouer vite sur l'injoignable interdirait de chainer
# la creation au deploiement — le geste central d'une reconstruction.
deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre du graphe — HOTE=<nom>
@set -e; \
if [[ -z "$(HOTE)" ]]; then \
@ -380,7 +369,7 @@ deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre
exit 2; \
fi; \
$(VAULT_UNE_FOIS) \
$(MAKE) --no-print-directory _attendre-hote LIMITE="$(HOTE)"; \
$(MAKE) _verifier-acces-hote LIMITE="$(HOTE)"; \
$(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \
for playbook in $$playbooks; do \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$$playbook" --limit "$(HOTE)"; \
@ -1029,25 +1018,11 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \
DEMARRER="$(DEMARRER)" \
CLONE_COMPLET="$(CLONE_COMPLET)"
@# `creer-vm` CONFIRME LA MATERIALISATION, PAS LA JOIGNABILITE (2026-08-27). Il
@# attendait une reponse SSH — un geste que le runner du SITE ne peut pas faire : la
@# frontiere lui refuse d'entrer chez les tenants, et c'est le sens meme de leur
@# isolation. La premiere VM creee par le runner a donc ete declaree en echec apres
@# 600 secondes alors qu'elle tournait, avec la bonne adresse.
@#
@# L'agent invite repond a cette question sans rien ouvrir — l'API des hyperviseurs
@# est deja le flux par lequel la VM vient d'etre creee — et il PROUVE DAVANTAGE :
@# il rapporte le systeme, le noyau et l'adresse effectivement portee, la ou SSH ne
@# disait que « quelque chose ecoute sur le port 22 ».
@#
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
@# `creer-vm` rend une VM PRETE, pas seulement demarree : sans cette attente,
@# enchainer `creer-vm` puis `deployer` echoue presque toujours sur une machine
@# neuve. C'est ce qui separe une suite de commandes d'une reconstruction.
@if [[ "$(ATTENDRE)" != "false" ]]; then \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
eval "$$params"; \
python3 scripts/attendre_materialisation.py \
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \
--delai $(ATTENTE_HOTE); \
$(MAKE) --no-print-directory _attendre-hote LIMITE="$(HOTE)"; \
fi
inventaire-verifier: ansible-runtime _instance-requise ## Verifie que l'inventaire se parse (voute dechiffree)

View file

@ -1,88 +0,0 @@
# Preuve de conformite — Set-OPS — 2026-08-27
> 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** : `instance` — inventaire `instance/inventories/production/hosts.yml`
- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| 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). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 37 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 35 rôles, 92 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_oauth2_proxy |
| 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 | ✅ OK | 5 hotes, 14 groupes (inventaire dechiffre et parse). |
| 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 : 18 secret(s) exige(s), tous presents. Voute reelle : 21 cle(s), aucun manque. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 14 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 : 11 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), 79 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 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, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 29 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 18, 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 | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 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 (16 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (25 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) 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 : 36 role(s) serveur/client tous nommes, 37 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 : 47 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 : 5 machine(s) du plan retrouvees, 56 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 84 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 (110 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 121 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. |
## 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-08-27._

View file

@ -1,88 +0,0 @@
# Preuve de conformite — Set-OPS — 2026-08-28
> 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** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| 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). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 37 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 35 rôles, 92 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 | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| 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 | ✅ OK | 15 hotes, 36 groupes (inventaire dechiffre et parse). |
| 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 : 25 cle(s), aucun manque. |
| 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 : 11 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), 79 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 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, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 29 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 18, 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 | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 40 exigence(s) de role, toutes satisfaites (126 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 (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 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) 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 : 36 role(s) serveur/client tous nommes, 37 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 : 47 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 : 5 machine(s) du plan retrouvees, 56 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 84 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 (110 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 121 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. |
## 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-08-28._

View file

@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
| rôles | 61 | `roles/*/` |
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` |
| pièces d'audit | 29 | `docs/audit/*` |
| pièces d'audit | 27 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -1,27 +0,0 @@
# Bibliotheques Python requises par le CONTROLEUR Set-OPS.
#
# LE PENDANT DE `requirements.yml`, POUR PYTHON (2026-08-27). Les collections Ansible
# etaient declarees ; les bibliotheques dont leurs modules dependent ne l'etaient nulle
# part. Le venv d'un poste d'exploitation recevait `ansible-core` et `pyyaml`, ecrits en
# dur dans le role — et rien d'autre.
#
# Ce que ca a coute : le runner du SITE a echoue a la premiere materialisation de VM sur
# « La bibliotheque Python proxmoxer est absente ». Le poste du mainteneur l'avait par
# son paquet systeme, sans que personne ne l'ait jamais declare.
#
# CE FICHIER NE LISTE QUE CE QUI TOURNE SUR LE CONTROLEUR. Les modules `ldap_*` et
# `postgresql_*` s'executent SUR LEURS CIBLES : `python-ldap` et `psycopg2` sont donc
# affaire des roles `serveur_openldap` et `serveur_postgresql`, pas d'ici. Le poste du
# mainteneur ne les a pas non plus, et la flotte se deploie tres bien — c'est la preuve
# que la frontiere est au bon endroit.
#
# Les versions sont EPINGLEES, pour la meme raison que dans `requirements.yml` : une
# dependance non epinglee n'est pas une dependance, c'est un pari sur l'etat d'Internet
# a la date du deploiement. Voir la preuve P51.
# `community.general.proxmox_kvm`, `proxmox_pool`, `proxmox_nic`, `proxmox_disk` —
# tous `delegate_to: localhost`, donc tous exiges sur le CONTROLEUR.
proxmoxer==2.2.0
# Transport HTTP de proxmoxer. `pip` l'installerait en dependance, mais une dependance
# transitive non nommee est une version non choisie.
requests==2.32.3

View file

@ -7,36 +7,12 @@
# poste du mainteneur. Une collection utilisee par un role et absente d'ici marche chez
# le mainteneur et echoue partout ailleurs : c'est le genre d'ecart qu'on ne voit qu'en
# portant le moteur sur une autre machine.
# LES VERSIONS SONT EPINGLEES (2026-08-27). Ce fichier nommait ses collections sans dire
# LESQUELLES : chaque machine installait donc ce qui etait courant le jour de son montage.
#
# Ce que ca a coute. Le runner du SITE, monte le 2026-08-26, a recu `community.general`
# 13.3.0 quand le poste du mainteneur porte la 10.3.0. Or la version 11 a RETIRE les
# modules Proxmox de cette collection — ils vivent desormais dans `community.proxmox`. Le
# runner a donc echoue a la premiere materialisation de VM, sur un
# « couldn't resolve module/action 'community.general.proxmox_pool' », alors que le meme
# depot, le meme playbook et le meme plan fonctionnaient chez le mainteneur.
#
# Un moteur qui ne dit pas de QUELLES versions il depend n'est pas portable : il est
# seulement chanceux. Nommer une collection sans l'epingler, c'est declarer une dependance
# sur « l'etat d'Internet a la date du deploiement ».
#
# MIGRATION CONNUE, PAS ENCORE FAITE : passer les quatre modules Proxmox
# (`proxmox_kvm`, `proxmox_pool`, `proxmox_nic`, `proxmox_disk`) a `community.proxmox`
# permettra de suivre `community.general` au-dela de la 11. Tant que ce n'est pas fait,
# l'epingle ci-dessous est ce qui tient. Les modules LDAP, eux, restent dans
# `community.general` — la migration ne les concerne pas.
collections:
- name: community.postgresql
version: 3.10.2
# `<11.0.0` N'EST PAS UN CAPRICE : la 11 a sorti les modules Proxmox de cette collection.
# Les remonter demanderait de reecrire `playbooks/proxmox/*` — voir la note ci-dessus.
- name: community.general
version: 10.3.0
# `serveur_backup` : ansible.posix.authorized_key. Utilisee depuis longtemps, jamais
# declaree — donc absente du runner, qui n'aurait pas pu deployer les sauvegardes.
- name: ansible.posix
version: 1.6.2
# `community.docker` a ete RETIREE le 2026-08-24 : `serveur_collabora` etait la seule
# exception conteneurisee de la flotte, et il tourne desormais en natif (paquet coolwsd
# du depot amont Collabora). Plus aucun role n'a besoin de Docker.

View file

@ -78,21 +78,6 @@
become: true
become_user: "{{ serveur_ops_utilisateur }}"
# LES BIBLIOTHEQUES DU CONTROLEUR SE DECLARENT, ELLES AUSSI (2026-08-27). Cette liste
# etait ecrite en dur — `ansible-core` et `pyyaml`, rien de plus — alors que les modules
# Proxmox du moteur exigent `proxmoxer` sur la machine qui les execute. Le runner du SITE
# a donc echoue a sa premiere materialisation de VM, sur une bibliotheque que le poste du
# mainteneur possedait par son paquet systeme sans que personne ne l'ait declaree.
# `requirements-python.txt` est desormais la source, au meme titre que `requirements.yml`
# pour les collections. Voir la preuve P51.
- name: Deposer la liste des bibliotheques Python du controleur
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../requirements-python.txt"
dest: "{{ serveur_ops_racine }}/requirements-python.txt"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
- name: Installer Ansible dans l'environnement isolé
ansible.builtin.pip:
name:
@ -108,22 +93,6 @@
delay: 6
until: serveur_ops_pip is succeeded
# TACHE SEPAREE : dans `ansible.builtin.pip`, `name` et `requirements` sont MUTUELLEMENT
# EXCLUSIFS. Les fusionner ne produit pas une erreur parlante — le module ignore l'un des
# deux, et la bibliotheque manquante ne se revele qu'au premier module qui en depend.
- name: Installer les bibliotheques Python du controleur (source declaree)
ansible.builtin.pip:
requirements: "{{ serveur_ops_racine }}/requirements-python.txt"
virtualenv: "{{ serveur_ops_venv }}"
state: present
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_pip_controleur
retries: 3
delay: 6
until: serveur_ops_pip_controleur is succeeded
# L'OUTILLAGE DOIT ETRE SUR LE CHEMIN DE CELUI QUI L'EXPLOITE (2026-08-26).
#
# Ansible vit dans un venv isole — c'est voulu, il est epingle. Mais rien ne le mettait sur
@ -264,50 +233,13 @@
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
# depuis le repertoire COURANT, pas depuis celui du fichier. Sans chdir : « Could not find
# community-postgresql-4.2.0.tar.gz » alors que l'archive est bien la, a cote.
# LE GARDE-FOU DOIT SUIVRE LE RESULTAT, PAS LE GESTE (2026-08-27). La condition
# d'installation ne regardait que le DEPOT des archives. Le jour ou l'on a corrige le
# CHEMIN d'installation, les archives etaient inchangees : le role a donc conclu qu'il
# n'y avait rien a faire, et les collections sont restees a l'ancien endroit. Le meme
# piege qu'en 2026-08-24, deplace d'un cran. On mesure desormais la DESTINATION.
- name: Voir si les collections sont deja la ou le moteur les cherche
ansible.builtin.stat:
path: >-
{{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur')
| first).dest }}/.ansible/collections/ansible_collections/community/general/MANIFEST.json
register: serveur_ops_collections_en_place
become: true
become_user: "{{ serveur_ops_utilisateur }}"
- name: Installer les collections depuis le depot local (hors ligne)
ansible.builtin.command:
cmd: >-
{{ serveur_ops_venv }}/bin/ansible-galaxy collection install
-r requirements.yml --force
-p {{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur')
| first).dest }}/.ansible/collections
-r requirements.yml
-p {{ serveur_ops_racine }}/.ansible/collections
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
# INSTALLER LA OU LE MOTEUR REGARDE (2026-08-27). Le `Makefile` pose
# `export ANSIBLE_HOME ?= $(CURDIR)/.ansible` : sous `make`, Ansible ne cherche donc ses
# collections QUE dans `<moteur>/.ansible/collections`. Ce role les deposait dans
# `<racine>/.ansible/collections` — a cote, jamais lu. Le runner du SITE echouait ainsi
# sur `couldn't resolve module/action community.general.proxmox_pool` alors que la
# collection etait bel et bien installee, complete, et visible par `ansible-doc`.
#
# POURQUOI LE MAINTENEUR NE VOYAIT RIEN : sur son poste, les collections viennent du
# paquet systeme (`/usr/lib/python3/dist-packages/ansible_collections`), toujours dans
# le chemin de recherche quel que soit `ANSIBLE_HOME`. Le reglage du Makefile n'y a
# jamais eu de consequence. Sur un runner, ou rien n'est installe par la distribution,
# il decide de tout. Encore un ecart qu'on ne voit qu'en portant le moteur ailleurs.
#
# `--force` PARCE QU'UN POSTE PEUT ETRE EN AVANCE, PAS SEULEMENT EN RETARD (2026-08-27).
# Sans lui, `ansible-galaxy` laisse en place une collection deja installee dans une
# version SUPERIEURE a celle demandee : il ne retrograde pas. Le runner du SITE, monte
# avant que `requirements.yml` n'epingle ses versions, portait `community.general` 13.3.0
# — une version d'ou les modules Proxmox ont ete RETIRES. La premiere materialisation de
# VM depuis le runner a echoue la-dessus. Le depot doit faire autorite dans les DEUX
# sens : ce que `requirements.yml` declare est ce qui est installe, ni plus haut ni plus
# bas. Voir la preuve P51.
#
# LE GARDE-FOU SUIVAIT LA MAUVAISE CHOSE (2026-08-24). Il etait `creates: …/community/general` :
# une collection AJOUTEE a requirements.yml n'aurait jamais ete installee, puisque le
# repertoire temoin existait deja. Le runner serait reste en retard sur le moteur, en
@ -322,7 +254,6 @@
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
when:
- serveur_ops_depot_collections is changed
or not serveur_ops_collections_en_place.stat.exists
- not ansible_check_mode
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par

View file

@ -111,14 +111,7 @@ def cle_regle(r: dict) -> str:
une regle a retirer, ce qui est exactement la verite.
"""
ports = ",".join(map(str, r["ports"] or ["*"]))
# L'ACTION N'ENTRE DANS LA CLE QUE SI ELLE N'EST PAS `pass` (2026-08-27). Les regles
# de SILENCE — des `block` non consignes — ont besoin d'une identite distincte, mais
# l'ajout naif d'un champ aurait change la cle des 117 regles deja posees : le
# rapprochement les aurait toutes detruites pour les recreer a l'identique, sur la
# frontiere, en production. Une marque vide pour le cas majoritaire garde ces cles
# octet pour octet, et n'en donne une nouvelle qu'a ce qui est nouveau.
marque = "" if str(r.get("action") or "pass") == "pass" else f"{r['action']}:"
return (f"setops:{marque}{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
return (f"setops:{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
f"{r['source']}->{r['destination']}:{ports}")
@ -181,29 +174,14 @@ def _corps_regle(r: dict, k: str) -> dict:
# le champ `destination_not` d'OPNsense.
dst = str(r["destination"])
nie = dst.startswith("!")
# L'ACTION ET LA CONSIGNATION VIENNENT DU DEVIS (2026-08-27). Elles etaient en dur :
# l'outil ne savait qu'AUTORISER, et une regle qui fait taire du bruit deja refuse
# n'avait donc aucun moyen de naitre du depot. Les defauts reproduisent exactement
# l'ancien comportement — `pass`, non consigne, sequence 1 — pour que les regles
# existantes soient emises a l'identique.
#
# LA SEQUENCE EST CE QUI REND UN `block` SUR : OPNsense evalue en `quick`, donc la
# PREMIERE regle qui correspond gagne. Un blocage large pose avant les `pass` du
# devis fermerait le courrier, le web et l'acces distant. Les silences se declarent
# a 900, apres tout le reste.
corps = {
"enabled": "1",
"sequence": str(r.get("sequence") or "1"),
"action": str(r.get("action") or "pass"),
"log": "1" if r.get("journaliser") else "0",
"sequence": "1",
"action": "pass",
"interface": r["interface"],
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
"ipprotocol": "inet",
# `any` reste EN MINUSCULES : OPNsense attend ses protocoles en majuscules mais
# nomme « tout protocole » `any`. Un `ANY` majuscule est rejete a la validation.
"protocol": ("any" if r["protocole"] == "any"
else "ICMP" if r["protocole"] == "icmp"
else r["protocole"].upper()),
"protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(),
"source_net": r["source"],
"destination_net": dst[1:] if nie else dst,
"destination_not": "1" if nie else "0",

View file

@ -1,119 +0,0 @@
#!/usr/bin/env python3
"""Attend qu'une VM soit MATERIALISEE : demarree, et portant l'adresse que le plan lui destine.
POURQUOI PAS SSH (mesure du 2026-08-27). `creer-vm` confirmait son succes en attendant
une reponse SSH. Deux choses clochaient.
La premiere est une question de PERIMETRE. Le runner du SITE materialise le terrain de
tous les tenants, mais la frontiere lui refuse — deliberement — d'entrer chez eux : c'est
le sens meme de l'isolation inter-tenant. La premiere VM creee par le runner a donc ete
declaree en echec apres 600 secondes alors qu'elle tournait parfaitement. Ouvrir le SSH
du runner vers tous les tenants aurait « repare » la mesure en detruisant ce qu'elle
mesurait : une machine capable d'entrer chez chaque locataire est exactement ce que
l'architecture cherche a ne pas avoir.
La seconde est que SSH PROUVE MOINS. « Quelque chose ecoute sur le port 22 » ne dit ni
quel systeme a demarre, ni si cloud-init a pose la bonne adresse. L'agent invite, lui,
rapporte le systeme, le noyau et l'adresse EFFECTIVEMENT portee par l'interface — on
verifie donc que la machine est celle que le plan decrivait, pas seulement qu'une machine
repond.
Et il n'ouvre rien : l'API des hyperviseurs est deja le flux par lequel on a cree la VM.
Qui peut la creer peut la voir naitre.
ATTENDRE LA DISPONIBILITE EST UN AUTRE GESTE, qui appartient a qui va CONFIGURER la
machine : `make deployer` attend desormais SSH, cloud-init et la liberation de dpkg,
juste avant d'en avoir besoin. Materialiser et configurer sont deux metiers.
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmox_api import Cluster # noqa: E402
def _noeud_de(api: Cluster, vmid: str) -> str | None:
"""Sur quel noeud vit ce VMID. Le plan ne le dit pas toujours — la fabric, si."""
noeuds = api("/nodes")
if not isinstance(noeuds, list):
return None
for n in noeuds:
nom = n.get("node")
vms = api(f"/nodes/{nom}/qemu")
if isinstance(vms, list) and any(str(v.get("vmid")) == str(vmid) for v in vms):
return nom
return None
def _adresses(api: Cluster, noeud: str, vmid: str) -> list[str]:
"""Les IPv4 que l'agent invite declare, hors boucle locale.
Un `_erreur` n'est pas un echec : tant que la machine demarre, l'agent ne repond pas
encore. C'est la difference entre « pas encore » et « pas du tout », et seule
l'expiration du delai tranche.
"""
rep = api(f"/nodes/{noeud}/qemu/{vmid}/agent/network-get-interfaces")
if not isinstance(rep, dict) or "result" not in rep:
return []
out = []
for i in rep["result"]:
if i.get("name") == "lo":
continue
for a in i.get("ip-addresses") or []:
if a.get("ip-address-type") == "ipv4":
out.append(str(a.get("ip-address")))
return out
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--vmid", required=True)
ap.add_argument("--ip", required=True, help="l'adresse que le plan destine a cette VM")
ap.add_argument("--hote", default="", help="son nom, pour les messages")
ap.add_argument("--delai", type=int, default=600)
ap.add_argument("--noeud", default="")
args = ap.parse_args(argv)
api, _ = Cluster.depuis_hebergeur()
nom = args.hote or f"VMID {args.vmid}"
noeud = args.noeud or _noeud_de(api, args.vmid)
if not noeud:
print(f"ECHEC: VMID {args.vmid} introuvable sur la fabric.", file=sys.stderr)
return 4
print(f"Attente de la materialisation de {nom} ({args.ip}) ", end="", flush=True)
fin = time.time() + args.delai
vues: list[str] = []
while time.time() < fin:
vues = _adresses(api, noeud, args.vmid)
if args.ip in vues:
print(" ok")
osinfo = api(f"/nodes/{noeud}/qemu/{args.vmid}/agent/get-osinfo")
o = osinfo.get("result", {}) if isinstance(osinfo, dict) else {}
print(f" {nom} : {args.ip} portee sur {noeud} — "
f"{o.get('pretty-name') or o.get('name') or 'systeme inconnu'} "
f"{o.get('kernel-release', '')}".rstrip())
return 0
print(".", end="", flush=True)
time.sleep(5)
print(f"\nECHEC: {nom} n'a pas porte {args.ip} apres {args.delai}s.", file=sys.stderr)
if vues:
print(f" L'agent invite rapporte pourtant : {', '.join(vues)}. "
f"La machine demarre mais son adressage n'est pas celui du plan — "
f"cloud-init, ou le pont/VLAN sur lequel elle est posee.", file=sys.stderr)
else:
print(" L'agent invite n'a rien rapporte : la machine ne demarre pas, ou "
"`qemu-guest-agent` est absent du gabarit dore.", file=sys.stderr)
return 4
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -832,50 +832,6 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"tenant": "SITE",
})
# LES SILENCES — refuser sans CONSIGNER (2026-08-27).
#
# Un pare-feu refuse deja tout ce qui n'est pas declare ; le probleme n'est pas ce
# qu'il bloque, c'est ce qu'il ECRIT. Mesure du jour : 982 000 entrees par jour, dont
# 82 % un balayage Internet contre le port VNC et le reste du bavardage de decouverte
# (mDNS, SSDP, NetBIOS) du reseau local. La consequence n'est pas cosmetique — la
# fenetre utile du journal tombait a QUARANTE-QUATRE SECONDES, moins que le temps de
# reproduire un defaut. Un journal noye ne dit rien, exactement comme un journal mort.
#
# Une regle de silence ne change AUCUN comportement : ce qu'elle bloque etait deja
# refuse par le defaut. Elle ne fait que taire une trace que personne ne lira. C'est
# pourquoi elle se declare ici, avec son MOTIF : sans lui, une regle `block` muette
# dans un pare-feu est indiscernable d'un oubli.
for s in (intrants.get("opnsense_silences") or []):
_dest = str(s.get("destination") or "any")
# Une liste d'adresses devient un ALIAS, pas une regle par adresse : l'alias
# nomme l'intention, et le boitier montre alors POURQUOI c'est tu.
if s.get("destinations"):
_nom_alias = f"SETOPS_SILENCE_{str(s['nom']).upper()}"
alias[_nom_alias] = {
"type": "network",
"contenu": [str(x) for x in s["destinations"]],
"description": f"Silence — {s.get('motif') or s['nom']}",
}
_dest = _nom_alias
regles.append({
"sens": "in",
"interface": str(s.get("interface") or if_wan),
"protocole": str(s.get("protocole") or "any"),
"source": str(s.get("source") or "any"),
"destination": _dest,
"ports": [str(p) for p in (s.get("ports") or [])],
"role": f"silence:{s.get('nom') or 'bruit'}",
"tenant": "SILENCE",
"action": "block",
"journaliser": False,
# 900 : APRES tous les `pass` (sequence 1) et Wireguard (101). OPNsense evalue
# en `quick` — un blocage large place avant eux fermerait le courrier, le web
# et l'acces distant d'un seul coup.
"sequence": str(s.get("sequence") or 900),
"chiffrement": "sans objet",
"raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"),
})
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
@ -1055,15 +1011,10 @@ def rendre(devis: dict) -> str:
# Dans OPNsense une regle est TOUJOURS `in` sur l'interface d'arrivee : c'est
# la que le paquet penetre le pare-feu. Le sens du flux (entrant/sortant vis-a-vis
# de l'ecosysteme) se lit alors dans l'interface, pas dans un mot-cle.
# L'ACTION S'AFFICHE (2026-08-27) : le rendu disait « pass » pour tout, ce qui
# etait vrai tant que l'outil ne savait qu'autoriser. Un devis qui affiche
# « pass » devant une regle de blocage serait pire qu'un devis muet.
_act = str(rg.get("action") or "pass")
_mut = "" if rg.get("journaliser", False) or _act == "pass" else " [non consigne]"
out.append(
f"{_act:4} in on {rg['interface']:8} proto {rg['protocole']:3} "
f"pass in on {rg['interface']:8} proto {rg['protocole']:3} "
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
f"# {rg['role']} ({rg['chiffrement']}){_mut}"
f"# {rg['role']} ({rg['chiffrement']})"
)
out.append(f"# {rg['raison']}")
sortants = sum(1 for r in devis["regles"] if r["sens"] == "out")

View file

@ -1396,214 +1396,6 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
def preuve_materialiser_n_exige_pas_d_entrer() -> tuple[bool, str]:
"""Creer une VM ne demande pas d'entrer dans le tenant qui l'heberge.
POURQUOI (mesure du 2026-08-27). `creer-vm` confirmait son succes en attendant une
reponse SSH. Le runner du SITE materialise le terrain de TOUS les tenants, mais la
frontiere lui refuse d'entrer chez eux — c'est le sens meme de leur isolation. La
premiere VM qu'il a creee a donc ete declaree en echec apres 600 secondes alors
qu'elle tournait, avec l'adresse exacte que le plan lui destinait.
La tentation etait d'ouvrir le SSH du runner vers tous les tenants. Ca aurait
« repare » la mesure en detruisant ce qu'elle protege : une machine capable d'entrer
chez chaque locataire est precisement ce que cette architecture refuse d'avoir.
L'agent invite repond sans rien ouvrir, et prouve DAVANTAGE — le systeme, le noyau et
l'adresse effectivement portee, la ou SSH ne disait que « quelque chose ecoute sur le
port 22 ». Cette preuve garde le couplage ferme : materialiser et configurer sont deux
metiers, et seul le second exige d'entrer.
"""
mk = RACINE / "Makefile"
if not mk.is_file():
return False, "Makefile introuvable."
txt = mk.read_text(encoding="utf-8")
debut = txt.find("\ncreer-vm:")
if debut < 0:
return False, "La cible `creer-vm` a disparu du Makefile."
# La recette court jusqu'a la prochaine cible en debut de ligne.
suite = txt[debut + 1:]
fin = re.search(r"\n[A-Za-z_][A-Za-z0-9_.-]*:", suite)
recette = suite[:fin.start()] if fin else suite
outil = RACINE / "scripts" / "attendre_materialisation.py"
fautes = []
if "_attendre-hote" in recette:
fautes.append("`creer-vm` appelle `_attendre-hote`, qui exige SSH : un runner de "
"SITE ne peut pas entrer chez un tenant, et ne doit pas pouvoir.")
if not outil.is_file():
fautes.append("scripts/attendre_materialisation.py est absent.")
elif "attendre_materialisation.py" not in recette:
fautes.append("`creer-vm` ne confirme plus la materialisation : ni SSH ni agent "
"invite. Une creation qui ne verifie rien n'est pas une creation.")
if fautes:
return False, " | ".join(fautes)
return True, ("`creer-vm` confirme par l'agent invite (API des hyperviseurs, deja "
"utilisee pour creer), sans exiger d'entrer dans le tenant.")
def preuve_collections_declarees_et_epinglees() -> tuple[bool, str]:
"""Toute collection appelee par le moteur est DECLAREE, et a une version EPINGLEE.
DEUX PANNES, LA MEME CAUSE — le moteur ne disait pas de quoi il depend.
Le 2026-08-24, `ansible.posix` etait utilisee depuis longtemps sans figurer dans
`requirements.yml` : elle trainait sur le poste du mainteneur, et manquait partout
ailleurs. Le runner n'aurait pas pu deployer les sauvegardes.
Le 2026-08-27, la meme faiblesse a frappe d'un autre cote. Les collections etaient
nommees mais SANS VERSION : le runner du site, monte un jour plus tard, a recu
`community.general` 13.3.0 quand le poste porte la 10.3.0. La version 11 ayant sorti
les modules Proxmox de cette collection, la premiere materialisation de VM depuis le
runner a echoue sur `couldn't resolve module/action community.general.proxmox_pool`
— avec le meme depot, le meme playbook et le meme plan que chez le mainteneur.
Une dependance non epinglee n'est pas une dependance : c'est un pari sur l'etat
d'Internet a la date du deploiement. Un moteur qui se veut portable, et exploitable
par un sysadmin sans IA, doit pouvoir se reconstruire a l'identique dans six mois.
On lit les MODULES REELLEMENT APPELES dans les fichiers YAML — la clef d'une tache,
pas une chaine dans du Python : `ansible.parsing.vault` est un import, pas une
collection, et l'y confondre ferait crier la preuve pour rien.
"""
req = RACINE / "requirements.yml"
if not req.is_file():
return False, "requirements.yml est introuvable — le moteur ne declare aucune dependance."
try:
import yaml as _y
decl = (_y.safe_load(req.read_text(encoding="utf-8")) or {}).get("collections") or []
except Exception as e: # noqa: BLE001
return False, f"requirements.yml est illisible : {e}"
declarees = {}
for c in decl:
if isinstance(c, dict) and c.get("name"):
declarees[str(c["name"])] = str(c.get("version") or "").strip()
elif isinstance(c, str):
declarees[c] = ""
# Une clef de tache YAML : ` - community.general.ldap_entry:` ou ` module.x.y:`
motif = re.compile(r"^\s*(?:-\s+)?([a-z0-9_]+\.[a-z0-9_]+)\.[a-z0-9_]+\s*:", re.M)
# ON NE LIT QUE LES FICHIERS DE TACHES. Un `defaults/main.yml` peut contenir
# `net.ipv4.ip_forward: 1` — une clef de sysctl que le motif prendrait pour un module,
# et la preuve crierait a une collection manquante nommee `net.ipv4`. Un module ne
# peut apparaitre que la ou il y a des taches.
fichiers = list((RACINE / "playbooks").rglob("*.yml"))
for sous in ("tasks", "handlers"):
fichiers += list((RACINE / "roles").rglob(f"{sous}/*.yml"))
utilisees: dict[str, str] = {}
for f in fichiers:
try:
txt = f.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
continue
for coll in motif.findall(txt):
if coll == "ansible.builtin":
continue
utilisees.setdefault(coll, str(f.relative_to(RACINE)))
manquantes = [f"{c} (ex. {utilisees[c]})" for c in sorted(utilisees) if c not in declarees]
sans_epingle = [c for c in sorted(declarees) if not declarees[c]]
fautes = []
if manquantes:
fautes.append("appelee(s) mais NON DECLAREE(S) dans requirements.yml : "
+ ", ".join(manquantes))
if sans_epingle:
fautes.append("declaree(s) SANS VERSION — le deploiement prendra ce qui passe : "
+ ", ".join(sans_epingle))
# LES BIBLIOTHEQUES PYTHON DU CONTROLEUR, MEME EXIGENCE (2026-08-27). Les collections
# etaient declarees ; ce dont leurs modules dependent ne l'etait nulle part. Le venv
# d'un poste recevait `ansible-core` et `pyyaml`, ecrits en dur dans le role. Le runner
# a echoue sur « la bibliotheque Python proxmoxer est absente » — que le poste du
# mainteneur possedait par son paquet systeme, sans que personne ne l'ait declaree.
py = RACINE / "requirements-python.txt"
if not py.is_file():
fautes.append("requirements-python.txt est absent : les bibliotheques du "
"controleur ne sont declarees nulle part.")
else:
lignes = [l.strip() for l in py.read_text(encoding="utf-8").splitlines()
if l.strip() and not l.strip().startswith("#")]
floues = [l for l in lignes if "==" not in l]
if not lignes:
fautes.append("requirements-python.txt ne declare aucune bibliotheque.")
if floues:
fautes.append("bibliotheque(s) Python sans version exacte : " + ", ".join(floues))
# Le moteur appelle des modules Proxmox EN LOCAL : `proxmoxer` est donc exige sur
# le controleur. C'est la dependance dont l'absence a bloque la premiere
# materialisation depuis le runner ; on la nomme pour qu'elle ne reparte pas.
if any((RACINE / "playbooks").rglob("*proxmox*")) and \
not any(l.lower().startswith("proxmoxer") for l in lignes):
fautes.append("le moteur porte des playbooks Proxmox mais ne declare pas "
"`proxmoxer`, exige sur le CONTROLEUR.")
py_n = len(lignes)
if fautes:
return False, " | ".join(fautes)
return True, (f"{len(utilisees)} collection(s) et {py_n} bibliotheque(s) Python "
f"declarees et epinglees : "
+ ", ".join(f"{c}=={declarees[c]}" for c in sorted(utilisees)))
def preuve_silences_declares() -> tuple[bool, str]:
"""Un `block` muet doit etre DECLARE, place APRES les `pass`, et porter son motif.
POURQUOI (mesure du 2026-08-27). Le journal de la frontiere ecrivait 982 000 entrees
par jour, dont 82 % un balayage Internet contre le port VNC. Sa fenetre utile etait
tombee a QUARANTE-QUATRE SECONDES : j'y ai cherche la trace d'un flux du site vers les
hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne
bloquait. Un journal noye ment aussi surement qu'un journal mort.
La reponse — taire ce qui est deja refuse — introduit dans l'outil sa PREMIERE regle
de blocage. Elle amene deux dangers que cette preuve garde :
1. L'ORDRE. OPNsense evalue en `quick` : la premiere regle qui correspond gagne. Un
blocage large emis avant les `pass` du devis fermerait d'un coup le courrier, le
web et l'acces distant. Les silences doivent donc porter une sequence STRICTEMENT
superieure a celle de toute regle `pass`.
2. LE MOTIF. Une regle `block` sans raison ecrite est indiscernable d'un oubli pour
qui relira cette frontiere dans six mois. Le silence se justifie ou n'existe pas.
On rejoue le devis en memoire — rien n'est ecrit, rien n'est joint sur le reseau.
"""
try:
sys.path.insert(0, str(RACINE / "scripts"))
import devis_opnsense as DO
devis = DO.construire(DO.decouvrir_du_site())
except Exception as e: # noqa: BLE001
return False, (f"Le devis de la frontiere n'a pas pu etre rejoue : {e}. "
f"Sans lui, on ne sait pas ce que la frontiere recevrait.")
regles = devis.get("regles") or []
silences = [r for r in regles if str(r.get("action") or "pass") != "pass"]
if not silences:
return True, ("Aucune regle de blocage au devis — rien a garder. La preuve "
"redeviendra parlante des qu'un silence sera declare.")
seq_pass = [int(r.get("sequence") or 1) for r in regles
if str(r.get("action") or "pass") == "pass"]
plancher = max(seq_pass) if seq_pass else 1
fautes = []
for r in silences:
seq = int(r.get("sequence") or 1)
if seq <= plancher:
fautes.append(
f"{r['interface']}/{r['role']} : sequence {seq} <= {plancher}, la plus "
f"haute des regles `pass`. En `quick`, ce blocage gagnerait AVANT elles.")
if r.get("journaliser"):
fautes.append(f"{r['interface']}/{r['role']} : consigne — ce n'est pas un silence.")
raison = str(r.get("raison") or "").strip()
if len(raison) < 30:
fautes.append(
f"{r['interface']}/{r['role']} : motif absent ou trop court "
f"({len(raison)} car.). Un blocage muet sans raison ecrite est un oubli.")
if fautes:
return False, " | ".join(fautes)
return True, (f"{len(silences)} silence(s) declare(s), tous en sequence > {plancher} "
f"(la plus haute des {len(seq_pass)} regles `pass`), tous non consignes "
f"et tous motives.")
def preuve_registre_flux_a_jour() -> tuple[bool, str]:
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI.
@ -2000,12 +1792,6 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_carte_dit_vrai},
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
"refs": [], "func": preuve_registre_flux_a_jour},
{"id": "P50", "titre": "Silences : un refus muet est declare, place en dernier, et motive",
"refs": [], "func": preuve_silences_declares},
{"id": "P51", "titre": "Collections : toutes declarees, toutes epinglees",
"refs": [], "func": preuve_collections_declarees_et_epinglees},
{"id": "P52", "titre": "Materialiser n'exige pas d'entrer dans le tenant",
"refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer},
{"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": [],