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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-10 13:06:45 -04:00
parent 84b5ecc71d
commit 4ef3a1d553
6 changed files with 184 additions and 9 deletions

View file

@ -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

View file

@ -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 ?=

View file

@ -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). |

View file

@ -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"

View file

@ -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.")

View file

@ -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