diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffa0cd..3a236a0 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 79dce17..7e25921 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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; `; } + // 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 'aucune trace'; + if (!t.commit) return `jamais commité${t.erreur ? ' — ' + echapper(t.erreur) : ''}`; + return `commit ${echapper(t.commit)} · ${echapper(quand(t.date))}` + + (t.modifie ? ' modifié, pas encore commité' : ''); + } + function lignesFlux(liste, vide) { + if (!liste || !liste.length) return `
${echapper(vide)}
`; + return '
' + liste.map(f => `
+ ${echapper(f.hote)}${echapper(f.proto)} ${echapper(f.port)} + ${echapper(f.role)}
`).join('') + '
'; + } + 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 = '
Lecture de la face réseau et de la fiche du site…
'; + try { lienCharge = await lireContrat('/api/lien-site'); } + catch (e) { cible.innerHTML = '
' + echapper(e.message) + '
'; return; } + } + const L = lienCharge, F = L.face, R = F.resume, Fi = L.fiche; + const site = L.hebergeur || Fi.site || 'mon site'; + const entete = `
+

Lien avec le site

+ Ce que je publie à ${echapper(site)}, et ce qu'il m'a déposé. Deux fichiers passent entre nous, rien d'autre.
`; + + // MA FACE RESEAU + let etatFace; + if (!F.publiee) etatFace = 'jamais publiée'; + else if ((F.ecarts || []).length) etatFace = 'à republier'; + else etatFace = 'à jour avec mon plan'; + const face = !F.publiee ? `
Le site ne peut rien créer ni ouvrir pour moi sans elle. + make face-reseau-publier, puis commiter face-reseau.yml.
` : ` +
+
${ligneTrace(F.trace)}
+ ${(F.ecarts || []).length ? ` +
` : ''} +
+
${R.machines}machines${R.planifiees.length ? `, dont ${R.planifiees.length} pas encore actives` : ''}
+
${R.zones}zones${R.vlans.length ? ` · VLAN ${R.vlans[0]}–${R.vlans[1]}` : ''}
+
${R.expositions.length}entrées depuis Internet
+
${R.sorties.length}sorties vers Internet
+
+ ${R.planifiees.length ? `
Pas encore actives : ${echapper(R.planifiees.join(' · '))}. Le site ne les crée que sur demande.
` : ''} +

Ce que j'expose à Internet

+ ${lignesFlux(R.expositions, 'Aucune entrée depuis Internet.')} +

Ce que je demande de joindre sur Internet

+ ${lignesFlux(R.sorties, 'Aucune sortie vers Internet : tout passe par les services du site.')} + ${R.dns_public.length ? `
Zones publiques que le DNS du site sert pour moi : ${echapper(R.dns_public.join(', '))}.
` : ''} +
+ appliquée par le site : non rapporté + Le site ne rapporte pas encore quand il pose cette face à sa frontière. Pour le savoir aujourd'hui, lui demander son make frontiere-plan. +
+
`; + + // LA FICHE DU SITE + let etatFiche; + if (!Fi.presente) etatFiche = 'absente'; + else if (!Fi.jugeable) etatFiche = 'lue, non jugeable d\'ici'; + else if ((Fi.ecarts || []).length) etatFiche = 'périmée'; + else etatFiche = 'conforme à la carte du site'; + 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]) => `
${echapper(nom)} + ${echapper(valeur(off[k]))}
`) + .concat(Object.keys(off).filter(k => !connus.has(k)).map(k => `
${echapper(k)} + ${echapper(valeur(off[k]))}
`)) + .concat((gen.serveurs_temps || []).length ? [`
Heure${echapper(gen.serveurs_temps.join(', '))}
`] : []) + .concat((gen.resolveurs_site || []).length ? [`
Résolveurs du site${echapper(gen.resolveurs_site.join(', '))}
`] : []); + const fiche = !Fi.presente ? `
Mon inventaire se génère d'après elle. + Le site la dépose (make fiches-site-deposer) ; je la commite chez moi.
` : ` +
+
${ligneTrace(Fi.trace)}
+ ${(Fi.ecarts || []).length ? `` : ''} +
+
${echapper(String((Fi.attribue || {}).index ?? '—'))}mon index, attribué
+
${echapper((Fi.attribue || {}).ip_publique || '—')}mon adresse publique
+
${echapper(gen.routage_tenants || '—')}routage de mes zones
+
+

Ce que le site m'offre

+
${services.join('') || '
Aucun service déclaré.
'}
+
${echapper(Fi.site || site)}${off.index_site != null ? ' · index ' + echapper(String(off.index_site)) : ''}${off.domaine_site ? ' · ' + echapper(off.domaine_site) + '' : ''}${Fi.racine ? ` · racine de confiance ${echapper(Fi.racine)}…` : ''}. + Ce que la fiche offre est déclaré, pas mesuré : c'est la supervision qui dit si chaque service répond.
+
`; + + cible.innerHTML = entete + `
+

Ma face réseau

face-reseau.yml${etatFace}
${face}
+

Ce que le site m'a déposé

fiche-site.yml${etatFiche}
${fiche}
+
`; + dessinerDetail(); + } + + function inspecteurLien() { + const site = (lienCharge && (lienCharge.hebergeur || lienCharge.fiche.site)) || 'le site'; + document.getElementById('detail').innerHTML = `
+
Qui fait quoi
+
+
Le site${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.
+
MoiJe 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.
+
+
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é.
+
Une machine à créer, une ouverture à poser : je republie ma face, puis je le demande au site par sa forge.
+
`; + } + + async function dessinerLocataires() { + const cible = document.getElementById('grilles'); + if (!locatairesCharge) { + cible.innerHTML = '
Lecture des faces publiées…
'; + try { locatairesCharge = await lireContrat('/api/locataires-heberges'); } + catch (e) { cible.innerHTML = '
' + echapper(e.message) + '
'; return; } + } + const D = locatairesCharge, liste = D.locataires || []; + if (selLocataire >= liste.length) selLocataire = 0; + dessinerNavigation(); + const entete = `
+

Locataires hébergés

+ Ce que chacun m'a publié, et la fiche que je lui ai déposée. Le miroir de leur « Lien avec le site ».
+ +
`; + const rang = (l, i) => { + const R = l.face.resume; + const rel = l.relation === 'actif' ? 'hébergeur actif' + : l.relation === 'reprise' ? `reprise` + : 'face absente'; + const idx = (R && R.index !== l.index_attribue) + ? `${echapper(String(l.index_attribue))} ≠ ${echapper(String(R.index))}` + : `${echapper(String(l.index_attribue ?? '—'))}${R && R.vlans.length ? `VLAN ${R.vlans[0]}–${R.vlans[1]}` : ''}`; + const fiche = l.relation !== 'actif' ? 'de son hébergeur' + : !l.fiche.deposee ? 'jamais déposée' + : (l.fiche.ecarts || []).length ? 'à redéposer' + : 'à jour'; + return ``; + }; + const absents = (D.absents || []).length + ? `
Déclarés dans underlay.yml sans dépôt ici : ${echapper(D.absents.join(', '))}. Le site ne peut rien lire d'eux.
` : ''; + cible.innerHTML = entete + absents + (liste.length ? `
+
LocataireIndex · VLANFace lueActivesAdresse publiqueFiche déposée
+ ${liste.map(rang).join('')}
` : '
Ce site ne déclare aucun locataire.
') + + `
+
Face lue — le dernier commit de face-reseau.yml présent ici. C'est la seule chose de lui que le site lit, et ce que locataire-creer, creer-vm et la frontière liront.
+
Fiche déposée — comparée à ce que ce site calcule aujourd'hui. Périmée, son inventaire se génère sur d'anciennes valeurs.
+
Pas ici — 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.
+
`; + dessinerDetail(); + } + function choisirLocataire(i) { selLocataire = i; dessinerLocataires(); } + + function inspecteurLocataire() { + const cible = document.getElementById('detail'); + const l = locatairesCharge && (locatairesCharge.locataires || [])[selLocataire]; + if (!l) { cible.innerHTML = '
Aucun locataireChoisis une ligne à gauche.
'; return; } + const R = l.face.resume; + const geste = (cle, titre, sous, cls) => `
${echapper(titre)}${sous} +
`; + const gestes = []; + if (l.relation === 'actif' && (l.fiche.ecarts || []).length) + gestes.push(geste('fiches-site-deposer', 'Redéposer sa fiche', 'make fiches-site-deposer, puis la commiter chez lui')); + if (R && R.planifiees.length) + gestes.push(geste('creer-vm', 'Créer une machine, s\'il la demande', `make creer-vm TENANT=${echapper(l.nom)} HOTE=… — pas encore actives : ${echapper(R.planifiees.join(', '))}`)); + gestes.push(geste('frontiere', 'Mesurer sa frontière', 'make frontiere-plan : l\'écart entre la frontière et ce que les faces demandent')); + cible.innerHTML = `
+
${echapper(l.nom)}
+
${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 : ${echapper(l.ip_publique)}.` : ''}
+ ${R ? `
Ce qu'il demande
+

Entrées depuis Internet

${lignesFlux(R.expositions, 'Aucune.')} +

Sorties vers Internet

${lignesFlux(R.sorties, 'Aucune.')} + ${R.dns_public.length ? `
DNS public : ${echapper(R.dns_public.join(', '))}
` : ''} +
Administration : ${echapper((R.administration.reseaux || []).concat(R.administration.tunnel || []).join(', ') || '—')}${R.administration.pairs ? ` · ${R.administration.pairs} pair(s) VPN` : ''}${R.sauvegarde ? ' · clé de sauvegarde publiée' : ''}
` : ''} +
Gestes pour ce locataire
+ ${gestes.join('')} +
Ils vivent dans l'assistant « Tenir un site en état ». Raser un locataire y est aussi, nom écrit en toutes lettres.
+
Ce que je ne vois pas
+
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.
+
`; + } + 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 diff --git a/scripts/tests/test_portee_console.py b/scripts/tests/test_portee_console.py index aac4f8e..02076f1 100644 --- a/scripts/tests/test_portee_console.py +++ b/scripts/tests/test_portee_console.py @@ -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__": diff --git a/scripts/tests/test_rendu_gui.py b/scripts/tests/test_rendu_gui.py index fb68b62..5ab4ece 100644 --- a/scripts/tests/test_rendu_gui.py +++ b/scripts/tests/test_rendu_gui.py @@ -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).