Set-OPS-Public/scripts/forge_etancheite.py

151 lines
6.7 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ?
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Le plan d'un ecosysteme et la carte
du site sont prives sur la forge du site ; chaque runner de locataire lit le sien avec une cle
de deploiement en lecture seule. Deux mesures, depuis deux endroits :
SANS IDENTIFIANT, depuis ce poste (HTTPS) :
- un depot PRIVE doit EXIGER une authentification. Un refus RESEAU ne compte pas : il ne
dit rien de la forge, seulement du chemin (une sonde rapporte son errno) ;
- un depot PUBLIC (le moteur, les modeles) doit repondre : un ecosysteme neuf le clone
sans rien detenir.
DEPUIS CHAQUE RUNNER DE LOCATAIRE (SSH, sa cle, la cle d'hote epinglee) :
- il lit SON depot ;
- la forge lui REFUSE les autres depots prives (« not authorized »).
Le resultat se lit ligne par ligne ; le code de sortie vaut 1 au moindre ecart.
Usage :
python3 scripts/forge_etancheite.py (make forge-etancheite)
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
def classer_https(rc: int, sortie: str) -> str:
"""'lu', 'authentification', ou 'reseau' — ce que dit un `git ls-remote` anonyme."""
if rc == 0:
return "lu"
s = sortie.lower()
if any(m in s for m in ("could not read username", "terminal prompts disabled",
"authentication failed", "401", "403")):
return "authentification"
return "reseau"
def classer_ssh(rc: int, sortie: str) -> str:
"""'lu', 'refuse' (la forge a juge), ou 'reseau' (personne n'a juge)."""
if rc == 0:
return "lu"
s = sortie.lower()
if "not authorized" in s or "permission denied" in s or "does not exist" in s:
return "refuse"
return "reseau"
def _site_et_depots():
import underlay as underlay_mod
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
depots = []
for srv in (plan.get("serveurs") or {}).values():
d = (srv.get("variables") or {}).get("serveur_ops_depots")
if d:
depots = list(d)
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
racine_ac = (underlay_mod.chemin() or Path()).resolve().parent / "ac-racine-site.crt"
return dom, depots, racine_ac
def mesurer_https(dom: str, depots: list[dict], racine_ac: Path) -> list[str]:
ecarts = []
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_ASKPASS="/bin/false")
if racine_ac.is_file():
env["GIT_SSL_CAINFO"] = str(racine_ac)
print(f"Sans identifiant, depuis ce poste (https://forge.{dom}) :")
for d in depots:
nom, prive = str(d["depot"]), str(d.get("role")) in ROLES_PRIVES
r = subprocess.run(["git", "ls-remote", f"https://forge.{dom}/genome/{nom}.git", "HEAD"],
capture_output=True, text=True, timeout=30, env=env)
verdict = classer_https(r.returncode, r.stdout + r.stderr)
attendu = "authentification" if prive else "lu"
ok = verdict == attendu
print(f" {'OK ' if ok else 'ECART'} {nom:<18} {'prive ' if prive else 'public'} -> {verdict}"
+ ("" if ok else f" (attendu : {attendu})"))
if not ok:
ecarts.append(f"{nom} : {verdict}, attendu {attendu}")
return ecarts
def mesurer_runners(depots: list[dict]) -> list[str]:
import contexte as _ctx
import yaml
prives = [str(d["depot"]) for d in depots if str(d.get("role")) in ROLES_PRIVES]
ecarts = []
for site, loc in _ctx.couples_hebergeur():
face = loc.face_publiee() or {}
if face.get("hebergeur") != site.nom:
continue
sien = str((face.get("genome") or {}).get("depot") or "")
runners = sorted((face.get("groupes") or {}).get("serveur_ops_tenant") or [])
gv = yaml.safe_load((loc.dossier_inventaire() / "group_vars" / "serveur_ops.yml")
.read_text(encoding="utf-8")) or {}
dest = next((str(x.get("dest")) for x in gv.get("serveur_ops_depots") or []
if x.get("role") == "instance"), "")
if not (sien and runners and dest):
ecarts.append(f"{loc.nom} : face sans depot, sans runner, ou plan sans `role: instance`")
continue
print(f"\nDepuis le runner de {loc.nom} ({runners[0]}), par l'URL de son origin :")
# LA COMMANDE SSH DU DEPOT LUI-MEME (`core.sshCommand`, posee par serveur_ops) : on
# mesure ce que le runner fait vraiment, pas une commande refaite a cote.
script = (f"cd /opt/setops/{dest} && base=$(git remote get-url origin) && base=${{base%/*}} && "
f"for n in {' '.join(prives)}; do "
"if out=$(git ls-remote \"$base/$n.git\" HEAD 2>&1 >/dev/null); then echo \"MESURE $n lu\"; "
"else echo \"MESURE $n $(echo $out | tr '\\n' ' ')\"; fi; done")
r = subprocess.run(["ansible", "-i", str(loc.inventaire()), runners[0], "-b",
"--become-user", "setops", "-m", "shell", "-a", script],
capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
mesures = {}
for ligne in r.stdout.splitlines():
if ligne.startswith("MESURE "):
_m, n, reste = ligne.split(" ", 2)
mesures[n] = ("lu", "") if reste.strip() == "lu" else (classer_ssh(1, reste), reste.strip())
if not mesures:
ecarts.append(f"{loc.nom} : aucune mesure rendue par {runners[0]} ({(r.stderr or r.stdout)[-160:]})")
print(f" ECART aucune mesure rendue")
continue
for n in prives:
verdict, detail = mesures.get(n, ("absent", ""))
attendu = "lu" if n == sien else "refuse"
ok = verdict == attendu
print(f" {'OK ' if ok else 'ECART'} {n:<18} -> {verdict}"
+ ("" if ok else f" (attendu : {attendu}) {detail[:90]}"))
if not ok:
ecarts.append(f"{loc.nom} -> {n} : {verdict}, attendu {attendu}")
return ecarts
def main() -> int:
dom, depots, racine_ac = _site_et_depots()
if not dom or not depots:
print("REFUS : aucun site monte, ou son plan ne declare pas `serveur_ops_depots`.")
return 2
ecarts = mesurer_https(dom, depots, racine_ac) + mesurer_runners(depots)
if ecarts:
print(f"\nNON ETANCHE : {len(ecarts)} ecart(s).")
return 1
print("\nETANCHE : chaque runner lit son depot, aucun celui d'un autre ; le public reste public.")
return 0
if __name__ == "__main__":
raise SystemExit(main())