Set-OPS-Public/scripts/applications.py
Daniel Allaire 4462c13b3d P03 : la preuve comparait chaque instance a l'inventaire d'UNE SEULE
Trouve en validant une mise a jour du CHANGELOG. Deux invocations de la meme preuve,
deux verdicts : `make prouver` -> NON CONFORME (« lab : 17 hotes avec ecart »),
`python3 scripts/prouver.py` -> CONFORME 37/37. Le lab n'avait aucun ecart.

DEUX VARIABLES DESIGNENT LA CIBLE, ET LA SECONDE GAGNE. Le Makefile exporte
SETOPS_INVENTAIRE (ligne 13), derive de l'instance ACTIVE ; instancier.py:68 lui fait
FORCER la cible par-dessus SETOPS_INSTANCE. P03 (prouver.py:505) ne redirigeait que
SETOPS_INSTANCE : elle generait le plan de CHAQUE instance federee et le comparait a
l'inventaire applique de la SEULE instance active.

LE ROUGE N'ETAIT PAS LE PROBLEME, LE VERT L'ETAIT. Sous `make`, l'inventaire applique
de lab et de Technolibre n'etait JAMAIS lu — l'angle meme pour lequel P03 a ete ecrite
le 2026-08-12 (un tenant qu'on ne regarde pas imposant ses vieilles adresses au pare-feu
partage). La preuve etait aveugle a son propre cas, par l'invocation documentee. Les
rapports du 13 et du 14 sortent de cette invocation-la. Signature visible sans lire le
code : les hosts.genere.yml de lab et de Technolibre ne bougeaient pas.

CORRECTIF, cinq sites : env.pop("SETOPS_INVENTAIRE") partout ou l'on redirige
SETOPS_INSTANCE — P03 et P15, plus les trois applicateurs (opnsense, proxmox_fw, sdn) qui
pointent vers l'HEBERGEUR. Ces trois sont sans effet tant qu'hebergeur et tenant actif
coincident, c'est-a-dire jusqu'au second site. Le geste existait deja (modeles.py:96).

GARDE, pour que la classe cesse d'etre silencieuse : inventory_rules.inventaire_force()
REFUSE une cible hors de l'instance visee, en nommant les deux valeurs. Eprouvee dans les
deux sens (contradiction -> code 1 ; cible legitime dans l'instance -> passe). Branchee
sur les quatre resolutions de _inventaire (instancier, serveurs, applications,
config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un
fils et resout par symlink a chaque requete.

make prouver : 37 OK, 0 echec, 0 saute — et les hosts.genere.yml des TROIS instances
portent l'horodatage du passage. make test 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:35:01 -04:00

173 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Lit et valide le registre des applications Set-OPS (instance/plan/applications.yml)."""
from __future__ import annotations
import os
import argparse
import sys
from pathlib import Path
import yaml
from inventory_gui import charger_yaml, liste_hotes
from inventory_rules import (
bases_de_application,
chaine_connexion,
charger_applications,
charger_bases_donnees,
charger_domaines,
charger_serveurs,
inventaire_force,
valider_applications,
)
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
FICHIER = INSTANCE / "plan/applications.yml"
FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml"
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
def _inventaire(instance: Path, *noms: str) -> Path:
forced = inventaire_force(instance) # refuse une cible hors de `instance`
if forced:
return forced
for nom in noms:
p = instance / "inventories" / nom / "hosts.yml"
if p.exists():
return p
return instance / "inventories" / noms[0] / "hosts.yml"
INVENTAIRE = _inventaire(INSTANCE, "principal", "production")
# Groupes 'serveurs_*' qui sont des capacites de SOCLE (sur toutes les VM),
# pas des applications a part entiere.
GROUPES_SOCLE = {"serveur_debian", "serveur_durci"}
def _liste(valeur: str) -> list:
return [v.strip() for v in (valeur or "").split(",") if v.strip()]
def ecrire(registre: dict) -> None:
entete = (
"# Registre des applications Set-OPS (application = entite pivot).\n"
"# Edite par make inventaire-ui ou scripts/applications.py.\n"
"# Une VM peut porter plusieurs applications ; une base se lie a une application.\n"
"---\n"
)
with FICHIER.open("w", encoding="utf-8") as fichier:
fichier.write(entete)
yaml.safe_dump({"applications": registre.get("applications", {}) or {}},
fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def lister(registre: dict, bases: dict) -> None:
apps = registre.get("applications", {})
if not apps:
print("Aucune application declaree.")
return
for nom, app in apps.items():
extras = []
if app.get("port"):
extras.append(f"port {app['port']}")
if app.get("requiert"):
extras.append(f"requiert {', '.join(app['requiert'])}")
if app.get("expose"):
extras.append(f"expose {', '.join(app['expose'])}")
suffixe = (" {" + " ; ".join(extras) + "}") if extras else ""
print(f"{nom} [groupe {app.get('groupe', '?')}, hote {app.get('hote', '?')}]{suffixe}")
for entree in bases_de_application(bases, application=nom,
groupe=app.get("groupe"), hote=app.get("hote")):
base = entree["base"]
portee = base.get("portee", "groupe")
print(f" DSN ({portee}/{base.get('usage', '-')}): {chaine_connexion(base, entree['serveur'])}")
def bootstrap(registre_existant: dict) -> dict:
"""(Re)genere les applications depuis les appartenances de groupes de l'inventaire.
Un groupe 'serveurs_*' (hors socle) sur un hote = une application. Les entrees
existantes correspondant a un couple (groupe, hote) toujours present sont
preservees telles quelles (port / expose / requiert ...).
"""
existant = registre_existant.get("applications") or {}
par_cle = {(a.get("groupe"), a.get("hote")): (cle, a) for cle, a in existant.items()}
apps: dict = {}
for h in liste_hotes(charger_yaml(INVENTAIRE)):
hote = h.get("nom")
if not hote:
continue
for groupe in sorted(h.get("groupes", [])):
if not groupe.startswith("serveurs_") or groupe in GROUPES_SOCLE:
continue
garde = par_cle.get((groupe, hote))
if garde:
apps[garde[0]] = garde[1]
continue
base = groupe[len("serveurs_"):]
app_id = base if base not in apps else f"{base}-{hote}"
apps[app_id] = {"groupe": groupe, "hote": hote}
return {"applications": apps}
def main() -> int:
parser = argparse.ArgumentParser(description="Registre des applications Set-OPS.")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Affiche les applications et leurs DSN resolus.")
sub.add_parser("verifier", help="Valide la coherence du registre.")
sub.add_parser("bootstrap", help="(Re)genere applications.yml depuis les groupes de l'inventaire.")
pa = sub.add_parser("ajouter", help="Ajoute ou met a jour une application.")
pa.add_argument("--id", required=True)
pa.add_argument("--groupe", required=True)
pa.add_argument("--hote", required=True)
pa.add_argument("--port", type=int)
pa.add_argument("--requiert", default="", help="Applications dont elle depend (separees par virgule).")
pa.add_argument("--expose", default="", help="FQDN publics qui la publient (separes par virgule).")
pr = sub.add_parser("retirer", help="Retire une application.")
pr.add_argument("--id", required=True)
args = parser.parse_args()
try:
registre = charger_applications(FICHIER)
domaines = charger_domaines(FICHIER_DOMAINES)
serveurs = charger_serveurs(FICHIER_SERVEURS)
if args.commande == "lister":
lister(registre, charger_bases_donnees(FICHIER_BASES))
elif args.commande == "verifier":
valider_applications(registre, domaines, serveurs)
print("Registre des applications valide.")
elif args.commande == "bootstrap":
registre = bootstrap(registre)
valider_applications(registre, domaines, serveurs)
ecrire(registre)
print(f"instance/plan/applications.yml genere depuis l'inventaire ({len(registre['applications'])} applications).")
elif args.commande == "ajouter":
app = {"groupe": args.groupe, "hote": args.hote}
if args.port is not None:
app["port"] = args.port
if _liste(args.requiert):
app["requiert"] = _liste(args.requiert)
if _liste(args.expose):
app["expose"] = _liste(args.expose)
registre["applications"][args.id] = app
valider_applications(registre, domaines, serveurs)
ecrire(registre)
print(f"Application '{args.id}' enregistree.")
elif args.commande == "retirer":
registre["applications"].pop(args.id, None)
valider_applications(registre, domaines, serveurs)
ecrire(registre)
print(f"Application '{args.id}' retiree.")
except Exception as exc:
print(f"erreur: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())