From 4ef3a1d5539311c1934ee94b4045e7a751777cf1 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 10 Sep 2026 13:06:45 -0400 Subject: [PATCH] D-86 mis a l epreuve : Chezlepro rasee et refaite depuis zero Quatorze machines detruites, quatorze refaites. 1 h 08, 0 injoignable. D-86 TIENT, et la mesure le dit : 30 resultats de controle recus a 12:20:41 alors que la reconstruction ne s est terminee qu a 12:35:29. Trente services rapportaient leur etat pendant que Keycloak, Forgejo et Nextcloud montaient encore. Sa limite, dite franchement : D-86 affirme que le DNS peut rester en couche 6 grace au plancher /etc/hosts. Or _amorcer-socle monte infra-dns-01 EN ENTIER d abord. Le DNS etait debout, le plancher n a rien eu a porter, et la partie la plus audacieuse de la decision reste non testee. LE PLACEMENT DES VM N ETAIT DECLARE NULLE PART. Les quatorze vivaient sur asgard depuis toujours, mais SETOPS_NOEUD valait le vide : ce placement n existait que dans l etat d execution de Proxmox. Sans cible, le clone reste sur le noeud du gabarit - vishnu, qui a 14 Go libres et heberge eregion et site-forge-01. Le defaut avait survecu a la reconstruction du 2026-09-02 parce que les VM existaient deja et que le clone les sautait. Il faut un from-zero VRAI pour voir ce genre de chose. LE CLONAGE ETAIT 45 FOIS TROP LENT, et pas a cause du reseau : source et destination etaient sur le meme stockage. C est le LVM epais qui interdit a Proxmox tout clone autre que complet. Gabarit deplace sur CephNVMe, mesure 480 s -> 8 s. On garde les clones complets : le clone lie descend a 1 s mais enchaine chaque VM a l image de base pour toujours. Un champ stockage: entre au gabarit, branche en quatre points - declare, expose, consomme par le Makefile, garde par gabarit_etat. Declarer sans consommer, c est decorer. ICINGA NE POUVAIT PAS FAIRE SES PROPRES CONTROLES : icinga2 n apporte aucun greffon, et la supervision etant passive, le manque etait masque. Quatorze ping4 muets d une seule cause. monitoring-plugins-basic entre au role. Le site l avait deja - pose A LA MAIN, jamais declare : troisieme occurrence du jour d un etat qui ne survivrait pas a une reconstruction. Trois defauts laisses ouverts : le cache apt attendu pendant l amorcage, la regression D-85 sur /etc/cloud - qui a fait decrocher deux hotes de la tache deposant icinga-ca.crt, rendant leurs sondes muettes en silence - et auditd sans regles dans le gabarit. Mesure finale : Icinga 93 OK sur 98, site 51/51, Prometheus 15/15, prouver 64 OK, lint 0 defaut. Les quatre non-OK restants sont des sauvegarde dont le minuteur nocturne n a pas encore visite une flotte nee il y a deux heures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- CHANGELOG.md | 111 +++++++++++++++++++++++++ Makefile | 12 ++- docs/audit/preuve-2026-09-10.md | 4 +- roles/serveur_icinga/defaults/main.yml | 23 +++++ scripts/gabarit_etat.py | 37 ++++++++- scripts/underlay.py | 6 +- 6 files changed, 184 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d70aeee..50dd00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,116 @@ # CHANGELOG — Set-OPS +## 2026-09-10 (7) — D-86 mis a l'epreuve : Chezlepro rasee et refaite depuis zero + +Quatorze machines detruites, quatorze refaites. **1 h 08**, 0 injoignable. La reconstruction +a confirme D-86 — et revele quatre defauts qu'aucune preuve statique n'aurait pu voir, +parce qu'ils n'existent QUE pendant une naissance. + +### D-86 tient, et voici la mesure + +L'ordre declare est l'ordre reel : socle → `step_ca` → `client_pki` → **observabilite** +(postgresql, prometheus, loki, grafana, icinga) → **agents** (metrique, journal, sante) → +services → apps. + +Mais l'ordre ne prouve pas que quelqu'un REGARDAIT. Ce qui le prouve : + + 30 resultats de controle recus a 12:20:41 + fin de la reconstruction 12:35:29 + +Trente services rapportaient leur etat pendant que Keycloak, Forgejo et Nextcloud montaient +encore. Prometheus scrutait 14 cibles sur 15. **Ce qui se deploie ensuite l'est bien sous +l'oeil de la supervision.** + +### La limite, dite franchement : le plancher n'a pas ete eprouve + +D-86 affirme que le DNS peut rester en couche 6 *grace au plancher `/etc/hosts`*. Ce n'est +pas ce qui s'est passe : `_amorcer-socle` monte `infra-dns-01` EN ENTIER d'abord — +`serveur_powerdns` puis `serveur_resolveur`, bien avant l'observabilite. Le DNS etait +debout ; le plancher n'a rien eu a porter. **La partie la plus audacieuse de la decision +reste non testee**, et l'eprouver demanderait de retirer l'amorcage DNS — une autre +decision. + +### Le placement des VM n'etait declare NULLE PART + +Les quatorze machines vivaient sur `asgard` depuis toujours. `SETOPS_NOEUD` valait le +VIDE : ce placement n'existait que dans l'etat d'execution de Proxmox. Sans cible, le clone +reste sur le noeud du gabarit — `vishnu`, qui a **14 Go libres** et heberge `eregion` et +`site-forge-01`. Quarante Go de VM neuves y auraient atterri. + +Le defaut avait survecu a la reconstruction du 2026-09-02 : les VM existaient deja, et le +clone les sautait. **Un etat qui n'est declare nulle part ne se reproduit pas** — il faut +un `from-zero` VRAI pour le voir. `noeud: asgard` est desormais au plan. + +### Le clonage etait 45 fois trop lent, et la cause n'etait pas le reseau + +Mesure sur le vrai gabarit : + + disque sur `TrueNAS` (lvm sur iSCSI) 352 a 480 s par clone + disque sur `CephNVMe` (rbd) 8 a 9 s par clone + +Source ET destination etaient sur le meme stockage : rien ne traversait d'hyperviseur a +hyperviseur. La cause est que **le LVM epais interdit a Proxmox tout clone autre que +complet** — 16 Go copies par machine, a travers iSCSI. + +Le clone LIE descend a 1 s, mais enchaine chaque VM a l'image de base pour toujours. Pour +7 s gagnees sur 480, l'echange ne vaut pas la dependance : **on garde les clones complets.** + +Gabarit deplace par `qm move_disk` sans `--delete` — les anciens disques restent attaches +en `unused`, le retour arriere tient en une commande. + +### Un champ `stockage:` au gabarit, branche en quatre points + +Declarer sans consommer, c'est decorer. Le champ vit donc partout ou il compte : + +| ou | quoi | +|---|---| +| `plan/10-intrants.yml` | la declaration, avec la mesure qui la justifie | +| `underlay.py --gabarit` | l'expose | +| `Makefile` | `STOCKAGE_PROXMOX` en derive — le clone ne l'HERITE plus en silence | +| `gabarit_etat.py` | compare le declare au reel, eprouve dans les deux sens | + +Et le remede devient contextuel : le conseil *« NE PAS CONVERTIR… REFABRIQUER le gabarit »* +s'imprimait pour TOUT ecart, y compris un deplacement de disque qui se repare en une +commande. *Un remede plus lourd que le mal se fait ignorer, puis le vrai avec lui.* + +### Icinga ne pouvait pas faire ses propres controles + + ping4 UNKNOWN execvpe(/usr/lib/nagios/plugins/check_ping) failed: No such file + +`icinga2` n'apporte AUCUN greffon. Toute la supervision de Set-OPS etant PASSIVE, le manque +etait masque : les services qui rapportent d'eux-memes etaient verts, et personne ne +regardait les autres. Quatorze `ping4` muets d'une seule cause. + +`monitoring-plugins-basic` entre au role — `-basic` et non le metapaquet : `-standard` +ajoute des greffons LDAP/SMTP/PostgreSQL que Set-OPS n'appelle jamais. + +**Le site l'avait deja — pose A LA MAIN plus tot dans la journee, jamais declare.** Meme +defaut que le placement, troisieme occurrence du jour : un etat qui ne vit que sur la +machine ne survit pas a une reconstruction. + +### Trois defauts trouves, laisses ouverts + +1. **`infra-pki-01` et `infra-dns-01` attendent `forge-01:3142`** pendant l'amorçage — le + cache apt naitra bien plus tard. Un ordre qui se mord la queue. +2. **Regression D-85** : `Destination directory /etc/cloud/templates does not exist`. Un + role ecrit encore la ou cloud-init a ete supprime. **C'est ce defaut qui a fait + decrocher deux hotes de la tache deposant `icinga-ca.crt`** — leur porteur de sante + tournait, et chaque rapport echouait sur `curl: (77)`, en silence. +3. **`auditd` dans le gabarit, sans regles** : `audit-rules.service` echoue au premier + demarrage de CHAQUE VM neuve, jusqu'au passage du socle. Le CRITICAL ressemble alors a + un probleme de la machine alors qu'il vient du modele. + +### Etat mesure, apres correction + + Icinga Chezlepro 93 OK | 1 WARNING | 1 CRITICAL | 3 UNKNOWN sur 98 + Icinga du site 51 / 51 OK + Prometheus 15 / 15 cibles + prouver 64 OK, 0 echec + ansible-lint 0 defaut, profil production + +Les quatre non-OK restants sont tous des `sauvegarde` : le minuteur nocturne n'a pas encore +visite une flotte nee il y a deux heures. + ## 2026-09-10 (6) — La frontiere passe, et trois sondes disaient faux Application de ce que l'entree precedente avait prepare, puis correction de ce que diff --git a/Makefile b/Makefile index 1c3d6e0..48f4b05 100644 --- a/Makefile +++ b/Makefile @@ -86,7 +86,17 @@ VMID_MODELE ?= $(shell python3 scripts/underlay.py --gabarit 2>/dev/null | sed - # site quand l'inventaire ne dit rien. VMID ?= NOEUD_PROXMOX ?= -STOCKAGE_PROXMOX ?= +# LE STOCKAGE DU CLONE SUIT CELUI DU GABARIT, ET IL EST DECLARE (2026-09-10). +# +# Vide, `qm clone` heritait SILENCIEUSEMENT du stockage de la source. Ca marchait, et +# c'est bien la le probleme : quand le gabarit a change de stockage, la destination a +# suivi sans qu'aucun fichier ne le dise. Le site le declare desormais +# (`gabarit.stockage`), et cette ligne le rend a la commande de clonage. +# +# `SETOPS_STOCKAGE` — le `stockage:` d'une machine au plan du tenant — garde la priorite +# (voir `creer-vm`) : un ecosysteme peut poser une machine ailleurs, ce qui n'est pas la +# meme question que « d'ou nait la flotte ». +STOCKAGE_PROXMOX ?= $(shell python3 scripts/underlay.py --gabarit 2>/dev/null | sed -n 's/^stockage=//p') FORMAT_DISQUE ?= TAILLE_DISQUE ?= DISQUE_PROXMOX ?= diff --git a/docs/audit/preuve-2026-09-10.md b/docs/audit/preuve-2026-09-10.md index 7d255dd..eb14714 100644 --- a/docs/audit/preuve-2026-09-10.md +++ b/docs/audit/preuve-2026-09-10.md @@ -31,7 +31,7 @@ | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 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. | +| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 29 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). | @@ -44,7 +44,7 @@ | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 67 roles avec README. | -| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 39 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 39 exigence(s) de role, toutes satisfaites (132 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 | 44 document(s) declarent leur lecteur (36 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). | diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index 5ad0a57..5b75ac8 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -4,6 +4,29 @@ serveur_icinga_paquets: - icingadb - icingadb-redis - postgresql-client # pour importer le schema vers la BD distante + # LES GREFFONS DE CONTROLE, SANS LESQUELS LE TEMOIN NE VOIT RIEN DE LUI-MEME. + # + # `icinga2` n'apporte AUCUN greffon : le paquet ne pose que `check_nscp_api`. Toute la + # supervision de Set-OPS est PASSIVE — chaque noeud pousse son propre resultat — et + # cette architecture a masque le manque : les 53 services qui rapportent d'eux-memes + # etaient verts, et personne ne regardait les autres. + # + # CE QUE CA A COUTE (reconstruction du 2026-09-10) : les quatorze services `ping4` + # rendaient UNKNOWN avec + # + # execvpe(/usr/lib/nagios/plugins/check_ping) failed: No such file or directory + # + # Or `hostalive` est un ping. Un hote qu'Icinga ne peut pas joindre n'est pas DOWN, + # il est UNKNOWN — et Icinga ne supprime pas les notifications d'un hote UNKNOWN comme + # il le fait d'un hote DOWN. Le defaut est donc moins grave qu'une extinction, mais il + # rend la colonne « l'hote repond-il ? » definitivement muette. + # + # `-basic` ET NON LE METAPAQUET : il porte `check_ping`, `check_disk`, `check_http`, + # `check_load` — ce qu'un temoin verifie lui-meme. `-standard` ajoute des greffons + # applicatifs (LDAP, SMTP, PostgreSQL) que Set-OPS ne consomme pas : chaque noeud + # mesure SES services et les rapporte. Installer ce qu'on n'appelle jamais, c'est + # elargir la surface pour rien. + - monitoring-plugins-basic serveur_icinga_service_icinga2: "icinga2" serveur_icinga_service_icingadb: "icingadb" diff --git a/scripts/gabarit_etat.py b/scripts/gabarit_etat.py index 53e4300..3a68dd9 100755 --- a/scripts/gabarit_etat.py +++ b/scripts/gabarit_etat.py @@ -34,7 +34,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import underlay as underlay_mod # noqa: E402 -# Ce que l'on compare. `qm config` les rend en `cle: valeur`. +# Ce que l'on compare EN `cle: valeur`. Le `stockage` se traite a part : il est +# enfoui dans la ligne du disque, pas rendu comme une cle a lui. CHAMPS = ("machine", "bios") @@ -102,6 +103,26 @@ def main() -> int: if reel != attendu: ecarts.append(f"{champ} : declare « {attendu} », reel « {reel or 'absent'} »") + # LE STOCKAGE NE SE LIT PAS EN `cle: valeur` — il est ENFOUI dans la ligne du disque : + # + # scsi0: CephNVMe:base-9006-disk-0,discard=on,iothread=1,size=16G,ssd=1 + # ^^^^^^^^ + # + # On le compare quand meme, parce qu'il decide du temps de reproduction de tout + # ecosysteme : mesure du 2026-09-10, 8 s par clone sur `CephNVMe` contre 352 a 480 s + # sur `TrueNAS`. Un gabarit qui glisse en arriere ne casse rien — il rend simplement + # chaque reconstruction quarante-cinq fois plus lente, sans qu'aucune erreur ne le dise. + attendu_stockage = str(g.get("stockage", "")).strip() + if attendu_stockage: + disque = str(cfg.get("scsi0") or cfg.get("virtio0") or "") + reel_stockage = disque.split(":", 1)[0].strip() if ":" in disque else "" + etat = "=" if reel_stockage == attendu_stockage else "≠" + print(f" {'stockage':<10} declare {attendu_stockage:<12} " + f"reel {reel_stockage or '(absent)':<12} {etat}") + if reel_stockage != attendu_stockage: + ecarts.append(f"stockage : declare « {attendu_stockage} », reel " + f"« {reel_stockage or 'absent'} » — chaque clone en paiera le prix") + if not str(cfg.get("template", "")).strip() == "1": ecarts.append("ce VMID n'est PAS un gabarit (`template: 1` absent) — le cloner " "marcherait, mais il derivera au premier demarrage") @@ -110,8 +131,18 @@ def main() -> int: print("\nECART — le gabarit ne porte pas ce que le SITE declare :") for e in ecarts: print(f" - {e}") - print("\n NE PAS CONVERTIR la machine : changer son chipset apres coup casse les") - print(" noms d'interfaces et les chemins de disques. REFABRIQUER le gabarit.") + # LE REMEDE DEPEND DE L'ECART, et ce n'est pas un detail de presentation : le + # conseil « refabriquer le gabarit » etait imprime pour TOUT ecart, y compris un + # simple deplacement de disque qui se repare en une commande sans rien refaire. + # Un remede plus lourd que le mal se fait ignorer, puis le vrai avec lui. + if any(e.startswith(("machine", "bios")) for e in ecarts): + print("\n NE PAS CONVERTIR la machine : changer son chipset apres coup casse les") + print(" noms d'interfaces et les chemins de disques. REFABRIQUER le gabarit.") + if any(e.startswith("stockage") for e in ecarts): + print(f"\n Le stockage, lui, se corrige SANS refabriquer :") + print(f" qm move_disk {vmid} scsi0 {attendu_stockage}") + print(" Sans `--delete`, l'ancien disque reste attache en `unused` — le") + print(" retour arriere tient en une commande.") return 1 print("\nConforme — le gabarit porte ce que le SITE declare de lui.") diff --git a/scripts/underlay.py b/scripts/underlay.py index a790bd2..c0fb632 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -142,7 +142,7 @@ def services_site() -> dict[str, list[str]]: def gabarit() -> dict: - """Le gabarit dore, tel que le SITE le declare — VMID, nom, noeud. + """Le gabarit dore, tel que le SITE le declare — VMID, nom, noeud, stockage. LE GABARIT EST UN ARTEFACT DU SITE (2026-09-01). C'est de lui que descend chaque VM de chaque tenant ; son identite ne peut donc pas etre decidee par un tenant. Elle l'etait : @@ -834,7 +834,7 @@ def main() -> None: # sur un inventaire d'un seul hote — sans `group_vars`, donc sans rien deriver # elle-meme. Elle demande ici, plutot que d'ecrire une adresse qui vieillirait. ap.add_argument("--gabarit", action="store_true", - help="le gabarit dore declare par le SITE (vmid, nom, noeud)") + help="le gabarit dore declare par le SITE (vmid, nom, noeud, stockage)") ap.add_argument("--adresses", metavar="GROUPE", help="adresses des machines du SITE portant ce groupe, une par ligne") args = ap.parse_args() @@ -843,7 +843,7 @@ def main() -> None: if not g: return # `cle=valeur` par ligne : consommable par `eval` en shell comme par Ansible. - for k in ("vmid", "nom", "noeud", "machine", "bios"): + for k in ("vmid", "nom", "noeud", "machine", "bios", "stockage"): if g.get(k) is not None: print(f"{k}={g[k]}") return