console : chaque bout du contrat site-locataire se lit dans sa console

Deux routes en lecture, /api/lien-site et /api/locataires-heberges, et deux vues
sous Veille : le locataire voit sa face reseau publiee et la fiche que son site lui
a deposee, le site voit ce que chaque locataire lui a publie. Chaque fichier est
montre avec son commit, que l'autre bout lit.

Un site de reprise ne juge plus la fiche deposee par l'hebergeur actif : il la
declarait perimee a chaque lecture (SITE-Technolibre). Un test le garde.

94 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 17:31:13 -04:00
parent 493a5dcbeb
commit 9592309f36
4 changed files with 600 additions and 5 deletions

View file

@ -1,5 +1,46 @@
# CHANGELOG — Set-OPS
## 2026-10-11 (128) — Le site et ses locataires se lisaient par deux fichiers que leurs consoles ne montraient pas
**94 preuves.** `make verifier` passe. Le banc de rendu dessine les deux nouvelles vues sur
les vraies réponses de leurs routes, et `test_portee_console.py` compte deux cas de plus.
Les deux consoles ont été regardées en capture. Aucun pouvoir ni geste n'est ajouté : les
deux routes sont en lecture.
### Deux fichiers passent entre un site et un locataire, et aucune console ne les montrait
Le locataire publie sa face réseau (`face-reseau.yml`), le site lui dépose sa fiche
(`fiche-site.yml`). Pour savoir ce que l'autre bout avait lu, il fallait ouvrir le dépôt et
lire l'historique. Chaque console a maintenant une vue « Veille » qui montre son bout du
contrat, avec le commit de chaque fichier : c'est le commit que l'autre lit, pas le disque.
- **Lien avec le site** (console du locataire). D'un côté, la face réseau : si elle est à
jour avec le plan (la règle de P92), ses machines, ses zones, ce qu'elle expose à
Internet et ce qu'elle demande d'y joindre, chaque ligne avec le rôle qui la déclare. De
l'autre, la fiche du site : l'index et l'adresse publique attribués, et les services
offerts, nommés en clair. Sur le runner d'un locataire, la carte du site n'est pas montée :
la fiche y est lue sans pouvoir être jugée, et la page le dit.
- **Locataires hébergés** (console du site). Une ligne par locataire : son index et ses VLAN,
le commit de la face lue, ses machines actives, son adresse publique, et si la fiche
déposée correspond encore à ce que le site calcule (la règle de P91). La fiche d'un
locataire montre ce qu'il demande et les gestes qui lui reviennent dans « Tenir un site en
état ».
Le poste du mainteneur monte les deux dépôts : il voit les deux vues.
### Un site de reprise déclarait périmée une fiche qui n'était pas la sienne
Mesuré pendant la construction sur `SITE-Technolibre`, qui déclare les deux locataires de
`SITE-Chezlepro` comme site de reprise. Il comparait leur fiche, déposée par leur hébergeur
actif, à celle qu'il leur aurait lui-même destinée, et la déclarait périmée à chaque lecture.
Cet écart faux aurait appris à ne plus regarder cette colonne. Le site ne juge plus que les
fiches des locataires dont la face le nomme hébergeur. Un test le garde, et il échoue quand
on retire la condition.
**Limite.** « Appliquée par le site » est affiché « non rapporté ». Le site ne dit pas encore
quand il a posé une face à sa frontière, et ces vues ne sondent rien. La frontière, le
pare-feu Proxmox et la réception des sauvegardes se mesurent toujours par leurs assistants.
## 2026-10-11 (127) — Deux consoles servaient la même page ; chacune dit maintenant qui elle est
**94 preuves.** `make verifier` passe, le banc de rendu (`test_rendu_gui.py`) aussi, et les

View file

@ -653,6 +653,15 @@ class Console:
def fabric(self) -> str | None:
return None
# LES DEUX COTES DU CONTRAT (lot 2, 2026-10-11). Un locataire lit ce qu'il publie a son
# site et ce que le site lui a depose ; un site lit ce que chacun de ses locataires lui
# a publie. None = cette console n'a pas ce cote-la, et la page ne l'offre pas.
def lien_avec_le_site(self) -> dict | None:
return None
def locataires_heberges(self) -> dict | None:
return None
# --- LE TRONC : UN SEUL ENDROIT OU LA CHARGE SE COMPOSE ---------------------------
def contexte(self) -> dict:
@ -667,6 +676,11 @@ class Console:
# QUELLE SOURCE D'INVENTAIRE : un site n'a pas de `hosts.yml`, son inventaire
# est un SCRIPT. Une console qui a quelque chose a piloter le NOMME.
"inventaire": self.nom_inventaire() or None,
# LES COTES DU CONTRAT QUE CETTE CONSOLE LIT : la page n'offre que ceux-la.
"contrat": {
"lien_avec_le_site": type(self).lien_avec_le_site is not Console.lien_avec_le_site,
"locataires_heberges": type(self).locataires_heberges is not Console.locataires_heberges,
},
}
def peut_conduire(self, portee_etape: str) -> tuple[bool, str]:
@ -754,6 +768,9 @@ class ConsoleLocataire(Console):
def intrants(self):
return intrants_api()
def lien_avec_le_site(self):
return lien_du_locataire(self._instance.resolve())
def pouvoirs(self):
return {"editer_le_plan": True, "configurer": self._a_voute,
"materialiser": False, "fabric": False}
@ -808,6 +825,9 @@ class ConsoleSite(Console):
def est_production(self):
return True
def locataires_heberges(self):
return locataires_du_site(self._depot)
def serveurs(self, data):
"""Les serveurs DECLARES du site, dans la forme que la page sait lire.
@ -884,6 +904,10 @@ class ConsolePoste(ConsoleLocataire):
def fabric(self):
return self._depot.name
def locataires_heberges(self):
# Le poste monte la carte du site : il voit les deux cotes du contrat.
return locataires_du_site(self._depot)
def pouvoirs(self):
p = super().pouvoirs()
p["materialiser"] = self._a_voute_site
@ -904,6 +928,165 @@ class ConsolePoste(ConsoleLocataire):
return r
# --- LE CONTRAT SITE <-> LOCATAIRE, EN LECTURE (lot 2, 2026-10-11) -----------------------
#
# Entre un site et un locataire passent deux fichiers, et seulement eux : la face reseau
# (`face-reseau.yml`, le locataire la publie) et la fiche du site (`fiche-site.yml`, le site
# la depose). Ces deux lectures les montrent a chaque bout, avec leur commit — c'est le
# commit qui dit ce que l'autre a pu lire. Rien n'est sonde ici : ce qui n'est pas dans
# ces fichiers n'est pas affirme, et la page le dit au lieu de le deviner.
def _trace_git(depot: Path, fichier: str) -> dict:
"""Le dernier commit qui touche ce fichier, et s'il porte des changements non commites.
Un fichier modifie mais non commite n'a pas encore ete transmis : l'autre bout lit
le commit, pas le disque."""
try:
r = subprocess.run(["git", "-C", str(depot), "log", "-1", "--format=%h%x09%cI", "--", fichier],
capture_output=True, text=True, timeout=5)
s = subprocess.run(["git", "-C", str(depot), "status", "--porcelain", "--", fichier],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired) as exc:
return {"commit": None, "date": None, "modifie": False, "erreur": str(exc)}
if r.returncode != 0:
return {"commit": None, "date": None, "modifie": False,
"erreur": ((r.stderr or "").strip().splitlines() or ["git refuse"])[-1]}
commit, _, date = r.stdout.strip().partition("\t")
return {"commit": commit or None, "date": date or None,
"modifie": bool(s.stdout.strip()) if s.returncode == 0 else False}
def resume_face(face: dict) -> dict:
"""Ce qu'une face reseau demande, compte et liste pour un humain. Les parametres de
clonage et les sources des flux internes restent dans le fichier."""
machines = face.get("machines") or {}
vlans = sorted({int(v) for m in machines.values()
for v in [(m.get("materialisation") or {}).get("proxmox_vlan")]
if str(v or "").isdigit()})
sorties, expositions = [], []
for h, d in sorted((face.get("flux") or {}).items()):
# Le ICMP « frag-needed » est sur chaque machine (decouverte du MTU) : il n'apprend
# rien a qui lit, et noierait les lignes qui comptent.
for s in d.get("sorties_externes") or []:
if s.get("proto") != "icmp":
sorties.append({"hote": h, "proto": s.get("proto"), "port": str(s.get("port")),
"role": s.get("role")})
for e in d.get("entrees") or []:
if "0.0.0.0/0" in (e.get("sources") or []) and e.get("proto") != "icmp":
expositions.append({"hote": h, "proto": e.get("proto"), "port": str(e.get("port")),
"role": e.get("role")})
adm = face.get("administration") or {}
return {
"index": face.get("index"),
"hebergeur": face.get("hebergeur"),
"machines": len(machines),
"actives": sum(1 for m in machines.values() if m.get("etat") == "actif"),
"planifiees": sorted(h for h, m in machines.items() if m.get("etat") != "actif"),
"zones": len(face.get("zones") or []),
"vlans": [vlans[0], vlans[-1]] if vlans else [],
"sorties": sorties,
"expositions": expositions,
"dns_public": (face.get("dns_public") or {}).get("zones") or [],
"administration": {"reseaux": adm.get("intrant") or [], "tunnel": adm.get("tunnel") or [],
"pairs": len(adm.get("pairs") or {})},
"sauvegarde": bool((face.get("sauvegarde") or {}).get("cle_publique")),
}
def lien_du_locataire(instance: Path) -> dict:
"""LE COTE LOCATAIRE : ce que je publie a mon site, et ce qu'il m'a depose.
« A jour » se mesure ici, chez le locataire qui seul sait calculer sa face (P92 la
meme regle). La fiche, elle, ne se juge que si la carte du site est montee (poste) ;
sur le runner d'un locataire, elle se lit sans pouvoir se juger, et la page le dit.
"""
import contexte as _ctx
loc = _ctx.Locataire(instance)
face = loc.face_publiee()
try:
ecarts_face = _ctx.verifier_face_publiee(loc)
except Exception as exc: # une face incalculable ne doit pas masquer la publiee
ecarts_face = [f"face incalculable ici : {exc}"]
fiche = loc.fiche_du_site() or {}
try:
site = loc.site()
except _ctx.ContexteInvalide:
site = None
ecarts_fiche = None
if site is not None and fiche:
try:
ecarts_fiche = _ctx.verifier_fiche_deposee(site, loc)
except Exception as exc:
ecarts_fiche = [f"fiche injugeable ici : {exc}"]
return {
"locataire": loc.nom,
"hebergeur": loc.nom_hebergeur(),
"face": {"publiee": face is not None,
"trace": _trace_git(loc.depot, _ctx.NOM_FICHIER_FACE_RESEAU),
"ecarts": ecarts_face,
"resume": resume_face(face) if face else None},
"fiche": {"presente": bool(fiche),
"trace": _trace_git(loc.depot, _ctx.NOM_FICHIER_FICHE_SITE) if fiche else None,
"site": fiche.get("site"),
"attribue": fiche.get("attribue") or {},
"offre": fiche.get("offre") or {},
"generation": fiche.get("generation") or {},
"racine": (fiche.get("racine_confiance") or {}).get("sha256", "")[:12],
"jugeable": site is not None,
"ecarts": ecarts_fiche},
# Le site ne rapporte pas encore quand il a pose cette face a sa frontiere. Le dire
# vaut mieux qu'un « applique » que rien ne mesure.
"retour_du_site": None,
}
def locataires_du_site(depot: Path) -> dict:
"""LE COTE SITE : ce que chacun de mes locataires m'a publie, et ce que je lui ai depose.
Le site ne lit de son locataire que sa face publiee — le commit present ici est donc ce
que ses gestes (`locataire-creer`, `creer-vm`, la frontiere) liront. La fiche se juge
contre ce que le site calcule aujourd'hui (P91 la meme regle).
"""
import contexte as _ctx
site = _ctx.Site(depot)
allocations = site.allocations()
sortie = []
for loc in site.locataires():
face = loc.face_publiee()
try:
attribue = site.fiche_pour(loc).get("attribue") or {}
except Exception as exc:
attribue = {"erreur": str(exc)}
# UN LOCATAIRE PEUT FIGURER CHEZ DEUX SITES : chez l'hebergeur que sa face nomme,
# et chez un site de reprise. Ce n'est pas le meme engagement.
relation = ("actif" if (face or {}).get("hebergeur") == site.nom else
"reprise" if face else "inconnue")
# LA FICHE QU'IL PORTE VIENT DE SON HEBERGEUR ACTIF. Un site de reprise qui la jugeait
# contre la sienne la declarait perimee a chaque lecture (mesure du 2026-10-11 sur
# SITE-Technolibre) : un faux ecart, qui aurait appris a ne plus regarder celui-ci.
if relation == "actif":
try:
ecarts_fiche = _ctx.verifier_fiche_deposee(site, loc)
except Exception as exc:
ecarts_fiche = [f"fiche injugeable ici : {exc}"]
else:
ecarts_fiche = None
resume = resume_face(face) if face else None
sortie.append({
"nom": loc.nom,
"index_attribue": allocations.get(loc.nom),
"ip_publique": attribue.get("ip_publique"),
"relation": relation,
"face": {"publiee": face is not None,
"trace": _trace_git(loc.depot, _ctx.NOM_FICHIER_FACE_RESEAU),
"resume": resume},
"fiche": {"deposee": loc.fiche_du_site() is not None,
"trace": _trace_git(loc.depot, _ctx.NOM_FICHIER_FICHE_SITE),
"ecarts": ecarts_fiche},
})
return {"site": site.nom, "index": site.index, "locataires": sortie,
"absents": site.locataires_absents()}
def console(inventaire: Path | None = None) -> Console:
"""QUELLE console sert cette machine — derive de ce qu'elle peut OUVRIR.
@ -2169,6 +2352,37 @@ button.prod:hover:not(:disabled) { background: #8f2424; border-color: #c94a4a; }
.rb-etape.bloquee { opacity: .55; }
.noeud.selectionnee { border-color: var(--identite); box-shadow: 0 0 0 1px var(--identite); }
.rb-etape button.destructif { background: transparent; border-color: #b23b3b; color: var(--rouge); }
/* --- LOT 2 (2026-10-11) : LES DEUX BOUTS DU CONTRAT SITE <-> LOCATAIRE --------------
Deux fichiers passent entre eux, chacun a sa carte ; le commit dit ce que l'autre lit. */
.contrat { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 14px; margin-bottom: 18px; }
.carte-contrat { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); display: flex; flex-direction: column; }
.carte-contrat > .tete { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--ligne); }
.carte-contrat > .tete h3 { margin: 0; font-size: 14.5px; font-weight: 750; }
.carte-contrat > .tete .fichier { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--faint); }
.carte-contrat > .corps { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
.trace { font-size: 12.5px; color: var(--muted); }
.trace .mono { color: var(--texte); }
.chiffres { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
.chiffre { display: flex; flex-direction: column; gap: 1px; padding: 8px 10px; border-radius: 7px; background: var(--surface-2); border: 1px solid var(--ligne); }
.chiffre .v { font-size: 16px; font-weight: 650; }
.chiffre .l { font-size: 11.5px; color: var(--faint); }
.sous-tete { font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--faint); margin: 4px 0 6px; }
.carte-contrat .sous-tete { margin: 2px 0 -6px; }
.lignes { display: flex; flex-direction: column; border: 1px solid var(--ligne); border-radius: 7px; overflow: hidden; }
.ligne { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 10px; align-items: center; min-height: 32px; padding: 0 10px;
border-bottom: 1px solid var(--ligne); font-size: 12.5px; }
.ligne:last-child { border-bottom: 0; }
.ligne .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ligne.deux { grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); }
.ecarts { margin: 0; padding: 8px 10px 8px 26px; border-radius: 7px; background: var(--ambre-fond); color: #f3dca0; font-size: 12.5px; }
.note-contrat { font-size: 12px; color: var(--faint); line-height: 1.45; }
.rang.loc { grid-template-columns: 1.3fr 1fr .9fr .45fr .95fr .85fr; min-height: 54px; }
.tableau-corps.loc { min-width: 640px; }
.rang .pile { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
.rang .pile .bas { font-size: 11.5px; color: var(--faint); white-space: nowrap; }
.qui-fait { display: flex; flex-direction: column; gap: 10px; }
.qui-fait .qui { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; font-size: 12.5px; color: var(--muted); line-height: 1.4; }
.qui-fait .qui b { color: var(--texte); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
@media (max-width: 1000px) {
.cadre { flex-direction: column; }
#navigation { flex: none; border-right: 0; border-bottom: 1px solid var(--ligne); }
@ -2424,8 +2638,15 @@ let selMachine = null;
const nbRb = (typeof runbooks !== 'undefined' && runbooks.length)
? runbooks.filter(rb => rb.conduisible).length : '';
const aNomenclature = Object.keys((nomenclature || {}).fonctions || {}).length > 0;
// LE CONTRAT AVEC L'AUTRE BOUT (lot 2) : en tete, parce que c'est ce qu'on regarde
// avant d'agir. Le poste du mainteneur monte les deux bouts, il voit les deux.
const veille = [];
if (aCote('lien_avec_le_site')) veille.push(['lien', 'Lien avec le site', '']);
if (aCote('locataires_heberges')) veille.push(['locataires', 'Locataires hébergés',
locatairesCharge ? n((locatairesCharge.locataires || []).length) : '']);
const enTete = veille.length ? [{titre: 'Veille', vues: veille}] : [];
if (peut('editer_le_plan')) {
return [
return [...enTete,
{titre: 'Exploiter', vues: [
['serveurs', 'Parc', n(serveurs.length)],
['assistants', 'Assistants', n(nbRb)]]},
@ -2450,7 +2671,7 @@ let selMachine = null;
lecture.push(['integrations', 'Intégrations', ''],
['flux', 'Flux', n(fluxRegistre.length)],
['couches', 'Couches de déploiement', n(couchesRegistre.length)]);
return [
return [...enTete,
{titre: 'Le terrain', vues: [
['serveurs', 'Machines & fabric', n(hotes.length)],
['reseau', 'Réseau & frontière', '']]},
@ -2494,6 +2715,7 @@ let selMachine = null;
}
async function charger() {
lienCharge = null; locatairesCharge = null; // « Recharger » relit aussi le contrat
try {
const ctxRep = await fetch('/api/contexte');
if (ctxRep.ok) appliquerContexte(await ctxRep.json());
@ -2603,6 +2825,8 @@ let selMachine = null;
function setVue(v) {
vuePrincipale = v;
if (v === 'lien') lienCharge = null;
if (v === 'locataires') locatairesCharge = null;
if (v === 'serveurs' && selServeur == null && serveurs.length) selServeur = 0;
if (v === 'applications' && selApp == null && applications.length) selApp = 0;
if (v === 'bases' && selBase == null) selBase = bdServeurs.length ? 'srv:0' : (bdApplis.length ? 'bd:0' : null);
@ -2635,6 +2859,8 @@ let selMachine = null;
if (vuePrincipale === 'couches') { dessinerCouches(); return; }
if (vuePrincipale === 'reseau') { dessinerReseau(); return; }
if (vuePrincipale === 'assistants') { dessinerAssistants(); return; }
if (vuePrincipale === 'lien') { dessinerLienSite(); return; }
if (vuePrincipale === 'locataires') { dessinerLocataires(); return; }
dessinerServeurs();
}
@ -3950,6 +4176,232 @@ let selMachine = null;
</div>`;
}
// LES DEUX BOUTS DU CONTRAT (lot 2, 2026-10-11).
//
// Entre un site et un locataire passent deux fichiers : la face reseau, que le
// locataire publie, et la fiche, que le site depose. Chaque console montre son bout,
// avec le commit — c'est le commit que l'autre lit, pas le disque. Rien n'est sonde :
// ce que ces fichiers ne disent pas, la page le dit absent au lieu de le deviner.
let lienCharge = null; // /api/lien-site
let locatairesCharge = null; // /api/locataires-heberges
let selLocataire = 0;
const aCote = (c) => !!(contexte && contexte.contrat && contexte.contrat[c]);
function quand(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString('fr-CA', {day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'});
}
function ligneTrace(t) {
if (!t) return '<span class="trace">aucune trace</span>';
if (!t.commit) return `<span class="trace">jamais commité${t.erreur ? ' — ' + echapper(t.erreur) : ''}</span>`;
return `<span class="trace">commit <span class="mono">${echapper(t.commit)}</span> · ${echapper(quand(t.date))}</span>`
+ (t.modifie ? ' <span class="etiq e-plan" title="Le fichier sur disque diffère du dernier commit : l\'autre bout lit le commit.">modifié, pas encore commité</span>' : '');
}
function lignesFlux(liste, vide) {
if (!liste || !liste.length) return `<div class="note-contrat">${echapper(vide)}</div>`;
return '<div class="lignes">' + liste.map(f => `<div class="ligne">
<span>${echapper(f.hote)}</span><span class="mono">${echapper(f.proto)} ${echapper(f.port)}</span>
<span class="mono" title="Le rôle qui déclare ce flux">${echapper(f.role)}</span></div>`).join('') + '</div>';
}
async function lireContrat(route) {
const r = await fetch(route, {headers: {'X-Jeton': JETON}});
const d = await r.json();
if (!r.ok) throw new Error(d.erreur || ('HTTP ' + r.status));
return d;
}
// CE QUE LA FICHE OFFRE, EN MOTS. Une clef inconnue reste affichee telle quelle :
// la cacher parce qu'on ne sait pas la nommer retirerait une information.
const SERVICES_DE_LA_FICHE = [
['dns_amorcage', 'Résolution de noms', 'au premier démarrage, avant mon propre résolveur'],
['artefacts_amorcage', 'Cache d\'artefacts', 'paquets apt'],
['setops_depot_binaires', 'Dépôt des roues et collections', 'ce que le runner installe sans Internet'],
['serveur_ops_forge_amont', 'Forge du génome', 'le moteur que mon runner suit'],
['client_backup_cible', 'Dépôt de sauvegarde', 'reçoit mon chiffré ; la vérification reste chez moi'],
['dns_public_site', 'DNS public secondaire', ''],
['dns_public_nom', 'Nom du DNS public', ''],
['ip_publique_site', 'Adresse publique du site', ''],
['passerelle_sortie', 'Sortie vers Internet', 'seulement ce que mes rôles déclarent'],
['nftables_admin_ssh', 'Réseaux d\'administration', 'ceux qui entrent en SSH chez moi'],
];
const IDENTITE_DE_LA_FICHE = ['domaine_site', 'index_site'];
async function dessinerLienSite() {
const cible = document.getElementById('grilles');
// Lu a l'ouverture de la vue (`setVue` vide le cache), pas a chaque redessin.
if (!lienCharge) {
cible.innerHTML = '<div class="vide" style="padding:14px">Lecture de la face réseau et de la fiche du site…</div>';
try { lienCharge = await lireContrat('/api/lien-site'); }
catch (e) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(e.message) + '</div>'; return; }
}
const L = lienCharge, F = L.face, R = F.resume, Fi = L.fiche;
const site = L.hebergeur || Fi.site || 'mon site';
const entete = `<div class="vue-tete"><div style="display:flex;flex-direction:column;gap:2px">
<h2 class="vue-titre">Lien avec le site</h2>
<span class="vue-sous">Ce que je publie à ${echapper(site)}, et ce qu'il m'a déposé. Deux fichiers passent entre nous, rien d'autre.</span></div></div>`;
// MA FACE RESEAU
let etatFace;
if (!F.publiee) etatFace = '<span class="etiq e-bloq">jamais publiée</span>';
else if ((F.ecarts || []).length) etatFace = '<span class="etiq e-div">à republier</span>';
else etatFace = '<span class="etiq e-actif">à jour avec mon plan</span>';
const face = !F.publiee ? `<div class="corps"><div class="note-contrat">Le site ne peut rien créer ni ouvrir pour moi sans elle.
<span class="mono">make face-reseau-publier</span>, puis commiter <span class="mono">face-reseau.yml</span>.</div></div>` : `
<div class="corps">
<div>${ligneTrace(F.trace)}</div>
${(F.ecarts || []).length ? `<ul class="ecarts">${F.ecarts.map(e => `<li>${echapper(e)}</li>`).join('')}</ul>
<div><button type="button" class="fantome" onclick="ouvrirAssistant('locataire-materialiser')">Assistant : republier la face</button></div>` : ''}
<div class="chiffres">
<div class="chiffre"><span class="v">${R.machines}</span><span class="l">machines${R.planifiees.length ? `, dont ${R.planifiees.length} pas encore actives` : ''}</span></div>
<div class="chiffre"><span class="v">${R.zones}</span><span class="l">zones${R.vlans.length ? ` · VLAN ${R.vlans[0]}–${R.vlans[1]}` : ''}</span></div>
<div class="chiffre"><span class="v">${R.expositions.length}</span><span class="l">entrées depuis Internet</span></div>
<div class="chiffre"><span class="v">${R.sorties.length}</span><span class="l">sorties vers Internet</span></div>
</div>
${R.planifiees.length ? `<div class="note-contrat">Pas encore actives : <span class="mono">${echapper(R.planifiees.join(' · '))}</span>. Le site ne les crée que sur demande.</div>` : ''}
<p class="sous-tete">Ce que j'expose à Internet</p>
${lignesFlux(R.expositions, 'Aucune entrée depuis Internet.')}
<p class="sous-tete">Ce que je demande de joindre sur Internet</p>
${lignesFlux(R.sorties, 'Aucune sortie vers Internet : tout passe par les services du site.')}
${R.dns_public.length ? `<div class="note-contrat">Zones publiques que le DNS du site sert pour moi : <span class="mono">${echapper(R.dns_public.join(', '))}</span>.</div>` : ''}
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-top:1px solid var(--ligne);padding-top:10px">
<span class="etiq e-recon">appliquée par le site : non rapporté</span>
<span class="note-contrat">Le site ne rapporte pas encore quand il pose cette face à sa frontière. Pour le savoir aujourd'hui, lui demander son <span class="mono">make frontiere-plan</span>.</span>
</div>
</div>`;
// LA FICHE DU SITE
let etatFiche;
if (!Fi.presente) etatFiche = '<span class="etiq e-bloq">absente</span>';
else if (!Fi.jugeable) etatFiche = '<span class="etiq e-recon" title="La carte du site n\'est pas montée sur cette machine : seul le site, ou le poste du mainteneur, peut dire si elle est à jour.">lue, non jugeable d\'ici</span>';
else if ((Fi.ecarts || []).length) etatFiche = '<span class="etiq e-div">périmée</span>';
else etatFiche = '<span class="etiq e-actif">conforme à la carte du site</span>';
const off = Fi.offre || {}, gen = Fi.generation || {};
const connus = new Set([...SERVICES_DE_LA_FICHE.map(x => x[0]), ...IDENTITE_DE_LA_FICHE]);
const valeur = (v) => Array.isArray(v) ? v.join(', ') : String(v);
const services = SERVICES_DE_LA_FICHE.filter(([k]) => off[k] != null && off[k] !== '')
.map(([k, nom, usage]) => `<div class="ligne"><span title="${echapper(usage)}">${echapper(nom)}</span>
<span class="mono" style="grid-column:span 2" title="${echapper(k)}">${echapper(valeur(off[k]))}</span></div>`)
.concat(Object.keys(off).filter(k => !connus.has(k)).map(k => `<div class="ligne"><span class="mono">${echapper(k)}</span>
<span class="mono" style="grid-column:span 2">${echapper(valeur(off[k]))}</span></div>`))
.concat((gen.serveurs_temps || []).length ? [`<div class="ligne"><span>Heure</span><span class="mono" style="grid-column:span 2">${echapper(gen.serveurs_temps.join(', '))}</span></div>`] : [])
.concat((gen.resolveurs_site || []).length ? [`<div class="ligne"><span>Résolveurs du site</span><span class="mono" style="grid-column:span 2">${echapper(gen.resolveurs_site.join(', '))}</span></div>`] : []);
const fiche = !Fi.presente ? `<div class="corps"><div class="note-contrat">Mon inventaire se génère d'après elle.
Le site la dépose (<span class="mono">make fiches-site-deposer</span>) ; je la commite chez moi.</div></div>` : `
<div class="corps">
<div>${ligneTrace(Fi.trace)}</div>
${(Fi.ecarts || []).length ? `<ul class="ecarts">${Fi.ecarts.map(e => `<li>${echapper(e)}</li>`).join('')}</ul>` : ''}
<div class="chiffres">
<div class="chiffre"><span class="v mono">${echapper(String((Fi.attribue || {}).index ?? '—'))}</span><span class="l">mon index, attribué</span></div>
<div class="chiffre"><span class="v mono" style="font-size:14px">${echapper((Fi.attribue || {}).ip_publique || '—')}</span><span class="l">mon adresse publique</span></div>
<div class="chiffre"><span class="v mono" style="font-size:14px">${echapper(gen.routage_tenants || '—')}</span><span class="l">routage de mes zones</span></div>
</div>
<p class="sous-tete">Ce que le site m'offre</p>
<div class="lignes">${services.join('') || '<div class="ligne">Aucun service déclaré.</div>'}</div>
<div class="note-contrat">${echapper(Fi.site || site)}${off.index_site != null ? ' · index ' + echapper(String(off.index_site)) : ''}${off.domaine_site ? ' · <span class="mono">' + echapper(off.domaine_site) + '</span>' : ''}${Fi.racine ? ` · racine de confiance <span class="mono">${echapper(Fi.racine)}…</span>` : ''}.
Ce que la fiche offre est déclaré, pas mesuré : c'est la supervision qui dit si chaque service répond.</div>
</div>`;
cible.innerHTML = entete + `<div class="contrat">
<section class="carte-contrat"><div class="tete"><h3>Ma face réseau</h3><span class="fichier">face-reseau.yml</span><span class="grandit"></span>${etatFace}</div>${face}</section>
<section class="carte-contrat"><div class="tete"><h3>Ce que le site m'a déposé</h3><span class="fichier">fiche-site.yml</span><span class="grandit"></span>${etatFiche}</div>${fiche}</section>
</div>`;
dessinerDetail();
}
function inspecteurLien() {
const site = (lienCharge && (lienCharge.hebergeur || lienCharge.fiche.site)) || 'le site';
document.getElementById('detail').innerHTML = `<div class="detail"><div class="section-fiche">
<div class="section-tete">Qui fait quoi</div>
<div class="qui-fait">
<div class="qui"><b>Le site</b><span>${echapper(site)} matérialise : les VM, le réseau, le pare-feu Proxmox, la frontière, l'adresse publique. Il n'a pas ma voûte : il n'entre pas dans mes services.</span></div>
<div class="qui"><b>Moi</b><span>Je configure : le plan, les rôles, les services, les comptes. Je n'ai pas la carte de la fabric : je la demande, je ne la pose pas.</span></div>
</div>
<div class="hint-dep" style="margin-top:12px">Entre le site et moi passent la face réseau, commitée dans mon dépôt, et la fiche qu'il y dépose. Le site ne lit rien d'autre de mon côté.</div>
<div class="hint-dep" style="margin-top:10px">Une machine à créer, une ouverture à poser : je republie ma face, puis je le demande au site par sa forge.</div>
</div></div>`;
}
async function dessinerLocataires() {
const cible = document.getElementById('grilles');
if (!locatairesCharge) {
cible.innerHTML = '<div class="vide" style="padding:14px">Lecture des faces publiées…</div>';
try { locatairesCharge = await lireContrat('/api/locataires-heberges'); }
catch (e) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(e.message) + '</div>'; return; }
}
const D = locatairesCharge, liste = D.locataires || [];
if (selLocataire >= liste.length) selLocataire = 0;
dessinerNavigation();
const entete = `<div class="vue-tete"><div style="display:flex;flex-direction:column;gap:2px">
<h2 class="vue-titre">Locataires hébergés</h2>
<span class="vue-sous">Ce que chacun m'a publié, et la fiche que je lui ai déposée. Le miroir de leur « Lien avec le site ».</span></div>
<span class="grandit"></span>
<button type="button" class="fantome" onclick="ouvrirAssistant('site-tenir')">Assistant : tenir un site en état</button></div>`;
const rang = (l, i) => {
const R = l.face.resume;
const rel = l.relation === 'actif' ? '<span class="etiq e-actif" title="Sa face me nomme comme hébergeur.">hébergeur actif</span>'
: l.relation === 'reprise' ? `<span class="etiq e-recon" title="Sa face nomme ${echapper((R && R.hebergeur) || '')} ; je le déclare comme site de reprise.">reprise</span>`
: '<span class="etiq e-bloq">face absente</span>';
const idx = (R && R.index !== l.index_attribue)
? `<span class="etiq e-div" title="Sa face publie l'index ${echapper(String(R.index))}">${echapper(String(l.index_attribue))} ≠ ${echapper(String(R.index))}</span>`
: `<span>${echapper(String(l.index_attribue ?? '—'))}</span>${R && R.vlans.length ? `<span class="bas">VLAN ${R.vlans[0]}–${R.vlans[1]}</span>` : ''}`;
const fiche = l.relation !== 'actif' ? '<span class="etiq e-recon" title="Sa fiche vient de son hébergeur actif.">de son hébergeur</span>'
: !l.fiche.deposee ? '<span class="etiq e-bloq">jamais déposée</span>'
: (l.fiche.ecarts || []).length ? '<span class="etiq e-div">à redéposer</span>'
: '<span class="etiq e-actif">à jour</span>';
return `<button type="button" class="rang loc ${i === selLocataire ? 'selectionnee' : ''}" onclick="choisirLocataire(${i})">
<span class="pile"><span class="carte-nom">${echapper(l.nom)}</span>${rel}</span>
<span class="pile ip">${idx}</span>
<span class="pile ip">${l.face.trace && l.face.trace.commit ? `<span>${echapper(l.face.trace.commit)}</span><span class="bas">${echapper(quand(l.face.trace.date))}</span>` : '—'}</span>
<span>${R ? `${R.actives}/${R.machines}` : '—'}</span>
<span class="ip">${echapper(l.ip_publique || '—')}</span>
<span>${fiche}</span></button>`;
};
const absents = (D.absents || []).length
? `<div class="ecarts" style="list-style:none;padding-left:10px;margin-bottom:14px">Déclarés dans <span class="mono">underlay.yml</span> sans dépôt ici : ${echapper(D.absents.join(', '))}. Le site ne peut rien lire d'eux.</div>` : '';
cible.innerHTML = entete + absents + (liste.length ? `<div class="tableau"><div class="tableau-corps loc">
<div class="rang tete loc"><span>Locataire</span><span>Index · VLAN</span><span>Face lue</span><span>Actives</span><span>Adresse publique</span><span>Fiche déposée</span></div>
${liste.map(rang).join('')}</div></div>` : '<div class="vide" style="padding:14px">Ce site ne déclare aucun locataire.</div>')
+ `<div class="contrat" style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr))">
<div class="note-contrat"><b style="color:var(--muted)">Face lue</b> — le dernier commit de <span class="mono">face-reseau.yml</span> présent ici. C'est la seule chose de lui que le site lit, et ce que <span class="mono">locataire-creer</span>, <span class="mono">creer-vm</span> et la frontière liront.</div>
<div class="note-contrat"><b style="color:var(--muted)">Fiche déposée</b> — comparée à ce que ce site calcule aujourd'hui. Périmée, son inventaire se génère sur d'anciennes valeurs.</div>
<div class="note-contrat"><b style="color:var(--muted)">Pas ici</b> — la frontière posée, le pare-feu Proxmox, le dépôt de sauvegarde reçu : ils se mesurent par leurs assistants, pas par ces fichiers.</div>
</div>`;
dessinerDetail();
}
function choisirLocataire(i) { selLocataire = i; dessinerLocataires(); }
function inspecteurLocataire() {
const cible = document.getElementById('detail');
const l = locatairesCharge && (locatairesCharge.locataires || [])[selLocataire];
if (!l) { cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun locataire</b>Choisis une ligne à gauche.</div></div>'; return; }
const R = l.face.resume;
const geste = (cle, titre, sous, cls) => `<div class="geste"><span class="quoi"><b>${echapper(titre)}</b><span>${sous}</span></span>
<button type="button" class="${cls || 'fantome'}" onclick="ouvrirAssistant('site-tenir')">Ouvrir</button></div>`;
const gestes = [];
if (l.relation === 'actif' && (l.fiche.ecarts || []).length)
gestes.push(geste('fiches-site-deposer', 'Redéposer sa fiche', '<span class="mono">make fiches-site-deposer</span>, puis la commiter chez lui'));
if (R && R.planifiees.length)
gestes.push(geste('creer-vm', 'Créer une machine, s\'il la demande', `<span class="mono">make creer-vm TENANT=${echapper(l.nom)} HOTE=…</span> — pas encore actives : ${echapper(R.planifiees.join(', '))}`));
gestes.push(geste('frontiere', 'Mesurer sa frontière', '<span class="mono">make frontiere-plan</span> : l\'écart entre la frontière et ce que les faces demandent'));
cible.innerHTML = `<div class="detail">
<div class="section-fiche"><div class="section-tete">${echapper(l.nom)}</div>
<div class="hint-dep">${l.relation === 'actif' ? 'Ce site est son hébergeur actif.' : l.relation === 'reprise' ? `Site de reprise : sa face nomme ${echapper((R && R.hebergeur) || '')}.` : 'Aucune face publiée : le site ne sait rien de lui.'}
${l.ip_publique ? ` Adresse publique attribuée : <span class="mono">${echapper(l.ip_publique)}</span>.` : ''}</div></div>
${R ? `<div class="section-fiche"><div class="section-tete">Ce qu'il demande</div>
<p class="sous-tete" style="margin-top:4px">Entrées depuis Internet</p>${lignesFlux(R.expositions, 'Aucune.')}
<p class="sous-tete" style="margin-top:10px">Sorties vers Internet</p>${lignesFlux(R.sorties, 'Aucune.')}
${R.dns_public.length ? `<div class="note-contrat" style="margin-top:8px">DNS public : <span class="mono">${echapper(R.dns_public.join(', '))}</span></div>` : ''}
<div class="note-contrat" style="margin-top:8px">Administration : <span class="mono">${echapper((R.administration.reseaux || []).concat(R.administration.tunnel || []).join(', ') || '—')}</span>${R.administration.pairs ? ` · ${R.administration.pairs} pair(s) VPN` : ''}${R.sauvegarde ? ' · clé de sauvegarde publiée' : ''}</div></div>` : ''}
<div class="gestes"><div class="gestes-tete"><span class="section-tete" style="margin:0">Gestes pour ce locataire</span></div>
${gestes.join('')}
<div class="gestes-note">Ils vivent dans l'assistant « Tenir un site en état ». Raser un locataire y est aussi, nom écrit en toutes lettres.</div></div>
<div class="section-fiche"><div class="section-tete">Ce que je ne vois pas</div>
<div class="hint-dep">Ses services, ses comptes, son plan au-delà de sa face, la validité de ses sauvegardes. Je n'ai pas sa voûte, et c'est voulu.</div></div>
</div>`;
}
function dessinerServeurs() {
// Une console qui n'edite aucun plan n'a pas de registre `serveurs` — et elle a
// pourtant des machines. On dessine CE QU'ELLE A.
@ -4160,6 +4612,8 @@ let selMachine = null;
// Le panneau de droite reflète la sélection de la VUE COURANTE.
if (vuePrincipale === 'flux' || vuePrincipale === 'couches' || vuePrincipale === 'reseau') return legendeLecture();
if (vuePrincipale === 'assistants') return inspecteurRunbook();
if (vuePrincipale === 'lien') return inspecteurLien();
if (vuePrincipale === 'locataires') return inspecteurLocataire();
if (vuePrincipale === 'integrations') return legendeIntegrations();
if (vuePrincipale === 'applications') return inspecteurApplication();
if (vuePrincipale === 'bases') return inspecteurBase();
@ -5130,6 +5584,22 @@ class Gestionnaire(BaseHTTPRequestHandler):
self.repondre(204, b"", "image/x-icon")
elif chemin == "/api/contexte":
self.repondre_json(200, contexte())
elif chemin in ("/api/lien-site", "/api/locataires-heberges"):
# EN LECTURE, sans pouvoir requis : ces deux fichiers sont deja dans les
# depots que cette console monte. Une console qui n'a pas ce cote du
# contrat le dit (404), elle ne rend pas un objet vide qui passerait pour
# « rien a signaler ».
_c = console(self.inventaire)
try:
charge = (_c.lien_avec_le_site() if chemin == "/api/lien-site"
else _c.locataires_heberges())
except Exception as exc:
self.repondre_json(500, {"erreur": f"{type(exc).__name__}: {exc}"})
return
if charge is None:
self.repondre_json(404, {"erreur": "Cette console n'a pas ce côté du contrat."})
else:
self.repondre_json(200, charge)
elif chemin == "/api/inventaire":
# LE GESTIONNAIRE NE SAIT PLUS LAQUELLE LUI REPOND, et c'est le but : il
# demandait « est-ce un site ? » pour choisir entre deux assembleurs, et

View file

@ -100,6 +100,46 @@ class TestPorteeDeLaConsole(unittest.TestCase):
ctx = self._monter(instance=False, voute_tenant=False, fabric=False, voute_site=False)
self.assertEqual(ctx["portee"], "orphelin")
self.assertFalse(any(ctx["peut"].values()))
self.assertFalse(any(ctx["contrat"].values()))
def test_chaque_console_lit_son_bout_du_contrat(self):
"""Lot 2 : le locataire lit son lien avec le site, le site ses locataires ; le
poste monte les deux depots, il lit les deux. Le cote suit la BRANCHE, pas la
voute : lire ces deux fichiers ne demande aucun secret."""
attendus = {
(True, False): {"lien_avec_le_site": True, "locataires_heberges": False},
(False, True): {"lien_avec_le_site": False, "locataires_heberges": True},
(True, True): {"lien_avec_le_site": True, "locataires_heberges": True},
}
for (instance, fabric), contrat in attendus.items():
with self.subTest(instance=instance, fabric=fabric):
ctx = self._monter(instance=instance, voute_tenant=False,
fabric=fabric, voute_site=False)
self.assertEqual(ctx["contrat"], contrat)
import shutil
shutil.rmtree(self.dossier, ignore_errors=True)
self.dossier.mkdir()
def test_un_site_de_reprise_ne_juge_pas_la_fiche_d_un_autre(self):
"""LE DEFAUT, NOMME (2026-10-11). Le locataire porte la fiche de son hebergeur
ACTIF ; SITE-Technolibre, qui le declare en reprise, la jugeait contre la sienne
et la disait perimee a chaque lecture."""
site = self.dossier / "SITE-Reprise"
site.mkdir()
(site / "underlay.yml").write_text(
"underlay:\n index: 39\n tenants:\n OPS-Essai: 18\n", encoding="utf-8")
loc = self.dossier / "OPS-Essai"
(loc / "plan").mkdir(parents=True)
(loc / "plan" / "nomenclature.yml").write_text("index: 18\n", encoding="utf-8")
(loc / "face-reseau.yml").write_text(
"locataire: OPS-Essai\nhebergeur: SITE-Actif\nindex: 18\nmachines: {}\n",
encoding="utf-8")
(loc / "fiche-site.yml").write_text("site: SITE-Actif\n", encoding="utf-8")
charge = gui.locataires_du_site(site)
(l,) = charge["locataires"]
self.assertEqual(l["relation"], "reprise")
self.assertIsNone(l["fiche"]["ecarts"], "la fiche d'un autre site ne se juge pas ici")
self.assertEqual(l["index_attribue"], 18)
if __name__ == "__main__":

View file

@ -100,6 +100,8 @@ function EventSource() { this.addEventListener = function () {}; this.close = fu
function alert() {} function confirm() { return false; } function prompt() { return null; }
var localStorage = {getItem: function () { return null; }, setItem: function () {}, removeItem: function () {}};
var __PAYLOAD__ = JSON.parse(process.env.PAYLOAD);
// Les routes en lecture qu'un test sert pour de vrai (lot 2 : le contrat site <-> locataire).
var __ROUTES__ = JSON.parse(process.env.ROUTES || '{}');
var __postsCaptures = {};
// `fetch` rend la VRAIE reponse de /api/inventaire ; les autres routes rendent un objet
// vide plutot que de jeter, pour que les sondes de fond ne masquent pas l'erreur cherchee.
@ -108,7 +110,7 @@ function fetch(url, opts) {
// Un POST rend la MEME charge que l'inventaire : le GUI recharge apres sauvegarde, et
// lui rendre {} le ferait repartir d'un plan vide — un faux vert, pas un test.
var corps = (String(url).indexOf('/api/inventaire') === 0 || (opts && opts.method === 'POST'))
? __PAYLOAD__ : {};
? __PAYLOAD__ : (__ROUTES__[String(url)] || {});
return Promise.resolve({ok: true, status: 200, json: function () { return Promise.resolve(corps); },
text: function () { return Promise.resolve(''); }});
}
@ -165,6 +167,26 @@ charger().then(function () {
"""
# LE BANC DU CONTRAT (lot 2). Les deux vues lisent leur route a l'ouverture : on attend
# leur promesse, sinon on lirait l'ecran « Lecture… » et on croirait la vue dessinee.
APRES_CONTRAT = r"""
charger().then(async function () {
var o = {erreurs: [], lien: '', lienDetail: '', locataires: '', locDetail: ''};
try {
vuePrincipale = 'lien'; lienCharge = null; await dessinerLienSite();
o.lien = document.getElementById('grilles').innerHTML;
o.lienDetail = document.getElementById('detail').innerHTML;
vuePrincipale = 'locataires'; locatairesCharge = null; await dessinerLocataires();
o.locataires = document.getElementById('grilles').innerHTML;
o.locDetail = document.getElementById('detail').innerHTML;
} catch (e) { o.erreurs.push(String((e && e.stack) || e)); }
console.log('__RESULTAT__' + JSON.stringify(o));
}).catch(function (e) {
console.log('__RESULTAT__' + JSON.stringify({erreurs: ['charger() : ' + ((e && e.stack) || e)]}));
});
"""
class TestFormeDesCharges(unittest.TestCase):
"""Les deux charges que la page consomme ont-elles la MEME FORME ?
@ -229,13 +251,14 @@ class TestRenduGui(unittest.TestCase):
cls.blocs = blocs
cls.js = BANC + "\n".join(blocs) + APRES
def _jouer(self, js: str, payload: dict) -> list:
def _jouer(self, js: str, payload: dict, routes: dict | None = None) -> list:
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f:
f.write(js)
chemin = f.name
try:
r = subprocess.run(["node", chemin], capture_output=True, text=True, timeout=120,
env={**__import__("os").environ, "PAYLOAD": json.dumps(payload)})
env={**__import__("os").environ, "PAYLOAD": json.dumps(payload),
"ROUTES": json.dumps(routes or {})})
finally:
Path(chemin).unlink(missing_ok=True)
marque = "__RESULTAT__"
@ -285,6 +308,27 @@ class TestRenduGui(unittest.TestCase):
self.assertIn("matérialise", r["detail"])
self.assertIn(noms[0], r["detailChoisi"])
def test_les_deux_bouts_du_contrat_se_dessinent(self):
"""LOT 2 (2026-10-11) : « Lien avec le site » et « Locataires heberges », servis
par leurs VRAIES routes sur les depots montes ici. Chaque vue doit montrer le
commit de la face qu'elle lit — c'est lui que l'autre bout lit."""
if not g.FICHIER_UNDERLAY.exists() or not g.INSTANCE.exists():
self.skipTest("il faut un locataire et un site montes")
lien = g.lien_du_locataire(g.INSTANCE.resolve())
locs = g.locataires_du_site(g._depot_hebergeur())
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
js = BANC + "\n".join(self.blocs) + APRES_CONTRAT
r = self._jouer(js, payload, {"/api/lien-site": lien, "/api/locataires-heberges": locs})
self.assertEqual(r["erreurs"], [], "une vue a leve :\n - " + "\n - ".join(r["erreurs"]))
self.assertIn("Ma face réseau", r["lien"])
if lien["face"]["trace"]["commit"]:
self.assertIn(lien["face"]["trace"]["commit"], r["lien"])
self.assertIn("Qui fait quoi", r["lienDetail"])
for l in locs["locataires"]:
self.assertIn(l["nom"], r["locataires"])
if locs["locataires"]:
self.assertIn("Ce que je ne vois pas", r["locDetail"])
def test_une_console_de_site_sert_son_propre_plan(self):
"""UN SITE A UN PLAN, ET ON LE LUI SERVAIT VIDE (2026-09-20).