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 `
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 ? `Ce que le site m'offre
+Entrées depuis Internet
${lignesFlux(R.expositions, 'Aucune.')} +Sorties vers Internet
${lignesFlux(R.sorties, 'Aucune.')} + ${R.dns_public.length ? `