From 0854b2a93cd504916f673a84e9310ac06a14a145 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 2 Sep 2026 09:59:15 -0400 Subject: [PATCH] reconstruction : quatre defauts que seule une flotte rasee pouvait montrer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on --- CHANGELOG.md | 99 +++++++++++++++++++ docs/audit/preuve-2026-09-02.md | 92 +++++++++++++++++ docs/carte-set-ops.md | 2 +- playbooks/proxmox/cloner_vm_debian.yml | 46 ++++++++- roles/client_artefacts/tasks/main.yml | 47 ++++++--- roles/serveur_resolveur/defaults/main.yml | 16 +++ .../templates/setops.conf.j2 | 63 ++++++++++++ scripts/instancier.py | 81 +++++++++++++-- 8 files changed, 425 insertions(+), 21 deletions(-) create mode 100644 docs/audit/preuve-2026-09-02.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe7a34..b815204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-02.md b/docs/audit/preuve-2026-09-02.md new file mode 100644 index 0000000..e73214d --- /dev/null +++ b/docs/audit/preuve-2026-09-02.md @@ -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._ diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 322ec64..24165a0 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -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 | diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 7223562..e36a1c3 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -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: ` 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: ` 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: diff --git a/roles/client_artefacts/tasks/main.yml b/roles/client_artefacts/tasks/main.yml index e5da68c..76feee0 100644 --- a/roles/client_artefacts/tasks/main.yml +++ b/roles/client_artefacts/tasks/main.yml @@ -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 diff --git a/roles/serveur_resolveur/defaults/main.yml b/roles/serveur_resolveur/defaults/main.yml index 012e5d6..69d9081 100644 --- a/roles/serveur_resolveur/defaults/main.yml +++ b/roles/serveur_resolveur/defaults/main.yml @@ -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: [] diff --git a/roles/serveur_resolveur/templates/setops.conf.j2 b/roles/serveur_resolveur/templates/setops.conf.j2 index 3e27a1b..46873ab 100644 --- a/roles/serveur_resolveur/templates/setops.conf.j2 +++ b/roles/serveur_resolveur/templates/setops.conf.j2 @@ -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: diff --git a/scripts/instancier.py b/scripts/instancier.py index 84ed105..6307baa 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -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", {})