diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ee4fd..53b5f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # 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 ### Le gabarit portait le nom du mauvais propriétaire diff --git a/Makefile b/Makefile index ecf7a1d..44104a9 100644 --- a/Makefile +++ b/Makefile @@ -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_raser.py python3 scripts/tests/test_raser_resultat.py + python3 scripts/tests/test_underlay_bande_basse.py .PHONY: verifier verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide) diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 5283df0..f39dd97 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -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..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-77** | **L'underlay d'un site dérive du même `index` que son tenant**, dans la **bande basse** `10.(10+index).0–15.x` ; les réseaux de **stockage** en sortent et sont **identiques partout**, en `192.168..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).0–15.x` ; les réseaux de **stockage** en sortent et sont **identiques partout**, en `192.168..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 > mutuelle : sans plages distinctes, aucun lien entre deux sites ne peut router. Elle est diff --git a/scripts/tests/test_underlay_bande_basse.py b/scripts/tests/test_underlay_bande_basse.py new file mode 100644 index 0000000..a95796f --- /dev/null +++ b/scripts/tests/test_underlay_bande_basse.py @@ -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}).") diff --git a/scripts/underlay.py b/scripts/underlay.py index f09e2f7..05178df 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -1,13 +1,33 @@ #!/usr/bin/env python3 """Underlay = la fabric physique partagee par les instances federees. -Cluster-global, HORS de la derivation par-tenant (aucun index) : management des -switches et du mgmt Proxmox/OOB, stockage iSCSI, reseaux Ceph (public + cluster). -Ce sont des reseaux du *sous-sol* : ils portent la flotte, ils n'appartiennent a -aucun tenant. +Cluster-global : management des switches et du mgmt Proxmox/OOB, stockage iSCSI, +reseaux Ceph (public + cluster). Ce sont des reseaux du *sous-sol* : ils portent la +flotte, ils n'appartiennent a aucun tenant. 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..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 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. 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: """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 +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]]: """VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis).""" import devis_reseau @@ -285,6 +317,9 @@ def valider(underlay: dict | None, if tenants_vlans is None or tenants_supernets is None: tenants_vlans, tenants_supernets = _tenants_reperes() 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] = [] vus_vlan: dict[int, str] = {} @@ -311,9 +346,29 @@ def valider(underlay: dict | None, except ValueError: erreurs.append(f"reseau '{nom}': sous_reseau '{sous}' invalide") 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: - if net.overlaps(ts): - erreurs.append(f"reseau '{nom}': {sous} chevauche le supernet tenant {ts}") + if not net.overlaps(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: if net.overlaps(autre): erreurs.append(f"reseau '{nom}': {sous} chevauche le reseau underlay '{autre_nom}'")