Compare commits
6 commits
561c034eb4
...
35853ff5fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 35853ff5fb | |||
| 6fe62f74e1 | |||
| 5d0f82792a | |||
| 3fa6e4c3e1 | |||
| 3b65384b0f | |||
| 14fa129731 |
12 changed files with 863 additions and 14 deletions
124
CHANGELOG.md
124
CHANGELOG.md
|
|
@ -1,5 +1,129 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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
|
## 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`,
|
**48 preuves.** `docs/registre-flux.md` est **généré** depuis les `roles/*/meta/flux.yml`,
|
||||||
|
|
|
||||||
35
Makefile
35
Makefile
|
|
@ -355,6 +355,17 @@ 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)
|
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)'
|
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>
|
deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre du graphe — HOTE=<nom>
|
||||||
@set -e; \
|
@set -e; \
|
||||||
if [[ -z "$(HOTE)" ]]; then \
|
if [[ -z "$(HOTE)" ]]; then \
|
||||||
|
|
@ -369,7 +380,7 @@ deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre
|
||||||
exit 2; \
|
exit 2; \
|
||||||
fi; \
|
fi; \
|
||||||
$(VAULT_UNE_FOIS) \
|
$(VAULT_UNE_FOIS) \
|
||||||
$(MAKE) _verifier-acces-hote LIMITE="$(HOTE)"; \
|
$(MAKE) --no-print-directory _attendre-hote LIMITE="$(HOTE)"; \
|
||||||
$(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \
|
$(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \
|
||||||
for playbook in $$playbooks; do \
|
for playbook in $$playbooks; do \
|
||||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$$playbook" --limit "$(HOTE)"; \
|
ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$$playbook" --limit "$(HOTE)"; \
|
||||||
|
|
@ -1018,11 +1029,25 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable —
|
||||||
CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \
|
CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \
|
||||||
DEMARRER="$(DEMARRER)" \
|
DEMARRER="$(DEMARRER)" \
|
||||||
CLONE_COMPLET="$(CLONE_COMPLET)"
|
CLONE_COMPLET="$(CLONE_COMPLET)"
|
||||||
@# `creer-vm` rend une VM PRETE, pas seulement demarree : sans cette attente,
|
@# `creer-vm` CONFIRME LA MATERIALISATION, PAS LA JOIGNABILITE (2026-08-27). Il
|
||||||
@# enchainer `creer-vm` puis `deployer` echoue presque toujours sur une machine
|
@# attendait une reponse SSH — un geste que le runner du SITE ne peut pas faire : la
|
||||||
@# neuve. C'est ce qui separe une suite de commandes d'une reconstruction.
|
@# 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.
|
||||||
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
@if [[ "$(ATTENDRE)" != "false" ]]; then \
|
||||||
$(MAKE) --no-print-directory _attendre-hote LIMITE="$(HOTE)"; \
|
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); \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
inventaire-verifier: ansible-runtime _instance-requise ## Verifie que l'inventaire se parse (voute dechiffree)
|
inventaire-verifier: ansible-runtime _instance-requise ## Verifie que l'inventaire se parse (voute dechiffree)
|
||||||
|
|
|
||||||
88
docs/audit/preuve-2026-08-27.md
Normal file
88
docs/audit/preuve-2026-08-27.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# 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 | [0m[0m |
|
||||||
|
| 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._
|
||||||
88
docs/audit/preuve-2026-08-28.md
Normal file
88
docs/audit/preuve-2026-08-28.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# 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 | [0m[0m |
|
||||||
|
| 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._
|
||||||
|
|
@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
| rôles | 61 | `roles/*/` |
|
| rôles | 61 | `roles/*/` |
|
||||||
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||||
| documents | 38 | `docs/*.md` |
|
| documents | 38 | `docs/*.md` |
|
||||||
| pièces d'audit | 27 | `docs/audit/*` |
|
| pièces d'audit | 29 | `docs/audit/*` |
|
||||||
| unités de wiki | 27 | `wiki/*.md` |
|
| unités de wiki | 27 | `wiki/*.md` |
|
||||||
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.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 |
|
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||||
|
|
|
||||||
27
requirements-python.txt
Normal file
27
requirements-python.txt
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# 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
|
||||||
|
|
@ -7,12 +7,36 @@
|
||||||
# poste du mainteneur. Une collection utilisee par un role et absente d'ici marche chez
|
# 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
|
# 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.
|
# 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:
|
collections:
|
||||||
- name: community.postgresql
|
- 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
|
- name: community.general
|
||||||
|
version: 10.3.0
|
||||||
# `serveur_backup` : ansible.posix.authorized_key. Utilisee depuis longtemps, jamais
|
# `serveur_backup` : ansible.posix.authorized_key. Utilisee depuis longtemps, jamais
|
||||||
# declaree — donc absente du runner, qui n'aurait pas pu deployer les sauvegardes.
|
# declaree — donc absente du runner, qui n'aurait pas pu deployer les sauvegardes.
|
||||||
- name: ansible.posix
|
- name: ansible.posix
|
||||||
|
version: 1.6.2
|
||||||
# `community.docker` a ete RETIREE le 2026-08-24 : `serveur_collabora` etait la seule
|
# `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
|
# 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.
|
# du depot amont Collabora). Plus aucun role n'a besoin de Docker.
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,21 @@
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ serveur_ops_utilisateur }}"
|
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é
|
- name: Installer Ansible dans l'environnement isolé
|
||||||
ansible.builtin.pip:
|
ansible.builtin.pip:
|
||||||
name:
|
name:
|
||||||
|
|
@ -93,6 +108,22 @@
|
||||||
delay: 6
|
delay: 6
|
||||||
until: serveur_ops_pip is succeeded
|
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).
|
# 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
|
# Ansible vit dans un venv isole — c'est voulu, il est epingle. Mais rien ne le mettait sur
|
||||||
|
|
@ -233,13 +264,50 @@
|
||||||
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
|
# 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
|
# 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.
|
# 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)
|
- name: Installer les collections depuis le depot local (hors ligne)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: >-
|
cmd: >-
|
||||||
{{ serveur_ops_venv }}/bin/ansible-galaxy collection install
|
{{ serveur_ops_venv }}/bin/ansible-galaxy collection install
|
||||||
-r requirements.yml
|
-r requirements.yml --force
|
||||||
-p {{ serveur_ops_racine }}/.ansible/collections
|
-p {{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur')
|
||||||
|
| first).dest }}/.ansible/collections
|
||||||
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
|
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` :
|
# 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
|
# 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
|
# repertoire temoin existait deja. Le runner serait reste en retard sur le moteur, en
|
||||||
|
|
@ -254,6 +322,7 @@
|
||||||
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
|
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
|
||||||
when:
|
when:
|
||||||
- serveur_ops_depot_collections is changed
|
- serveur_ops_depot_collections is changed
|
||||||
|
or not serveur_ops_collections_en_place.stat.exists
|
||||||
- not ansible_check_mode
|
- not ansible_check_mode
|
||||||
|
|
||||||
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par
|
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,14 @@ def cle_regle(r: dict) -> str:
|
||||||
une regle a retirer, ce qui est exactement la verite.
|
une regle a retirer, ce qui est exactement la verite.
|
||||||
"""
|
"""
|
||||||
ports = ",".join(map(str, r["ports"] or ["*"]))
|
ports = ",".join(map(str, r["ports"] or ["*"]))
|
||||||
return (f"setops:{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
|
# 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']}:"
|
||||||
f"{r['source']}->{r['destination']}:{ports}")
|
f"{r['source']}->{r['destination']}:{ports}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -174,14 +181,29 @@ def _corps_regle(r: dict, k: str) -> dict:
|
||||||
# le champ `destination_not` d'OPNsense.
|
# le champ `destination_not` d'OPNsense.
|
||||||
dst = str(r["destination"])
|
dst = str(r["destination"])
|
||||||
nie = dst.startswith("!")
|
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 = {
|
corps = {
|
||||||
"enabled": "1",
|
"enabled": "1",
|
||||||
"sequence": "1",
|
"sequence": str(r.get("sequence") or "1"),
|
||||||
"action": "pass",
|
"action": str(r.get("action") or "pass"),
|
||||||
|
"log": "1" if r.get("journaliser") else "0",
|
||||||
"interface": r["interface"],
|
"interface": r["interface"],
|
||||||
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
|
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
|
||||||
"ipprotocol": "inet",
|
"ipprotocol": "inet",
|
||||||
"protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(),
|
# `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()),
|
||||||
"source_net": r["source"],
|
"source_net": r["source"],
|
||||||
"destination_net": dst[1:] if nie else dst,
|
"destination_net": dst[1:] if nie else dst,
|
||||||
"destination_not": "1" if nie else "0",
|
"destination_not": "1" if nie else "0",
|
||||||
|
|
|
||||||
119
scripts/attendre_materialisation.py
Normal file
119
scripts/attendre_materialisation.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/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:]))
|
||||||
|
|
@ -832,6 +832,50 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||||
"tenant": "SITE",
|
"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 :
|
# 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.
|
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
|
||||||
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
|
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
|
||||||
|
|
@ -1011,10 +1055,15 @@ def rendre(devis: dict) -> str:
|
||||||
# Dans OPNsense une regle est TOUJOURS `in` sur l'interface d'arrivee : c'est
|
# 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
|
# 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.
|
# 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(
|
out.append(
|
||||||
f"pass in on {rg['interface']:8} proto {rg['protocole']:3} "
|
f"{_act:4} in on {rg['interface']:8} proto {rg['protocole']:3} "
|
||||||
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
|
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
|
||||||
f"# {rg['role']} ({rg['chiffrement']})"
|
f"# {rg['role']} ({rg['chiffrement']}){_mut}"
|
||||||
)
|
)
|
||||||
out.append(f"# {rg['raison']}")
|
out.append(f"# {rg['raison']}")
|
||||||
sortants = sum(1 for r in devis["regles"] if r["sens"] == "out")
|
sortants = sum(1 for r in devis["regles"] if r["sens"] == "out")
|
||||||
|
|
|
||||||
|
|
@ -1396,6 +1396,214 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
|
||||||
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
|
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]:
|
def preuve_registre_flux_a_jour() -> tuple[bool, str]:
|
||||||
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI.
|
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI.
|
||||||
|
|
||||||
|
|
@ -1792,6 +2000,12 @@ PREUVES: list[dict] = [
|
||||||
"refs": [], "func": preuve_carte_dit_vrai},
|
"refs": [], "func": preuve_carte_dit_vrai},
|
||||||
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
|
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
|
||||||
"refs": [], "func": preuve_registre_flux_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": [],
|
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
|
||||||
"func": preuve_devis_frontiere_du_site},
|
"func": preuve_devis_frontiere_du_site},
|
||||||
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue