diff --git a/.gitignore b/.gitignore index fceea71..f7d8bee 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ facts_cache/ **/vault.yml # Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul. /underlay.yml +# Contexte actif de CETTE machine (`site:` ou `locataire:`) : propre a chaque +# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py. +/contexte *.secret *.pem *.key diff --git a/CHANGELOG.md b/CHANGELOG.md index f11da6d..042a5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # CHANGELOG — Set-OPS +## 2026-10-04 (79) — Étape 1 : le module `contexte.py`, que rien n'utilise encore + +**Le chemin** (`docs/conception-contextes.md` §6), premier pas : le tronc commun et les deux +classes existent, testées, sans qu'aucun script ne change. + +**Fait** : +- `scripts/contexte.py` : `Ecosysteme` (nom, dépôt, plan, voûte et sa clé, filiation), + `Site` et `Locataire`, qui surchargent l'index, la voûte et l'inventaire (un script pour le + site, le `hosts.yml` généré pour le locataire). Relations : `site.locataires()`, les absents + **nommés** par `locataires_absents()`, et `locataire.site()`, l'hébergeur **actif** que + nomme `parente.yml`. Un locataire peut figurer chez deux sites : SITE-Technolibre, site de + reprise, déclare les mêmes deux locataires que SITE-Chezlepro. +- `contexte.actif()` : le fichier `contexte` à la racine du moteur fait foi + (`site:` ou `locataire:`) ; sans lui, les indices d'avant (`SETOPS_*`, liens), + et le locataire l'emporte quand les deux sont montés, comme la console d'aujourd'hui. Un + contexte nommé qui ne désigne rien est un **refus**, pas un contexte vide. +- Le module **réutilise** `voutes.cle_de`, `inventory_rules.dossier_inventaire` et + `underlay.allocations` au lieu d'en refaire des copies. +- `/contexte` au `.gitignore` : propre à chaque machine, comme `instance/` et `underlay.yml`. + +**Éprouvé** : `scripts/tests/test_contexte.py`, 40 contrôles, inscrit dans `make test`. Sur +une arborescence fabriquée (un site, des locataires, un dépôt ambigu refusé), puis sur les +dépôts réels : chaque locataire déclaré par un site porte l'index que ce site lui attribue +(17 et 23, chez les deux sites) et nomme un hébergeur qui existe. Les tests savent échouer : +le module altéré (le site l'emporte sur le locataire) en fait échouer deux. + +**Ce que `make verifier` a trouvé, et corrigé avant ce commit** : +- **P34 et P48 étaient rouges depuis (78)**, publié sans `make verifier` : la page de + conception ne déclarait pas son lecteur, et la carte d'orientation comptait toujours 45 + documents. La page déclare son lecteur ; la carte en compte 46 et la range dans le parcours + « Le modèle », après `meta-classe.md`, que la page cite désormais (la méta-classe est le + mécanisme de la classe `Locataire`). +- **P41** refusait la première version du module : il relisait `SETOPS_INSTANCE` lui-même. Il + passe par les deux résolutions qui font foi, `inventory_rules.instance_courante()` et + `underlay.chemin()`. + +`python3 scripts/contexte.py` sur le poste dit ce que les indices d'avant désignent : +`locataire OPS-Chezlepro ET site SITE-Chezlepro`. C'est l'ambiguïté qu'un sélecteur lèvera. + ## 2026-10-04 (78) — Conception : un tronc commun, deux classes (SITE et LOCATAIRE) **Demande de l'exploitant** : en finir avec les « classes à tout faire ». Deux grandes classes, diff --git a/Makefile b/Makefile index a0fa89c..8a88912 100644 --- a/Makefile +++ b/Makefile @@ -333,6 +333,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_restauration.py python3 scripts/tests/test_repertoires_partages.py python3 scripts/tests/test_frontiere_refus.py + python3 scripts/tests/test_contexte.py # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index e0f6403..ab28081 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -25,7 +25,7 @@ README de rôles). Cette page comble ces deux trous. |---|---|---| | rôles | 69 | `roles/*/` | | README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | -| documents | 45 | `docs/*.md` | +| documents | 46 | `docs/*.md` | | pièces d'audit | 51 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | @@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous. | Sujet | Documents | |---|---| | **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` | -| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) | +| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) | | **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) | | **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` | | **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` | diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index 9ffd388..f18fca5 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -1,5 +1,7 @@ # Contextes : un tronc commun, deux classes (SITE et LOCATAIRE) +> **Pour qui :** le **mainteneur** — comment le moteur sait s'il sert un site ou un locataire, et ce que les deux s'apprennent l'un à l'autre. + > **Statut : arrêtée avec l'exploitant le 2026-10-04.** Rien n'est encore construit ; les > décisions sont au §7, le chemin au §6. @@ -72,7 +74,8 @@ Méthodes abstraites, que chaque classe **surcharge** : `inventaire()`, `machine ### 2.3 `Locataire(Ecosysteme)` - **Déclaration** : `plan/` (nomenclature, serveurs, applications, bases, domaines). -- **Inventaire** : généré (`instancier.py` → `hosts.yml`). +- **Inventaire** : généré (`instancier.py` → `hosts.yml`). C'est la **méta-classe** de + [`meta-classe.md`](meta-classe.md) : une définition qui engendre toute la flotte. - **Ce qu'il porte en propre** : la configuration de ses services, la remise au client. - **Relation** : `site()`, l'hébergeur que nomme `parente.yml`, résolu en objet `Site`. Le locataire n'en lit que les **intrants exposés** (§2.4). diff --git a/scripts/contexte.py b/scripts/contexte.py new file mode 100644 index 0000000..d472039 --- /dev/null +++ b/scripts/contexte.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Le contexte d'un ecosysteme : un tronc commun, deux classes (SITE et LOCATAIRE). + +Conception : `docs/conception-contextes.md` (arretee avec l'exploitant le 2026-10-04). + +POURQUOI CE MODULE. Le 2026-10-04, 33 scripts DEVINAIENT leur contexte, chacun a sa facon : +le lien `instance/`, le lien `underlay.yml`, les variables `SETOPS_*`, les dossiers freres, +les `SITE-*`. Les indices ne concordaient pas toujours, et chaque desaccord avait deja produit +un defaut silencieux (la frontiere d'un site recevant les regles de l'autre, la console d'un +site affichant zero machine, `make ci` melangeant le modele public et les ecosystemes reels). + +Ce module est le SEUL endroit ou se lisent ces indices. Les scripts lui demandent « ou +suis-je ? » et recoivent un objet : un `Site` ou un `Locataire`, qui heritent d'`Ecosysteme`. + +ETAPE 1 DU CHEMIN (§6) : le module existe, il est teste, et RIEN NE L'UTILISE ENCORE. Les +scripts l'adopteront un par un, chacun a resultat identique. Les fiches du contrat +(`site.fiche_pour`, `locataire.face_reseau`) sont l'etape 2. + +Usage : + python3 scripts/contexte.py # le contexte actif, et ce que chaque indice dit + python3 scripts/contexte.py --json +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import yaml # noqa: E402 + +import underlay as underlay_mod # noqa: E402 +import voutes as voutes_mod # noqa: E402 +from inventory_rules import ORDRE_INVENTAIRE, dossier_inventaire, instance_courante # noqa: E402 + +RACINE = Path(__file__).resolve().parents[1] +# Les ecosystemes vivent a cote du moteur : des dossiers freres, pas de registre. +DOSSIER_ECOSYSTEMES = RACINE.parent +# Le contexte actif, nomme (decision du 2026-10-04) : une seule valeur, `site:` ou +# `locataire:`. Absent, on retombe sur les liens d'avant, le temps de la bascule. +NOM_FICHIER_CONTEXTE = "contexte" +NATURES = ("site", "locataire") + + +class ContexteInvalide(Exception): + """Un contexte nomme qui ne designe rien, ou un depot qui ne dit pas ce qu'il est.""" + + +def _yaml(chemin: Path) -> dict: + if not chemin.is_file(): + return {} + return yaml.safe_load(chemin.read_text(encoding="utf-8")) or {} + + +# --- LE TRONC COMMUN ---------------------------------------------------------------------- + +class Ecosysteme: + """Ce que tout ecosysteme possede, quel que soit son contexte (§2.1). + + Les methodes qui dependent du contexte sont SURCHARGEES par `Site` et `Locataire` ; ici + elles refusent, pour qu'un oubli de surcharge se voie au premier appel. + """ + + nature = "ecosysteme" + + def __init__(self, depot: Path): + self.depot = Path(depot) + + # Identite ------------------------------------------------------------------------- + @property + def nom(self) -> str: + return self.depot.name + + @property + def plan(self) -> Path: + return self.depot / "plan" + + def lire_plan(self, fichier: str) -> dict: + """Un fichier du plan, ou {} s'il n'existe pas.""" + return _yaml(self.plan / fichier) + + # Voute : un depot, une cle (2026-08-28) --------------------------------------------- + @property + def cle_voute(self) -> Path: + """La cle qui ouvre SA voute, nommee d'apres le depot (`voutes.cle_de`).""" + return voutes_mod.cle_de(self.nom) + + @property + def voute(self) -> Path: + raise NotImplementedError(f"{type(self).__name__} : `voute` non surchargee") + + def a_sa_voute(self) -> bool: + return self.voute.is_file() + + # Filiation ---------------------------------------------------------------------------- + @property + def filiation(self) -> dict: + """`parente.yml` : de quels depots (moteur, hebergeur, modeles) il descend.""" + return (_yaml(self.depot / "parente.yml").get("parente")) or {} + + # Ce que chaque contexte surcharge ------------------------------------------------------ + @property + def index(self) -> int | None: + raise NotImplementedError(f"{type(self).__name__} : `index` non surcharge") + + def inventaire(self) -> Path: + raise NotImplementedError(f"{type(self).__name__} : `inventaire` non surcharge") + + # Commodites ---------------------------------------------------------------------------- + def __repr__(self) -> str: + return f"{type(self).__name__}({self.nom!r})" + + def __eq__(self, autre: object) -> bool: + return (isinstance(autre, Ecosysteme) and type(autre) is type(self) + and autre.depot.resolve() == self.depot.resolve()) + + def __hash__(self) -> int: + return hash((type(self).__name__, str(self.depot.resolve()))) + + def decrire(self) -> dict: + return {"nature": self.nature, "nom": self.nom, "depot": str(self.depot), + "index": self.index, "voute": str(self.voute), + "a_sa_voute": self.a_sa_voute(), "cle_voute": str(self.cle_voute)} + + # Reconnaitre un depot ------------------------------------------------------------------ + @staticmethod + def depuis(depot: Path) -> "Ecosysteme": + """Le `Site` ou le `Locataire` que porte ce depot, d'apres CE QU'IL DECLARE. + + Un site declare un `underlay.yml` ; un locataire, une nomenclature. Un depot qui + declare les deux (l'ancien modele `socle`) ne dit pas ce qu'il est : refuse, plutot + que de choisir pour lui. + """ + depot = Path(depot) + est_site = (depot / "underlay.yml").is_file() + est_locataire = (depot / "plan" / "nomenclature.yml").is_file() + if est_site and est_locataire: + raise ContexteInvalide( + f"{depot.name} porte un `underlay.yml` ET une nomenclature : un site ou un " + f"locataire, pas les deux.") + if est_site: + return Site(depot) + if est_locataire: + return Locataire(depot) + raise ContexteInvalide( + f"{depot} n'est ni un site (pas d'`underlay.yml`) ni un locataire (pas de " + f"`plan/nomenclature.yml`).") + + +# --- SITE --------------------------------------------------------------------------------- + +class Site(Ecosysteme): + """L'hebergeur : le materiel, la fabric, la frontiere, et ses locataires (§2.2).""" + + nature = "site" + + @classmethod + def charger(cls, nom: str, dossier: Path = DOSSIER_ECOSYSTEMES) -> "Site": + depot = Path(dossier) / nom + if not (depot / "underlay.yml").is_file(): + raise ContexteInvalide(f"site « {nom} » introuvable : pas de {depot}/underlay.yml") + return cls(depot) + + @property + def underlay(self) -> Path: + return self.depot / "underlay.yml" + + def carte(self) -> dict: + """Le contenu de `underlay:`.""" + return _yaml(self.underlay).get("underlay") or {} + + @property + def voute(self) -> Path: + return self.depot / "underlay.vault.yml" + + @property + def index(self) -> int | None: + i = self.carte().get("index") + return i if isinstance(i, int) and not isinstance(i, bool) else None + + def inventaire(self) -> Path: + """Un site n'a pas de `hosts.yml` : son inventaire est un SCRIPT (`site_inventaire.py`).""" + return RACINE / "scripts" / "site_inventaire.py" + + def allocations(self) -> dict[str, int]: + """L'index que CE site attribue a chacun de ses locataires (`underlay.tenants`).""" + return underlay_mod.allocations({"underlay": self.carte()}) + + def noms_locataires(self) -> list[str]: + """Les locataires que ce site declare, sous l'une ou l'autre forme de `tenants`.""" + v = self.carte().get("tenants") + if isinstance(v, dict): + return sorted(str(n) for n in v) + if isinstance(v, list): + return sorted(str(n) for n in v) + return [] + + def locataires(self) -> list["Locataire"]: + """Ses locataires, resolus en objets. Un nom sans depot frere n'est PAS rendu ici : + `locataires_absents()` le dit — un filtre qui oublie en silence est un defaut.""" + out = [] + for nom in self.noms_locataires(): + depot = self.depot.parent / nom + if (depot / "plan" / "nomenclature.yml").is_file(): + out.append(Locataire(depot)) + return out + + def locataires_absents(self) -> list[str]: + presents = {l.nom for l in self.locataires()} + return [n for n in self.noms_locataires() if n not in presents] + + def decrire(self) -> dict: + d = super().decrire() + d.update({"locataires": self.noms_locataires(), + "locataires_absents": self.locataires_absents()}) + return d + + +# --- LOCATAIRE ---------------------------------------------------------------------------- + +class Locataire(Ecosysteme): + """Une organisation hebergee : son plan de services, son inventaire genere (§2.3). + + UN LOCATAIRE PEUT FIGURER CHEZ DEUX SITES : son hebergeur actif, que nomme `parente.yml`, + et un site de reprise qui le declare aussi (SITE-Technolibre declare les deux locataires de + SITE-Chezlepro). `site()` rend l'hebergeur ACTIF ; les autres le connaissent par leur + propre `locataires()`. + """ + + nature = "locataire" + + @classmethod + def charger(cls, nom: str, dossier: Path = DOSSIER_ECOSYSTEMES) -> "Locataire": + depot = Path(dossier) / nom + if not (depot / "plan" / "nomenclature.yml").is_file(): + raise ContexteInvalide( + f"locataire « {nom} » introuvable : pas de {depot}/plan/nomenclature.yml") + return cls(depot) + + def nomenclature(self) -> dict: + return self.lire_plan("nomenclature.yml") + + @property + def index(self) -> int | None: + i = self.nomenclature().get("index") + return i if isinstance(i, int) and not isinstance(i, bool) else None + + def dossier_inventaire(self) -> Path: + return dossier_inventaire(self.depot, ORDRE_INVENTAIRE) + + def inventaire(self) -> Path: + """Son `hosts.yml` GENERE du plan. Le chemin est rendu meme s'il n'existe pas encore + (instance neuve) : c'est `instancier` qui l'ecrira.""" + return self.dossier_inventaire() / "hosts.yml" + + @property + def voute(self) -> Path: + return self.dossier_inventaire() / "group_vars" / "all" / "vault.yml" + + def nom_hebergeur(self) -> str | None: + h = ((self.filiation.get("depots") or {}).get("hebergeur") or {}).get("nom") + return str(h) if h else None + + def site(self) -> "Site | None": + """Son hebergeur ACTIF. None s'il n'en nomme aucun ; refus s'il en nomme un absent.""" + nom = self.nom_hebergeur() + if not nom: + return None + return Site.charger(nom, self.depot.parent) + + def decrire(self) -> dict: + d = super().decrire() + d.update({"inventaire": str(self.inventaire()), "hebergeur": self.nom_hebergeur()}) + return d + + +# --- LE CONTEXTE ACTIF -------------------------------------------------------------------- + +def _lire_fichier_contexte(fichier: Path, dossier: Path) -> Ecosysteme: + lignes = [l.split("#", 1)[0].strip() + for l in fichier.read_text(encoding="utf-8").splitlines()] + valeurs = [l for l in lignes if l] + if len(valeurs) != 1 or ":" not in valeurs[0]: + raise ContexteInvalide( + f"{fichier} doit porter UNE valeur, `site:` ou `locataire:` " + f"(lu : {valeurs or 'rien'}).") + nature, nom = (x.strip() for x in valeurs[0].split(":", 1)) + if nature == "site": + return Site.charger(nom, dossier) + if nature == "locataire": + return Locataire.charger(nom, dossier) + raise ContexteInvalide(f"{fichier} : nature « {nature} » inconnue ({' ou '.join(NATURES)}).") + + +def montes(racine: Path = RACINE) -> list[Ecosysteme]: + """Ce que les INDICES D'AVANT designent : variables `SETOPS_*`, puis liens a la racine. + + Garde le temps de la bascule, et pour la dire : sur le poste d'aujourd'hui, cette liste + rend DEUX ecosystemes, ce qu'un selecteur ne doit plus faire. + """ + # LES DEUX RESOLUTIONS QUI FONT DEJA FOI, pas une troisieme copie (P41) : + # `instance_courante()` lit `SETOPS_INSTANCE`, puis le lien `instance/` ; `underlay.chemin()` + # lit `SETOPS_UNDERLAY`, puis le lien `underlay.yml`. Quand on interroge une AUTRE racine que + # celle du moteur (les tests), leurs liens par defaut sont remplaces par ceux de cette racine. + racine = Path(racine) + autre_racine = racine.resolve() != RACINE.resolve() + out: list[Ecosysteme] = [] + inst_p = instance_courante() + if autre_racine and inst_p == RACINE.joinpath("instance"): + inst_p = racine.joinpath("instance") + if not inst_p.is_absolute(): + inst_p = racine / inst_p + if (inst_p / "plan" / "nomenclature.yml").is_file(): + out.append(Locataire(inst_p.resolve())) + und_p = underlay_mod.chemin() + if autre_racine and (und_p is None or und_p == RACINE.joinpath("underlay.yml")): + und_p = racine.joinpath("underlay.yml") + if und_p is not None and not und_p.is_absolute(): + und_p = racine / und_p + if und_p is not None and und_p.is_file(): + out.append(Site(und_p.resolve().parent)) + return out + + +def actif(racine: Path = RACINE, dossier: Path | None = None) -> Ecosysteme | None: + """LE contexte actif de cette machine. + + 1. Le fichier `contexte` a la racine du moteur, s'il existe : il fait foi. + 2. Sinon, les indices d'avant (`montes`). S'ils designent un locataire ET un site — le + poste d'aujourd'hui —, le locataire l'emporte : c'est ce que fait deja la console + (`ConsolePoste` herite de `ConsoleLocataire`). Le fichier `contexte` leve l'ambiguite. + 3. Rien : None. A l'appelant de le dire, jamais de le dessiner comme un plan vide. + """ + dossier = Path(dossier) if dossier is not None else Path(racine).parent + fichier = Path(racine) / NOM_FICHIER_CONTEXTE + if fichier.is_file(): + return _lire_fichier_contexte(fichier, dossier) + m = montes(racine) + return m[0] if m else None + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + try: + eco = actif() + erreur = None + except ContexteInvalide as e: + eco, erreur = None, str(e) + fichier = RACINE / NOM_FICHIER_CONTEXTE + rapport = { + "source": "fichier `contexte`" if fichier.is_file() else "indices d'avant (liens, variables)", + "actif": eco.decrire() if eco else None, + "erreur": erreur, + "indices": [e.decrire() for e in montes()], + } + if args.json: + print(json.dumps(rapport, indent=2, ensure_ascii=False)) + return 0 if eco else 1 + print(f"Source : {rapport['source']}") + if erreur: + print(f"REFUS : {erreur}") + elif eco: + d = eco.decrire() + print(f"Contexte actif : {d['nature']} {d['nom']} (index {d['index']})") + if isinstance(eco, Site): + print(f" locataires : {', '.join(d['locataires']) or 'aucun'}" + + (f" ; ABSENTS : {', '.join(d['locataires_absents'])}" if d['locataires_absents'] else "")) + else: + print(f" hebergeur actif : {d['hebergeur'] or 'aucun nomme'}") + else: + print("Aucun contexte : ni fichier `contexte`, ni locataire, ni site monte.") + if len(rapport["indices"]) > 1: + print(" NOTE : les indices d'avant designent " + + " ET ".join(f"{i['nature']} {i['nom']}" for i in rapport["indices"]) + + " — un selecteur n'en garde qu'un (fichier `contexte`).") + return 0 if eco else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py new file mode 100644 index 0000000..5bc4d6e --- /dev/null +++ b/scripts/tests/test_contexte.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""`contexte.py` : un tronc commun, deux classes — et le contexte actif, nomme ou devine. + +Sur une arborescence FABRIQUEE (un site, deux locataires, un depot ambigu), on verifie : + - qu'un depot dit ce qu'il est (site, locataire), et qu'un depot qui dit les deux est refuse ; + - ce que chaque classe surcharge : index, voute, inventaire ; + - les relations : `site.locataires()` (et les absents, nommes), `locataire.site()` ; + - le contexte actif : le fichier `contexte` fait foi ; sans lui, les indices d'avant, et le + locataire l'emporte quand les deux sont montes (le poste d'aujourd'hui) ; + - un contexte nomme qui ne designe rien est un REFUS, pas un contexte vide. +Puis, si les vrais depots sont la : chaque locataire declare par un site porte l'index que ce +site lui attribue, et nomme un hebergeur qui existe. +""" +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import contexte as C # noqa: E402 + +ECHECS: list[str] = [] + + +def verifier(cond: bool, msg: str) -> None: + print(("OK " if cond else "ECHEC ") + msg) + if not cond: + ECHECS.append(msg) + + +def refuse(fonction, msg: str) -> None: + try: + fonction() + except C.ContexteInvalide: + verifier(True, msg) + return + verifier(False, msg + " (aucun refus)") + + +def ecrire(chemin: Path, texte: str) -> None: + chemin.parent.mkdir(parents=True, exist_ok=True) + chemin.write_text(texte, encoding="utf-8") + + +def fabriquer(d: Path) -> Path: + """Un dossier d'ecosystemes : un moteur, un site, deux locataires, un depot ambigu.""" + moteur = d / "Moteur" + moteur.mkdir() + ecrire(d / "SITE-Essai" / "underlay.yml", + "underlay:\n index: 9\n tenants:\n OPS-Un: 5\n OPS-Absent: 6\n") + ecrire(d / "SITE-Essai" / "underlay.vault.yml", "$ANSIBLE_VAULT;1.1;AES256\n") + ecrire(d / "OPS-Un" / "plan" / "nomenclature.yml", "index: 5\n") + ecrire(d / "OPS-Un" / "inventories" / "principal" / "hosts.yml", "all: {}\n") + ecrire(d / "OPS-Un" / "inventories" / "principal" / "group_vars" / "all" / "vault.yml", "x\n") + ecrire(d / "OPS-Un" / "parente.yml", + "parente:\n depots:\n hebergeur:\n nom: SITE-Essai\n") + ecrire(d / "OPS-Deux" / "plan" / "nomenclature.yml", "index: 7\n") + ecrire(d / "OPS-Fantome" / "plan" / "nomenclature.yml", "index: 8\n") + ecrire(d / "OPS-Fantome" / "parente.yml", + "parente:\n depots:\n hebergeur:\n nom: SITE-Inexistant\n") + ecrire(d / "Ambigu" / "underlay.yml", "underlay: {index: 1}\n") + ecrire(d / "Ambigu" / "plan" / "nomenclature.yml", "index: 1\n") + return moteur + + +def classes(d: Path) -> None: + site = C.Ecosysteme.depuis(d / "SITE-Essai") + un = C.Ecosysteme.depuis(d / "OPS-Un") + verifier(isinstance(site, C.Site) and site.nature == "site", "un depot a `underlay.yml` est un Site") + verifier(isinstance(un, C.Locataire) and un.nature == "locataire", + "un depot a nomenclature est un Locataire") + refuse(lambda: C.Ecosysteme.depuis(d / "Ambigu"), + "un depot qui porte `underlay.yml` ET une nomenclature est refuse") + refuse(lambda: C.Ecosysteme.depuis(d / "Moteur"), "un depot qui ne declare rien est refuse") + + verifier(site.index == 9 and un.index == 5, "chaque classe lit SON index (underlay / nomenclature)") + verifier(site.voute == d / "SITE-Essai" / "underlay.vault.yml" and site.a_sa_voute(), + "la voute d'un site est `underlay.vault.yml`") + verifier(un.voute == d / "OPS-Un" / "inventories" / "principal" / "group_vars" / "all" / "vault.yml" + and un.a_sa_voute(), "la voute d'un locataire est dans son inventaire") + verifier(un.inventaire() == d / "OPS-Un" / "inventories" / "principal" / "hosts.yml", + "l'inventaire d'un locataire est son `hosts.yml` genere") + verifier(site.inventaire().name == "site_inventaire.py", + "l'inventaire d'un site est un script, pas un `hosts.yml`") + verifier(un.cle_voute.name == "setops-vault-ops-un", "la cle de voute est nommee d'apres le depot") + + verifier(site.allocations() == {"OPS-Un": 5, "OPS-Absent": 6}, "le site rend les index qu'il attribue") + verifier([l.nom for l in site.locataires()] == ["OPS-Un"], + "`locataires()` rend les locataires presents") + verifier(site.locataires_absents() == ["OPS-Absent"], "un locataire declare sans depot est NOMME absent") + verifier(un.site() == site, "`locataire.site()` rend l'hebergeur que nomme `parente.yml`") + verifier(C.Locataire(d / "OPS-Deux").site() is None, "sans hebergeur nomme, `site()` rend None") + refuse(lambda: C.Locataire(d / "OPS-Fantome").site(), "un hebergeur nomme mais absent est refuse") + refuse(lambda: C.Site.charger("OPS-Un", d), "`Site.charger` refuse un locataire") + refuse(lambda: C.Locataire.charger("SITE-Essai", d), "`Locataire.charger` refuse un site") + + +def contexte_actif(d: Path, moteur: Path) -> None: + sauve = {k: os.environ.pop(k) for k in ("SETOPS_INSTANCE", "SETOPS_UNDERLAY") if k in os.environ} + try: + verifier(C.actif(moteur) is None, "rien de monte, rien de nomme : aucun contexte (None)") + + (moteur / "underlay.yml").symlink_to(d / "SITE-Essai" / "underlay.yml") + verifier(C.actif(moteur) == C.Site(d / "SITE-Essai"), "le lien `underlay.yml` seul designe le site") + + (moteur / "instance").symlink_to(d / "OPS-Un") + verifier(len(C.montes(moteur)) == 2, "les deux liens montes : deux ecosystemes designes") + verifier(C.actif(moteur) == C.Locataire(d / "OPS-Un"), + "les deux montes : le locataire l'emporte, comme la console d'aujourd'hui") + + os.environ["SETOPS_INSTANCE"] = str(d / "OPS-Deux") + verifier(C.actif(moteur) == C.Locataire(d / "OPS-Deux"), "`SETOPS_INSTANCE` passe avant le lien") + del os.environ["SETOPS_INSTANCE"] + + fichier = moteur / C.NOM_FICHIER_CONTEXTE + fichier.write_text("# le contexte de ce poste\nsite:SITE-Essai\n", encoding="utf-8") + verifier(C.actif(moteur) == C.Site(d / "SITE-Essai"), + "le fichier `contexte` fait foi, meme quand les liens disent autre chose") + fichier.write_text("locataire: OPS-Un\n", encoding="utf-8") + verifier(C.actif(moteur) == C.Locataire(d / "OPS-Un"), "`locataire:` designe le locataire") + + for contenu, msg in (("site:SITE-Inexistant\n", "un site nomme qui n'existe pas"), + ("locataire:SITE-Essai\n", "une nature qui ne correspond pas au depot"), + ("hebergeur:SITE-Essai\n", "une nature inconnue"), + ("site:SITE-Essai\nlocataire:OPS-Un\n", "deux valeurs"), + ("\n# rien\n", "un fichier vide")): + fichier.write_text(contenu, encoding="utf-8") + refuse(lambda: C.actif(moteur), f"fichier `contexte` refuse : {msg}") + finally: + os.environ.pop("SETOPS_INSTANCE", None) + os.environ.update(sauve) + + +def vrais_depots() -> None: + """Sur les depots reels, s'ils sont la : les deux sens de la relation concordent.""" + sites = [C.Site(p.parent) for p in sorted(C.DOSSIER_ECOSYSTEMES.glob("SITE-*/underlay.yml"))] + if not sites: + print("SAUTE aucun site frere : rien de reel a confronter") + return + for site in sites: + for loc in site.locataires(): + attendu = site.allocations().get(loc.nom) + verifier(attendu is None or loc.index == attendu, + f"{site.nom} attribue {attendu} a {loc.nom}, qui porte l'index {loc.index}") + heb = loc.nom_hebergeur() + if heb: + verifier((C.DOSSIER_ECOSYSTEMES / heb / "underlay.yml").is_file(), + f"{loc.nom} nomme l'hebergeur {heb}, qui existe") + verifier(not site.locataires_absents(), + f"{site.nom} : aucun locataire declare sans depot ({site.locataires_absents() or 'ok'})") + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + moteur = fabriquer(d) + classes(d) + contexte_actif(d, moteur) + vrais_depots() + if ECHECS: + print(f"\n{len(ECHECS)} echec(s).") + return 1 + print("\nLe contexte se nomme, se reconnait, et refuse ce qui ne designe rien.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())