contexte : etape 1, le tronc commun et les deux classes (inutilises)

scripts/contexte.py : Ecosysteme, Site, Locataire, contexte actif
(fichier `contexte`, sinon les indices d avant via instance_courante
et underlay.chemin). 40 controles dans make test. Corrige aussi P34 et
P48, rouges depuis le commit de la page de conception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-04 19:43:20 -04:00
parent 42e23885cd
commit c891d7fb26
7 changed files with 604 additions and 3 deletions

3
.gitignore vendored
View file

@ -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:<depot>` ou `locataire:<depot>`) : propre a chaque
# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py.
/contexte
*.secret
*.pem
*.key

View file

@ -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:<dépôt>` ou `locataire:<dépôt>`) ; 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,

View file

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

View file

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

View file

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

383
scripts/contexte.py Normal file
View file

@ -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:<depot>` ou
# `locataire:<depot>`. 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:<depot>` ou `locataire:<depot>` "
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())

View file

@ -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:<depot>` 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())