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:
parent
493a5dcbeb
commit
9592309f36
4 changed files with 600 additions and 5 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue