reconstruction : quatre defauts que seule une flotte rasee pouvait montrer
Some checks are pending
verifier / verifier (push) Waiting to run

Chezlepro detruite (15 VM, disques compris) et refaite depuis le gabarit
minimal. 15/15 hotes, 0 echec ; make valider passe, test de restitution
compris. Aucun defaut ne venait de la flotte ni du gabarit.

1. Un avertissement n est pas un echec. Proxmox rend WARNINGS: n pour une
   tache ABOUTIE ; la garde n acceptait que OK et declarait perdues cinq VM
   clonees a 100 pourcent. Le message parlait d etat stopped — celui de la
   TACHE, pas de la VM.

2. client_artefacts se contredisait : son commentaire disait de degrader, son
   code arretait. L autorite monte en premier, donc avant le cache du
   locataire : aucun ordre ne pouvait satisfaire la garde.

3. harden-below-nxdomain etendait le NXDOMAIN signe de la racine pour le TLD
   internal a toute la zone du site, sans jamais interroger l autoritatif.
   Declencheur : toute question sur un nom absent sous internal, y compris la
   zone d un autre locataire. Le cache contenait la bonne reponse ET un
   message negatif ; c est le negatif qui etait servi.
   aggressive-nsec: no avait semble marcher — c est le redemarrage qui vidait
   le cache, pas le reglage.

4. Un locataire doit savoir a qui demander la zone de son hebergeur, sans quoi
   il ne peut plus nommer son depot de sauvegarde. La derivation prenait
   dns_amorcage pour le resolveur du site : faux chez Technolibre, dont
   l amorcage est 9.9.9.9. P03 l a attrape avant tout deploiement.

Au passage : instancier tentait encore le mot de passe unique d avant la
separation des voutes ; comparer echouait en exit 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-09-02 09:59:15 -04:00
parent 3ea0c95492
commit 0854b2a93c
8 changed files with 425 additions and 21 deletions

View file

@ -1,5 +1,104 @@
# CHANGELOG — Set-OPS
## 2026-09-02 — Reconstruction de Chezlepro depuis zero : quatre defauts que seule une flotte rasee pouvait montrer
**56 preuves. `make valider` : 0 echec sur 13 hotes, test de restitution compris.**
Les quinze VM de Chezlepro ont ete detruites, disques compris, puis refaites depuis le
gabarit minimal — `make reconstruire` : creation des VM, AC et DNS montes completement
d'abord, puis toute la flotte par couches. Resultat : **15/15 hotes, 0 echec, 0
injoignable**, et les neuf detenteurs d'etat redeposent sur le depot du site.
Aucun des quatre defauts rencontres ne venait de la flotte ni du gabarit. Tous venaient de
gardes ou de derivations que rien n'avait jamais eprouvees, faute d'avoir jamais tout
reconstruit.
### 1. Un avertissement n'est pas un echec
Cinq VM sur quinze declarees perdues. Le journal Proxmox disait pourtant
`transferred 16.0 GiB of 16.0 GiB (100.00%)`, suivi de :
can't deactivate LV 'vm-9006-disk-1': Logical volume in use.
WARN: volume deactivation failed
Proxmox desactive le volume DU GABARIT apres un clonage, et n'y arrive pas tant qu'un
autre clonage parallele s'en sert — la consequence NORMALE de cloner un meme gabarit en
parallele, ce que `flotte-creer` fait justement pour aller vite. La garde n'acceptait que
`exitstatus == 'OK'`, or Proxmox rend trois formes : `OK`, `WARNINGS: n` pour une tache
ABOUTIE qui signale quelque chose, et un texte d'erreur pour un vrai echec.
Son message trompait en plus : « etat *stopped* » designe l'etat de la TACHE (terminee),
pas celui de la VM. On cherche un probleme de demarrage qui n'existe pas. Le message le
dit desormais. Et l'avertissement est AFFICHE au lieu d'etre avale.
### 2. Une garde qui se contredisait elle-meme
`_amorcer-socle` monte l'autorite en premier, comme il se doit. `infra-pki-01` est donc la
premiere machine debout — a un moment ou le cache du locataire, qui vit DANS la flotte
qu'on reconstruit, n'existe pas encore. `client_artefacts` arretait tout la.
Le plus parlant : **le commentaire du role decrivait deja le bon comportement** — « en
laissant le plancher en place, elles restent servies par le site : degrade, mais debout,
et reparable par un deploiement ». Le code faisait une `assert` qui arretait. Or ce que la
garde protege, c'est le plancher d'amorcage : il suffit de NE PAS l'ecraser. Arreter en
plus ne protege rien et rend la reconstruction impossible — aucun ordre de deploiement ne
pouvait la satisfaire, la contradiction etait dans la garde.
Elle degrade desormais, et le journal le DIT. La convergence a fonctionne dans la meme
passe : les quinze machines ont fini sur le cache de leur ecosysteme.
### 3. La racine nie notre TLD, et Unbound etend ce « non » — la vraie cause
`internal.` n'est pas delegue dans la racine, qui est SIGNEE : elle rend une preuve
NXDOMAIN validee pour ce TLD. `harden-below-nxdomain` (actif par defaut) tient ce « non »
pour prouve et repond NXDOMAIN pour TOUT nom sous `internal.` DEPUIS SON CACHE — sans
jamais interroger la `stub-zone` ni la `forward-zone`.
Le declencheur : n'importe quelle question sur un nom inexistant sous `internal.`, y
compris la zone d'UN AUTRE ecosysteme, que ce resolveur ne sert pas et va donc chercher a
la racine. Sur un resolveur partage par plusieurs locataires, ca arrive en permanence.
**Pourquoi ca a pris deux jours.** La panne parait intermittente : au redemarrage le cache
est vide, tout fonctionne, on conclut que c'est regle. Une seule requete l'eteint ensuite
pour des heures. Pire, le cache contenait EN MEME TEMPS la bonne reponse et un message
negatif pour le meme nom — c'est le message qui etait servi. Il a fallu lire le cache.
Le remede a d'abord ete mal identifie : `aggressive-nsec: no` avait semble marcher, parce
que le REDEMARRAGE qu'il imposait vidait le cache. C'est le vidage qui soignait. Mesure
qui tranche, cache vide puis une requete empoisonnante :
harden-below-nxdomain: no seul -> repond
aggressive-nsec: no seul -> ne repond pas
Ce qu'on perd : une protection anti-usurpation qui suppose que la racine dit vrai sur nos
noms. Elle ne le peut pas — nos zones n'y sont pas.
### 4. Un locataire doit savoir a qui demander la zone de son hebergeur
Un locataire depend de services du SITE par leur NOM : cache, forge, depot de sauvegarde,
autorite. Tant que ses machines pointaient sur le resolveur du site, ca marchait — par
accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait cette
zone : `make valider` a echoue sur la RESTITUTION d'une sauvegarde. La sauvegarde etait
intacte ; c'est le chemin pour la NOMMER qui manquait.
D'ou `serveur_resolveur_zones_deleguees`, derive par `instancier`. **La premiere version
prenait `dns_amorcage` pour l'adresse du resolveur du site** — vrai chez Chezlepro, FAUX
chez Technolibre, dont l'amorcage pointe sur `9.9.9.9`. On aurait delegue la zone
souveraine de l'hebergeur a Quad9. **P03 l'a attrape avant tout deploiement**, en
comparant l'inventaire de CHAQUE instance a son plan. L'adresse vient desormais du plan du
site : la machine qui y porte `serveur_resolveur`.
Vide par defaut, et c'est le comportement d'un EMANCIPE : sans la carte de l'hebergeur, on
ne delegue plus rien, donc on ne nomme plus ses services. C'est ce qu'on veut CONSTATER
d'une emancipation, pas une panne a reparer.
### Au passage
`instancier` tentait encore `~/.config/setops-vault-pass`, le mot de passe UNIQUE d'avant
la separation des voutes du 2026-08-28 : `comparer` echouait en exit 4 sur un message qui
ne parlait que d'`ansible-inventory`. On ne pouvait donc plus voir ce qu'on changeait
avant de l'appliquer. Il derive desormais ses identites de `voutes.py`.
## 2026-09-01 — Le depot de sauvegarde du SITE : l'etat d'un locataire quitte enfin sa propre flotte
**56 preuves.** Chezlepro rangeait ses instantanes sur `backup-01`, une VM DE SA PROPRE

View file

@ -0,0 +1,92 @@
# Preuve de conformite — Set-OPS — 2026-09-02
> 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 (56 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 : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 98 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ 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 non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 12 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), 51 groupe(s), 81 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 | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 54 scripts expliques et atteignables, 108 cibles make documentees, 64 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (30 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 : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 50 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 : 6 machine(s) du plan retrouvees, 80 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 (116 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 145 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 15 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 15. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-02._

View file

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

View file

@ -371,12 +371,48 @@
- not proxmox_clone_deja_la
- proxmox_clone_lance.json.data is defined
# UN AVERTISSEMENT N'EST PAS UN ECHEC — ET IL NE DOIT PAS DISPARAITRE NON PLUS.
#
# Proxmox rend trois formes d'`exitstatus` : `OK`, `WARNINGS: <n>` pour une tache
# QUI A ABOUTI en signalant quelque chose, et un texte d'erreur pour un vrai echec.
# La garde n'acceptait que `OK` : elle declarait donc perdus des clonages termines.
#
# MESURE DU 2026-09-01, RECONSTRUCTION DE CHEZLEPRO. Cinq VM sur quinze refusees,
# toutes avec `WARNINGS: 1`. Le journal de la tache disait :
#
# transferred 16.0 GiB of 16.0 GiB (100.00%)
# can't deactivate LV 'vm-9006-disk-1': Logical volume in use.
# WARN: volume deactivation failed
#
# Le disque etait transfere INTEGRALEMENT. Proxmox essaie ensuite de desactiver le
# volume DU GABARIT, et n'y parvient pas tant qu'un autre clonage parallele s'en
# sert. C'est la consequence NORMALE de cloner un meme gabarit en parallele — la
# condition meme que `flotte-creer` recherche pour aller vite.
#
# Ce que ca coutait : la reconstruction s'arretait sur cinq VM parfaitement clonees,
# et le message parlait d'« etat stopped » — l'etat de la TACHE, qui signifie
# « terminee ». On lit « la VM est arretee » et on cherche un probleme de demarrage
# qui n'existe pas. Le message dit desormais de quoi il parle.
- name: Le clonage a-t-il signale quelque chose en aboutissant ?
ansible.builtin.debug:
msg: >-
Clonage de {{ proxmox_clone_nom }} abouti AVEC AVERTISSEMENT
({{ proxmox_clone_tache.json.data.exitstatus }}). Le plus courant est
« volume deactivation failed » sur le disque du gabarit : un autre clonage
parallele s'en servait encore. Le journal complet de la tache est sur
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
when:
- not proxmox_clone_deja_la
- (proxmox_clone_tache.json.data.status | default('')) == 'stopped'
- (proxmox_clone_tache.json.data.exitstatus | default('')) is match('WARNINGS')
- name: Dire si le clonage ne s'est pas termine correctement
ansible.builtin.fail:
msg: >-
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?')
}} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
termine correctement : la TACHE est « {{ proxmox_clone_tache.json.data.status
| default('?') }} » (« stopped » = terminee, ce n'est pas l'etat de la VM) et sa
sortie est « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
Regarder la tache sur
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
when:
@ -386,9 +422,13 @@
# clonage inter-noeuds interroge sur le mauvais noeud — il n'y avait aucun
# `json.data`, donc aucun `exitstatus`, donc « OK » par defaut. On exige
# desormais d'avoir VU la tache s'arreter, et bien s'arreter.
# `OK` et `WARNINGS: <n>` disent tous deux que la tache a ABOUTI. Tout autre
# texte est un vrai echec, et l'absence de sortie signifie qu'on n'a jamais vu
# la tache s'arreter — le cas que la note ci-dessus decrit.
- >-
(proxmox_clone_tache.json.data.status | default('')) != 'stopped'
or (proxmox_clone_tache.json.data.exitstatus | default('')) != 'OK'
or not ((proxmox_clone_tache.json.data.exitstatus | default('')) == 'OK'
or (proxmox_clone_tache.json.data.exitstatus | default('')) is match('WARNINGS'))
- name: Dire pourquoi le clonage a echoue, s'il a echoue
ansible.builtin.fail:

View file

@ -41,19 +41,39 @@
- client_artefacts_actif | bool
- not ansible_check_mode
- name: Refuser de detourner apt vers une source muette
ansible.builtin.assert:
that:
- client_artefacts_sonde is not failed
fail_msg: >-
{{ client_artefacts_hote }}:{{ client_artefacts_port }} ne repond pas. Diriger `apt`
vers elle retirerait a cette machine le cache d'amorcage du SITE, qui fonctionne —
et la flotte perdrait `apt` sans qu'un deploiement puisse la reparer.
Deployer `serveur_artefacts` sur {{ client_artefacts_hote }} d'abord.
success_msg: "Source d'artefacts {{ client_artefacts_hote }} joignable."
# DEGRADER, PAS ARRETER — le commentaire ci-dessus disait deja quoi faire, le code
# faisait autre chose (mesure du 2026-09-01, reconstruction de Chezlepro depuis zero).
#
# La garde etait une `assert` qui ARRETAIT le deploiement. Or ce qu'elle protege, c'est
# le plancher d'amorcage du site : il suffit de NE PAS l'ecraser. Arreter en plus, ca ne
# protege rien de plus, et ca rend la reconstruction depuis zero IMPOSSIBLE.
#
# CE QUE CA A COUTE : `_amorcer-socle` monte l'autorite en premier, comme il se doit.
# `infra-pki-01` est donc la premiere machine debout — a un moment ou le cache du
# locataire, qui vit DANS la flotte qu'on reconstruit, n'existe evidemment pas encore.
# La garde tirait sur la premiere machine de la premiere couche, et tout s'arretait la.
# Aucun ordre de deploiement ne pouvait la satisfaire : la contradiction etait dans la
# garde, pas dans le plan.
#
# Le comportement juste est la CONVERGENCE : tant que la source du locataire est muette,
# la machine reste servie par le site — degradee, debout, et reparable par le passage
# suivant. Quand `serveur_artefacts` sera monte sur son hote, le deploiement d'apres
# basculera apt de lui-meme.
#
# On le DIT, fort : une machine qui reste sur le cache du site alors qu'elle devrait
# etre passee a celui de son ecosysteme est un ecart qu'il faut pouvoir lire dans le
# journal, pas deviner.
- name: Rester sur le cache du SITE tant que celui de l'écosystème est muet
ansible.builtin.debug:
msg: >-
{{ client_artefacts_hote }}:{{ client_artefacts_port }} ne repond pas — apt N'EST
PAS detourne. Cette machine reste servie par le cache d'amorcage du SITE, qui
fonctionne : degrade, debout, reparable. La bascule se fera au deploiement suivant,
une fois `serveur_artefacts` monte sur {{ client_artefacts_hote }}.
when:
- client_artefacts_actif | bool
- not ansible_check_mode
- client_artefacts_sonde is failed
- name: Diriger apt vers la source de l'écosystème
ansible.builtin.copy:
@ -77,7 +97,12 @@
owner: root
group: root
mode: "0644"
when: client_artefacts_actif | bool
when:
- client_artefacts_actif | bool
# Ne jamais ecraser le plancher du site par une source qu'on n'a pas VU repondre.
# En `check_mode` la sonde ne tourne pas : `is failed` est alors faux, et on garde
# le comportement de simulation d'avant.
- not (client_artefacts_sonde is failed)
# L'écosystème qui RETIRE sa source d'artefacts doit voir ses hôtes revenir à l'amont,
# sans quoi ils resteraient braqués sur une machine disparue et n'installeraient plus

View file

@ -59,3 +59,19 @@ serveur_resolveur_zones_inverses: >-
# reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
# 2026-08-23 avait justement supprimé.
serveur_resolveur_transitaires: []
# LES ZONES QUI NE SONT PAS À NOUS, ET À QUI LES DEMANDER.
#
# Liste de `{nom, adresses}`. Un locataire y trouve la zone de son HÉBERGEUR : il dépend
# de ses services par leur NOM — cache d'artefacts, forge, dépôt de sauvegarde, autorité
# — et doit savoir à quel résolveur les demander.
#
# DÉRIVÉE, PAS DÉCLARÉE : `inventory_host.py` la construit depuis la carte de
# l'hébergeur (le symlink `underlay.yml`) et l'adresse d'amorçage DNS que le locataire
# connaît déjà. Deux déclarations du même fait finiraient par diverger.
#
# VIDE PAR DÉFAUT, ET C'EST LE COMPORTEMENT D'UN ÉMANCIPÉ : un écosystème dont la carte
# de l'hébergeur n'est plus montée ne délègue plus rien. Il ne peut alors plus nommer les
# services du site — ce qui est exactement ce qu'on veut constater d'une émancipation, et
# non une panne à réparer.
serveur_resolveur_zones_deleguees: []

View file

@ -11,6 +11,37 @@ server:
hide-version: yes
prefetch: yes
do-ip6: no
# LA RACINE PROUVE QUE NOTRE TLD N'EXISTE PAS, ET UNBOUND ETEND CE « NON »
# A TOUT CE QUI EST DESSOUS (mesure du 2026-09-02).
#
# `internal.` n'est pas delegue dans la racine, qui est SIGNEE : elle rend donc une
# preuve NXDOMAIN validee pour ce TLD. `harden-below-nxdomain` (actif par defaut)
# tient ce « non » pour prouve et repond NXDOMAIN pour TOUT nom sous `internal.`
# DEPUIS SON CACHE — sans jamais interroger la `stub-zone` ni la `forward-zone`
# declarees plus bas. La delegation etait correcte, l'autoritatif repondait juste, et
# pas une requete ne lui parvenait.
#
# CE QUI DECLENCHE L'EMPOISONNEMENT : n'importe quelle question sur un nom inexistant
# sous `internal.` — y compris la zone d'UN AUTRE ecosysteme, que ce resolveur ne
# sert pas et va donc chercher a la racine. Sur un resolveur partage par plusieurs
# locataires, ca arrive en permanence.
#
# POURQUOI LE DIAGNOSTIC A DEMANDE DEUX JOURS. La panne parait INTERMITTENTE : au
# redemarrage le cache est vide, tout fonctionne, on conclut que c'est regle. Puis
# une seule requete l'eteint pour des heures. Pire, le cache contenait EN MEME TEMPS
# la bonne reponse (`A 10.0.35.11`) et un message negatif pour le meme nom — c'est le
# message qui etait servi. Il a fallu lire le cache pour le voir.
#
# MESURE QUI TRANCHE (trois essais, cache vide puis une requete empoisonnante) :
# harden-below-nxdomain: no seul -> repond 10.0.35.11
# aggressive-nsec: no seul -> ne repond pas
# Le second avait ete essaye d'abord et avait semble marcher : le REDEMARRAGE qu'il
# imposait vidait le cache. C'est le vidage qui soignait, pas le reglage.
#
# Ce qu'on perd : une protection anti-usurpation qui suppose que la racine dit vrai
# sur nos noms. Elle ne le peut pas — nos zones ne sont pas dans la racine. Ce qu'on
# garde : la validation DNSSEC complete de tout ce qui, lui, y est delegue.
harden-below-nxdomain: no
# La zone souveraine n'est pas signée : on ne la soumet pas à la validation DNSSEC.
domain-insecure: "{{ serveur_resolveur_zone_interne }}"
# LA RACINE NIE NOTRE TLD, ET UNBOUND ÉTEND CE NON À TOUT CE QUI EST DESSOUS
@ -69,6 +100,26 @@ server:
# flotte, si bien qu'un `getent hosts forge.genese.internal` semblait prouver que la
# résolution marchait. Seul un `dig` explicite l'a montrée.
do-not-query-localhost: no
{% for d in serveur_resolveur_zones_deleguees | default([]) %}
# LA ZONE D'UN AUTRE — CELLE DE L'HÉBERGEUR (2026-09-02).
#
# Un locataire dépend de services du SITE par leur NOM : son cache d'artefacts, sa
# forge, son dépôt de sauvegarde, son autorité. Ces noms vivent dans une zone que ce
# résolveur ne sert pas et n'a pas à servir — il doit savoir À QUI les demander.
#
# CE QUE SON ABSENCE A COÛTÉ : tant que les machines du locataire pointaient
# directement sur le résolveur du site, ça marchait — par accident. Reconstruites
# proprement, elles utilisent LEUR résolveur, qui ignorait la zone de l'hébergeur :
# la recette de validation a échoué sur la RESTITUTION d'une sauvegarde, avec
# « Could not resolve hostname sauvegarde.genese.internal ». La sauvegarde était
# intacte ; c'est le chemin pour la nommer qui manquait.
#
# Mêmes deux lignes que pour notre propre zone, même cause : la racine rend un
# NXDOMAIN signé pour le TLD, et `harden-below-nxdomain` l'étendrait à toute la zone
# sans jamais interroger le transitaire déclaré plus bas.
domain-insecure: "{{ d.nom }}"
local-zone: "{{ d.nom }}." transparent
{% endfor %}
stub-zone:
name: "{{ serveur_resolveur_zone_interne }}"
@ -83,6 +134,18 @@ stub-zone:
name: "{{ zone_inverse }}"
stub-addr: {{ serveur_resolveur_autoritatif }}@{{ serveur_resolveur_autoritatif_port }}
{% endfor %}
{% for d in serveur_resolveur_zones_deleguees | default([]) %}
{# `forward-zone` ET NON `stub-zone` : on s'adresse au RÉSOLVEUR de l'hébergeur, qui
recursera pour nous, et non à son autoritatif — que rien ne nous autorise à joindre
et dont l'adresse ne nous regarde pas. Le locataire connaît une seule adresse du
site en matière de noms : celle par laquelle il a été amorcé. #}
forward-zone:
name: "{{ d.nom }}"
{% for a in d.adresses %}
forward-addr: {{ a }}
{% endfor %}
{% endfor %}
{% if serveur_resolveur_transitaires %}
forward-zone:

View file

@ -85,6 +85,54 @@ def _domaine_interne() -> str:
def _zones_deleguees() -> list:
"""La zone de l'HEBERGEUR, et l'adresse a qui la demander. [] si aucune carte.
POURQUOI CETTE DERIVATION EXISTE (mesure du 2026-09-02, reconstruction de Chezlepro
depuis zero). Un locataire depend de services du SITE par leur NOM : son cache
d'artefacts, sa forge, son depot de sauvegarde, son autorite. Ces noms vivent dans une
zone que SON resolveur ne sert pas.
Tant que ses machines pointaient directement sur le resolveur du site, ca marchait —
par accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait
la zone de l'hebergeur : la recette a echoue sur la RESTITUTION d'une sauvegarde, avec
« Could not resolve hostname sauvegarde.genese.internal ». La sauvegarde etait
intacte ; c'est le chemin pour la NOMMER qui manquait.
DERIVEE DU PLAN DU SITE, PAS DE `dns_amorcage` — et la nuance a failli couter cher.
La premiere version prenait `dns_amorcage` pour « l'adresse du resolveur du site ».
C'est vrai chez Chezlepro ; c'est FAUX chez Technolibre, dont l'amorcage pointe sur
`9.9.9.9,149.112.112.112`. On aurait delegue la zone souveraine de l'hebergeur a
Quad9, qui n'en sait rien — et le NXDOMAIN rendu aurait ressemble a une zone vide.
P03 l'a attrape avant tout deploiement, en comparant l'inventaire de CHAQUE instance
a son plan.
#
L'adresse vient donc du PLAN DU SITE : la machine qui y porte `serveur_resolveur`.
C'est la seule source qui dise, par construction, qui resout la zone du site.
REND [] QUAND LA CARTE N'EST PAS MONTEE, et c'est le comportement d'un EMANCIPE : il
ne delegue plus rien, donc ne peut plus nommer les services du site. C'est ce qu'on
veut CONSTATER d'une emancipation, pas une panne a reparer.
"""
try:
import underlay as underlay_mod
zone = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("domaine_interne") or "").strip()
except Exception:
return []
# Un site qui s'heberge lui-meme partage la zone de son instance : il n'y a alors
# rien a deleguer, et se declarer transitaire de soi-meme ferait une boucle.
if not zone or zone == _domaine_interne():
return []
try:
adresses = [a for a in underlay_mod.adresses_site_portant("serveur_resolveur") if a]
except Exception:
adresses = []
if not adresses:
return []
return [{"nom": zone, "adresses": adresses}]
def _attributs_cible(vers: str, apps: dict, serveurs: dict, nomenclature: dict, domaine: str) -> dict | None:
"""Resout une cible de lien en attributs substituables. Phase 1 : applications."""
if vers in apps:
@ -213,6 +261,7 @@ def generer() -> dict:
+ "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction)
+ f"Fonctions connues de ce plan : {connues}")
zones_deleguees = _zones_deleguees()
children: dict = {
"modeles_vm": {"hosts": {}},
"hotes_actifs": {"hosts": {}},
@ -261,6 +310,9 @@ def generer() -> dict:
sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes})
if sans:
hostvars["sans_exposition"] = sans
# Le resolveur de l'ecosysteme doit savoir a qui demander la zone de l'hebergeur.
if "serveur_resolveur" in groupes and zones_deleguees:
hostvars["serveur_resolveur_zones_deleguees"] = zones_deleguees
etat = "hotes_actifs" if srv.get("etat") == "actif" else "hotes_planifies"
children[etat]["hosts"][nom] = hostvars
for groupe in sorted(groupes):
@ -280,12 +332,29 @@ def ecrire(path: Path = GENERE) -> None:
def _resolu(fichier: Path) -> tuple[dict, dict]:
cmd = ["ansible-inventory", "-i", str(fichier), "--list"]
# Une voute chiffree (group_vars/all/vault.yml) fait echouer ansible-inventory sans
# mot de passe (exit 4). Si ANSIBLE_VAULT_PASSWORD_FILE n'est pas deja fourni, on
# tente le fichier conventionnel Set-OPS ~/.config/setops-vault-pass.
if not os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"):
conv = Path.home() / ".config" / "setops-vault-pass"
if conv.is_file():
cmd += ["--vault-password-file", str(conv)]
# mot de passe (exit 4).
#
# UNE VOUTE, UNE CLE (depuis le 2026-08-28). Ce bloc tentait
# `~/.config/setops-vault-pass` — le mot de passe UNIQUE d'avant la separation, qui
# n'ouvre plus rien. `instancier comparer` echouait donc en exit 4 sur un message qui
# ne parlait que d'`ansible-inventory` : on ne pouvait plus relire l'inventaire en
# place pour le comparer au genere, donc plus l'appliquer sans `--force`, donc plus
# voir ce qu'on changeait.
#
# `voutes.py identites` rend la liste `etiquette@chemin` de toutes les voutes
# connues ; Ansible essaie chacune et retient celle qui ouvre. On ne pose rien si
# l'appelant a deja renseigne l'environnement : sa valeur est plus precise que la
# notre.
if not os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") \
and not os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"):
try:
ident = subprocess.run(
[sys.executable, str(Path(__file__).parent / "voutes.py"), "identites"],
capture_output=True, text=True, check=True).stdout.strip()
except (subprocess.CalledProcessError, OSError):
ident = ""
if ident:
os.environ["ANSIBLE_VAULT_IDENTITY_LIST"] = ident
sortie = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
data = json.loads(sortie)
hostvars = data.get("_meta", {}).get("hostvars", {})