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:
parent
42e23885cd
commit
c891d7fb26
7 changed files with 604 additions and 3 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
39
CHANGELOG.md
39
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:<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,
|
||||
|
|
|
|||
1
Makefile
1
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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
383
scripts/contexte.py
Normal 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())
|
||||
172
scripts/tests/test_contexte.py
Normal file
172
scripts/tests/test_contexte.py
Normal 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())
|
||||
Loading…
Reference in a new issue