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 # 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 ## 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 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. # site quand l'inventaire ne dit rien.
VMID ?= VMID ?=
NOEUD_PROXMOX ?= 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 ?= FORMAT_DISQUE ?=
TAILLE_DISQUE ?= TAILLE_DISQUE ?=
DISQUE_PROXMOX ?= DISQUE_PROXMOX ?=

View file

@ -31,7 +31,7 @@
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 36 groupes (inventaire dechiffre et parse). | | 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. | | 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.) | | 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. | | 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. | | 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). | | 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 | | 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. | | 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. | | 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). | | 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)). | | 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). | | 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
- icingadb-redis - icingadb-redis
- postgresql-client # pour importer le schema vers la BD distante - 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_icinga2: "icinga2"
serveur_icinga_service_icingadb: "icingadb" 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 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") CHAMPS = ("machine", "bios")
@ -102,6 +103,26 @@ def main() -> int:
if reel != attendu: if reel != attendu:
ecarts.append(f"{champ} : declare « {attendu} », reel « {reel or 'absent'} »") 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": if not str(cfg.get("template", "")).strip() == "1":
ecarts.append("ce VMID n'est PAS un gabarit (`template: 1` absent) — le cloner " ecarts.append("ce VMID n'est PAS un gabarit (`template: 1` absent) — le cloner "
"marcherait, mais il derivera au premier demarrage") "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 :") print("\nECART — le gabarit ne porte pas ce que le SITE declare :")
for e in ecarts: for e in ecarts:
print(f" - {e}") print(f" - {e}")
print("\n NE PAS CONVERTIR la machine : changer son chipset apres coup casse les") # LE REMEDE DEPEND DE L'ECART, et ce n'est pas un detail de presentation : le
print(" noms d'interfaces et les chemins de disques. REFABRIQUER le gabarit.") # 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 return 1
print("\nConforme — le gabarit porte ce que le SITE declare de lui.") 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: 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 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 : 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 # 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. # elle-meme. Elle demande ici, plutot que d'ecrire une adresse qui vieillirait.
ap.add_argument("--gabarit", action="store_true", 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", ap.add_argument("--adresses", metavar="GROUPE",
help="adresses des machines du SITE portant ce groupe, une par ligne") help="adresses des machines du SITE portant ce groupe, une par ligne")
args = ap.parse_args() args = ap.parse_args()
@ -843,7 +843,7 @@ def main() -> None:
if not g: if not g:
return return
# `cle=valeur` par ligne : consommable par `eval` en shell comme par Ansible. # `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: if g.get(k) is not None:
print(f"{k}={g[k]}") print(f"{k}={g[k]}")
return return