P23 : la bande basse de D-77 devient une regle outillee

D-77 disait ou l'underlay doit vivre ; rien ne le verifiait. Une convention qu'on
n'outille pas pourrit en silence (D-70) — c'est ce qui a laisse la sauvegarde vide
pendant un mois.

Le controle disait l'INVERSE de la decision : underlay.py refusait tout
chevauchement avec un supernet tenant. Rendu plus FIN, pas plus strict :

  son propre supernet, bande basse   -> conforme (la regle)
  son propre supernet, bande haute   -> REFUS (collision avec ses zones)
  supernet d'un AUTRE site           -> REFUS (jamais reliables)
  hors de tout supernet              -> conforme (heritage 10.0.x, stockage 192.168.x)

La frontiere derive d'OCTET_ZONE, jamais ecrite en dur. Le site declare son `index`
dans underlay.yml ; sans lui on retombe sur la regle stricte d'avant D-77, sure —
Chezlepro reste donc conforme en 10.0.x tant qu'il n'a pas migre.

LE PIEGE, attrape par le test : un prefixe peut COMMENCER dans la bande basse et
deborder — 10.21.0.0/19 couvre les octets 0 a 31. La borne est evaluee sur toute
l'etendue du prefixe, pas sur son premier octet.

Deux de mes propres cas d'epreuve etaient mal choisis (10.21.14.0/23 et
10.21.12.0/21 se normalisent dans la bande basse) : il a fallu un prefixe qui
franchit reellement la frontiere pour eprouver la garde.

test_underlay_bande_basse.py : 7 cas, cable dans make test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-12 15:59:56 -04:00
parent 0c93569dd7
commit b7239149bb
5 changed files with 180 additions and 8 deletions

View file

@ -1,5 +1,39 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-12 — P23 outille D-77 : la bande basse devient une règle, pas une convention
D-77 disait où l'underlay doit vivre. Rien ne le vérifiait — et une convention qu'on
n'outille pas pourrit en silence (D-70). C'est exactement ce qui a laissé la sauvegarde
vide pendant un mois.
**Le contrôle disait l'inverse de la décision.** `underlay.py` refusait *tout*
chevauchement avec un supernet tenant. Il fallait le rendre plus **fin**, pas plus strict :
| Situation | Verdict |
|---|---|
| dans **son propre** supernet, bande basse | conforme — c'est la règle |
| dans son propre supernet, bande **haute** | **refusé** — collision avec ses propres zones |
| dans le supernet d'un **autre** site | **refusé** — les deux ne pourront jamais être reliés |
| hors de tout supernet (`10.0.x`, `192.168.x`) | conforme — héritage, et stockage |
La frontière est **dérivée** de `OCTET_ZONE`, jamais écrite en dur : déplacer la règle des
zones déplace la borne avec elle. Le site déclare son `index` dans `underlay.yml` ; sans
lui, on retombe sur la règle stricte d'avant D-77 — le comportement sûr pour un underlay
qui n'a pas encore migré. **Chezlepro reste donc conforme aujourd'hui**, en `10.0.x`.
### Le piège que le test attrape
Un préfixe peut **commencer** dans la bande basse et déborder : `10.21.0.0/19` couvre les
octets 0 à 31. Une vérification qui ne regarderait que le premier octet le laisserait
passer. La borne est donc évaluée sur **toute l'étendue** du préfixe.
Deux de mes propres cas d'épreuve étaient mal choisis — `10.21.14.0/23` et `10.21.12.0/21`
se normalisent entièrement dans la bande basse, et « conforme » y était la bonne réponse.
Il a fallu construire un préfixe qui franchit réellement la frontière pour éprouver la
garde.
`scripts/tests/test_underlay_bande_basse.py` — 7 cas, câblé dans `make test`.
## 2026-08-12 — `modeleSetOPS`, et une porte pour l'hébergeur ## 2026-08-12 — `modeleSetOPS`, et une porte pour l'hébergeur
### Le gabarit portait le nom du mauvais propriétaire ### Le gabarit portait le nom du mauvais propriétaire

View file

@ -211,6 +211,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_inventory_host.py python3 scripts/tests/test_inventory_host.py
python3 scripts/tests/test_raser.py python3 scripts/tests/test_raser.py
python3 scripts/tests/test_raser_resultat.py python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_underlay_bande_basse.py
.PHONY: verifier .PHONY: verifier
verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide) verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide)

View file

@ -117,7 +117,7 @@ sont les seules vérifiables.
| **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — | | **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — |
| **D-76** | Le **gabarit doit se FABRIQUER par le dépôt**, pas se façonner à la main — décidée le 2026-08-12, **volontairement différée** après Technolibre | tout dérive d'un plan et se prouve ; le gabarit est la **seule pièce faite à la main** — 853 lignes de procédure manuelle — et il est en amont des quatorze VM. Le 2026-08-09 l'a démontré : l'ancien portait une **clé privée d'hôte SSH** et un `/etc/resolv.conf` figé, recopiés dans chaque clone, et il a fallu le recapturer. La preuve de reconstruction s'arrête donc un cran trop tôt : on reconstruit la flotte, pas ce dont elle est clonée. **Cible** : image cloud Debian officielle → signature vérifiée contre une empreinte épinglée (`verifier_signature.py`, déjà écrit) → import, réglages, conversion. **Coût honnête** : la recette doit être COMPARÉE au gabarit courant avant bascule — un réglage oublié serait hérité par les quatorze VM, et découvert loin de sa cause. **En attendant**, un gabarit se transporte d'un cluster à l'autre par `vzdump` / `qmrestore` | `docs/procedure-template-debian13-proxmox.md`, `scripts/verifier_signature.py` | — *(à construire)* | | **D-76** | Le **gabarit doit se FABRIQUER par le dépôt**, pas se façonner à la main — décidée le 2026-08-12, **volontairement différée** après Technolibre | tout dérive d'un plan et se prouve ; le gabarit est la **seule pièce faite à la main** — 853 lignes de procédure manuelle — et il est en amont des quatorze VM. Le 2026-08-09 l'a démontré : l'ancien portait une **clé privée d'hôte SSH** et un `/etc/resolv.conf` figé, recopiés dans chaque clone, et il a fallu le recapturer. La preuve de reconstruction s'arrête donc un cran trop tôt : on reconstruit la flotte, pas ce dont elle est clonée. **Cible** : image cloud Debian officielle → signature vérifiée contre une empreinte épinglée (`verifier_signature.py`, déjà écrit) → import, réglages, conversion. **Coût honnête** : la recette doit être COMPARÉE au gabarit courant avant bascule — un réglage oublié serait hérité par les quatorze VM, et découvert loin de sa cause. **En attendant**, un gabarit se transporte d'un cluster à l'autre par `vzdump` / `qmrestore` | `docs/procedure-template-debian13-proxmox.md`, `scripts/verifier_signature.py` | — *(à construire)* |
| **D-77** | **L'underlay d'un site dérive du même `index` que son tenant**, dans la **bande basse** `10.(10+index).015.x` ; les réseaux de **stockage** en sortent et sont **identiques partout**, en `192.168.<vlan>.0/24` | tant qu'il n'existait qu'un site, `10.0.x.x` suffisait. Deux sites qui doivent se joindre — reprise mutuelle, sauvegardes croisées, exploitation à distance — **ne peuvent pas** porter les mêmes plages : chaque routeur croit que le réseau est chez lui. Un « numéro de site » a d'abord été proposé : **rejeté**, c'était un SECOND SEED à tenir et à synchroniser, alors que tout dérive déjà d'`index` (D-17). La bande basse est sûre **par la règle, pas par chance** : les zones valent `10.(10+index).(15+categorie).0/24`, donc troisième octet ≥ 16 — les octets 0 à 15 ne sont jamais alloués. Les troisièmes et derniers octets de l'underlay actuel sont ainsi **préservés** : seul le deuxième change, et l'invariant `.1` (D-04) survit intact. Le **stockage** en est sorti parce qu'il est jumbo, non routé, et ne quitte jamais son site : il n'a aucun besoin d'être unique, et l'aligner sur le VLAN (`192.168.20.x` ↔ VLAN 20) fait dire son VLAN à l'adresse. Contrepartie assumée : ces réseaux ne pourront jamais traverser un lien inter-sites — sans conséquence, puisque ce qui voyage entre deux sites est l'**état**, par le dépôt de sauvegarde, pas le stockage bloc | `underlay.yml`, `docs/preparer-un-site-hebergeur.md`, `scripts/inventory_rules.py` (`OCTET_ZONE`) | **P23** *(à étendre : borner le 3ᵉ octet sous `OCTET_ZONE + 1`)* | | **D-77** | **L'underlay d'un site dérive du même `index` que son tenant**, dans la **bande basse** `10.(10+index).015.x` ; les réseaux de **stockage** en sortent et sont **identiques partout**, en `192.168.<vlan>.0/24` | tant qu'il n'existait qu'un site, `10.0.x.x` suffisait. Deux sites qui doivent se joindre — reprise mutuelle, sauvegardes croisées, exploitation à distance — **ne peuvent pas** porter les mêmes plages : chaque routeur croit que le réseau est chez lui. Un « numéro de site » a d'abord été proposé : **rejeté**, c'était un SECOND SEED à tenir et à synchroniser, alors que tout dérive déjà d'`index` (D-17). La bande basse est sûre **par la règle, pas par chance** : les zones valent `10.(10+index).(15+categorie).0/24`, donc troisième octet ≥ 16 — les octets 0 à 15 ne sont jamais alloués. Les troisièmes et derniers octets de l'underlay actuel sont ainsi **préservés** : seul le deuxième change, et l'invariant `.1` (D-04) survit intact. Le **stockage** en est sorti parce qu'il est jumbo, non routé, et ne quitte jamais son site : il n'a aucun besoin d'être unique, et l'aligner sur le VLAN (`192.168.20.x` ↔ VLAN 20) fait dire son VLAN à l'adresse. Contrepartie assumée : ces réseaux ne pourront jamais traverser un lien inter-sites — sans conséquence, puisque ce qui voyage entre deux sites est l'**état**, par le dépôt de sauvegarde, pas le stockage bloc | `underlay.yml` (`index`), `scripts/underlay.py`, `docs/preparer-un-site-hebergeur.md` | **P23** |
> **Ce que D-77 rend possible, et ce qu'elle coûte.** Elle est la condition de la reprise > **Ce que D-77 rend possible, et ce qu'elle coûte.** Elle est la condition de la reprise
> mutuelle : sans plages distinctes, aucun lien entre deux sites ne peut router. Elle est > mutuelle : sans plages distinctes, aucun lien entre deux sites ne peut router. Elle est

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""D-77 — l'underlay tient dans la BANDE BASSE du supernet de son propre site.
Ce que ce test garde, et pourquoi il existe. La regle « underlay dans la bande basse »
n'est sure que parce que les zones d'un tenant commencent a `OCTET_ZONE + 1`. C'est une
propriete de `inventory_rules`, pas une place qu'on aurait constatee libre un jour : si
`OCTET_ZONE` bougeait sans que la borne suive, la validation continuerait a dire « oui »
en laissant l'underlay recouvrir les zones — et personne ne le verrait avant que deux
machines ne se disputent une adresse.
Le test derive donc sa frontiere de la MEME constante que le code, et verifie les trois
cas qui comptent, y compris ceux ou un prefixe FRANCHIT la frontiere (un /19 couvre les
octets 0 a 31 : il deborde, meme s'il commence dans la bande basse).
"""
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as u # noqa: E402
from inventory_rules import OCTET_ZONE # noqa: E402
SUPERNETS = ["10.21.0.0/16", "10.27.0.0/16"] # deux sites : index 11 et 17
MIEN = 11
def _erreurs(sous: str, index: int | None = MIEN) -> list[str]:
site = {"reseaux": [{"nom": "g", "vlan": 10, "sous_reseau": sous}]}
if index is not None:
site["index"] = index
return [e for e in u.valider(site, tenants_vlans=set(), tenants_supernets=SUPERNETS)
if "deborde" in e or "chevauche le supernet" in e]
def test_la_borne_derive_de_la_regle_des_zones():
"""La frontiere n'est pas 16 en dur : elle suit `OCTET_ZONE`."""
assert u.PREMIER_OCTET_ZONE == OCTET_ZONE + 1
def test_bande_basse_de_son_propre_supernet_acceptee():
for sous in ("10.21.0.0/24", "10.21.4.0/24", "10.21.5.0/24", "10.21.15.0/24"):
assert _erreurs(sous) == [], f"{sous} devrait etre conforme (bande basse)"
def test_bande_des_zones_refusee():
for sous in (f"10.21.{OCTET_ZONE + 1}.0/24", "10.21.18.0/24", "10.21.16.0/20"):
assert _erreurs(sous), f"{sous} devrait etre REFUSE (bande des zones)"
def test_un_prefixe_qui_franchit_la_frontiere_est_refuse():
"""Le piege : il COMMENCE dans la bande basse et deborde. Un test qui ne
regarderait que le premier octet le laisserait passer."""
for sous in ("10.21.0.0/19", "10.21.0.0/16"):
assert _erreurs(sous), f"{sous} couvre au-dela de {OCTET_ZONE} : a refuser"
def test_supernet_d_un_autre_site_refuse():
"""C'est ce qui empeche deux sites d'etre relies — l'erreur la plus couteuse."""
assert _erreurs("10.27.0.0/24"), "le supernet d'un autre site doit etre refuse"
def test_hors_de_tout_supernet_accepte():
"""Le stockage (192.168.x, identique partout) et l'heritage 10.0.x avant migration."""
for sous in ("192.168.20.0/24", "192.168.31.0/24", "10.0.0.0/24"):
assert _erreurs(sous) == [], f"{sous} est hors des supernets : conforme"
def test_sans_index_on_retombe_sur_la_regle_stricte():
"""Un underlay qui n'a pas encore migre ne declare pas d'index : aucun chevauchement
n'est alors tolere, ce qui reste le comportement sur."""
assert _erreurs("10.21.0.0/24", index=None), \
"sans index, tout chevauchement doit etre refuse"
if __name__ == "__main__":
n = 0
for nom, fn in sorted(globals().items()):
if nom.startswith("test_") and callable(fn):
fn()
n += 1
print(f"OK — {n} test(s) D-77 (bande basse, frontiere derivee d'OCTET_ZONE={OCTET_ZONE}).")

View file

@ -1,13 +1,33 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Underlay = la fabric physique partagee par les instances federees. """Underlay = la fabric physique partagee par les instances federees.
Cluster-global, HORS de la derivation par-tenant (aucun index) : management des Cluster-global : management des switches et du mgmt Proxmox/OOB, stockage iSCSI,
switches et du mgmt Proxmox/OOB, stockage iSCSI, reseaux Ceph (public + cluster). reseaux Ceph (public + cluster). Ce sont des reseaux du *sous-sol* : ils portent la
Ce sont des reseaux du *sous-sol* : ils portent la flotte, ils n'appartiennent a flotte, ils n'appartiennent a aucun tenant.
aucun tenant.
Convention : VLAN < 1000, franchement SOUS la plage tenant (VLAN = 1000+index*10+zone). Convention : VLAN < 1000, franchement SOUS la plage tenant (VLAN = 1000+index*10+zone).
Les sous-reseaux ne doivent chevaucher aucun supernet tenant (10.(10+index).0.0/16).
ADRESSAGE (D-77, 2026-08-12). Un site derive du meme `index` que son tenant, et son
underlay occupe la BANDE BASSE de ce supernet : `10.(10+index).0-15.x`. La bande est
sure PAR LA REGLE et non par chance les zones d'un tenant valent
`10.(10+index).(15+categorie).0/24`, donc troisieme octet >= 16 ; les octets 0 a 15
ne sont jamais alloues. Deux sites ne peuvent alors pas se chevaucher, sans second
registre a tenir.
Trois cas, et un seul est une erreur :
- dans SON PROPRE supernet, bande basse -> conforme (c'est la regle)
- dans son propre supernet, bande HAUTE -> ERREUR : collision avec ses zones
- dans le supernet d'un AUTRE tenant -> ERREUR : collision entre sites
- hors de tout supernet (10.0.x, 192.168.x) -> conforme (heritage, et stockage)
Le site declare son index par `underlay.index`. **Sans lui**, on retombe sur la regle
stricte d'avant D-77 (aucun chevauchement) : c'est le comportement sur pour un
underlay qui n'a pas encore migre.
Les reseaux de STOCKAGE sortent de cet espace (192.168.<vlan>.0/24, identiques a tous
les sites) : jumbo, non routes, ils ne quittent jamais leur site, donc ils n'ont aucun
besoin d'etre uniques.
EMPLACEMENT : l'underlay appartient a l'HEBERGEUR ses switches, ses cables. Il vit EMPLACEMENT : l'underlay appartient a l'HEBERGEUR ses switches, ses cables. Il vit
donc dans SON depot, et le moteur le monte par symlink : donc dans SON depot, et le moteur le monte par symlink :
@ -36,6 +56,12 @@ from inventory_rules import supernet_de, vlan_de # noqa: E402
# Les VLAN tenant commencent a 1000+index*10+zone (>= 1011). L'underlay reste en dessous. # Les VLAN tenant commencent a 1000+index*10+zone (>= 1011). L'underlay reste en dessous.
SEUIL_VLAN_TENANT = 1000 SEUIL_VLAN_TENANT = 1000
# Premier troisieme-octet alloue aux ZONES d'un tenant. DERIVE de la regle
# (`base3_de` = 10.(10+index).(15+categorie)) et jamais ecrit en dur : deplacer
# OCTET_ZONE deplacerait la frontiere de la bande basse avec lui.
from inventory_rules import OCTET_ZONE # noqa: E402
PREMIER_OCTET_ZONE = OCTET_ZONE + 1
def chemin() -> Path | None: def chemin() -> Path | None:
"""Emplacement du underlay.yml : SETOPS_UNDERLAY, sinon le symlink a la racine. """Emplacement du underlay.yml : SETOPS_UNDERLAY, sinon le symlink a la racine.
@ -265,6 +291,12 @@ def octet_passerelle(plan_nomenclature: Path | None = None) -> int | None:
return int(v) if isinstance(v, int) else None return int(v) if isinstance(v, int) else None
def index_site(underlay: dict | None) -> int | None:
"""`index` du site — le seed dont derive tout son adressage (D-77). None si absent."""
v = (underlay or {}).get("underlay", underlay or {}).get("index")
return int(v) if isinstance(v, int) else None
def _tenants_reperes() -> tuple[set[int], list[str]]: def _tenants_reperes() -> tuple[set[int], list[str]]:
"""VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis).""" """VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis)."""
import devis_reseau import devis_reseau
@ -285,6 +317,9 @@ def valider(underlay: dict | None,
if tenants_vlans is None or tenants_supernets is None: if tenants_vlans is None or tenants_supernets is None:
tenants_vlans, tenants_supernets = _tenants_reperes() tenants_vlans, tenants_supernets = _tenants_reperes()
t_supernets = [ipaddress.ip_network(s, strict=False) for s in tenants_supernets] t_supernets = [ipaddress.ip_network(s, strict=False) for s in tenants_supernets]
# Sans `index` declare, on ignore quel supernet est le SIEN : on retombe alors sur la
# regle stricte d'avant D-77 (aucun chevauchement), qui reste sure.
idx_site = index_site(underlay)
erreurs: list[str] = [] erreurs: list[str] = []
vus_vlan: dict[int, str] = {} vus_vlan: dict[int, str] = {}
@ -311,9 +346,29 @@ def valider(underlay: dict | None,
except ValueError: except ValueError:
erreurs.append(f"reseau '{nom}': sous_reseau '{sous}' invalide") erreurs.append(f"reseau '{nom}': sous_reseau '{sous}' invalide")
continue continue
# D-77 : l'underlay d'un site vit dans la BANDE BASSE de SON propre supernet.
# Chevaucher celui d'un AUTRE tenant reste une erreur — c'est ce qui empeche deux
# sites de se joindre. Et deborder sur la bande haute de son propre supernet en
# est une aussi : on entrerait en collision avec ses propres zones.
mien = ipaddress.ip_network(supernet_de(idx_site), strict=False) \
if idx_site is not None else None
for ts in t_supernets: for ts in t_supernets:
if net.overlaps(ts): if not net.overlaps(ts):
erreurs.append(f"reseau '{nom}': {sous} chevauche le supernet tenant {ts}") continue
if mien is not None and ts == mien:
# Le sien : seule la bande basse est permise.
hauts = [o for o in range(net.network_address.packed[2],
net.broadcast_address.packed[2] + 1)
if o >= PREMIER_OCTET_ZONE]
if hauts:
erreurs.append(
f"reseau '{nom}': {sous} deborde sur la bande des ZONES de son "
f"propre tenant (3e octet {hauts[0]} >= {PREMIER_OCTET_ZONE}). "
f"L'underlay tient dans 10.x.0-{PREMIER_OCTET_ZONE - 1}.x (D-77).")
else:
erreurs.append(f"reseau '{nom}': {sous} chevauche le supernet tenant {ts} "
f"— c'est celui d'un AUTRE site, les deux ne pourront "
f"jamais etre relies")
for autre, autre_nom in vus_sous: for autre, autre_nom in vus_sous:
if net.overlaps(autre): if net.overlaps(autre):
erreurs.append(f"reseau '{nom}': {sous} chevauche le reseau underlay '{autre_nom}'") erreurs.append(f"reseau '{nom}': {sous} chevauche le reseau underlay '{autre_nom}'")