diff --git a/CHANGELOG.md b/CHANGELOG.md index b742f77..9d0184c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # CHANGELOG — Set-OPS +## 2026-10-11 (137) — Un mainteneur demande au site par un ticket, et la console du site le voit + +**95 preuves.** `make verifier` passe ; `test_forge_cles.py` couvre aussi les collaborations. +La vue a été regardée en capture. Rien n'est encore créé sur la forge. + +### Le canal des demandes (lot 3b) + +Décisions de Daniel : un compte par personne sur la forge du site ; la console du site lit +les demandes, celle du locataire donne d'abord le lien (un jeton en lecture pour elle viendra +plus tard). Les demandes vivent dans le dépôt **privé** de chaque locataire : seuls lui et le +site les lisent. + +- **`make forge-comptes`**, sur le runner du site. Il crée un compte à chaque mainteneur + déclaré dans `plan/mainteneurs.yml` du site, collaborateur en lecture sur les seuls dépôts des + locataires qu'il maintient (la lecture suffit pour ouvrir et commenter un ticket), et retire + une collaboration qui n'est plus déclarée. Le compte naît avec un mot de passe aléatoire que + personne ne voit ; la personne choisit le sien par « mot de passe oublié ». La cible tient + aussi le compte `console-site` : il appartient à l'équipe « Lecteurs » (lecture des tickets et + du code), et son jeton à portée lecture est rangé sur le runner du site, hors de tout dépôt, + en 0600. Le jeton est recréé s'il manque, et l'ancien est révoqué. +- **La console du site** met dans « À traiter » chaque demande ouverte des locataires : son + titre, son auteur, sa date et le lien vers le ticket. Sans jeton, elle dit « demandes non + lues » et donne la commande. +- **La console du locataire** ajoute à « Lien avec le site » la carte « Ce que je demande au + site ». Elle propose une demande pré-remplie pour chaque machine prévue mais pas encore + active (adresse, commit de la face, geste `make creer-vm` du site), une demande libre, et le + lien « Mes demandes ». + +Les mainteneurs sont dans un fichier à part, et non dans `10-intrants.yml` : celui-ci entre +dans la fiche de chaque locataire. Le premier essai les y avait mis, et P91 a aussitôt +déclaré périmées les deux fiches, pour un ajout qui ne les concerne pas. + +**Limite.** La console du locataire ne lit pas encore les réponses du site. + ## 2026-10-11 (136) — Technolibre reconstruit avec des dépôts privés : chaque runner ne lit que le sien **95 preuves.** Reconstruction complète de Technolibre (`make reconstruire-locataire`), puis diff --git a/Makefile b/Makefile index 0417fb3..b9c20a6 100644 --- a/Makefile +++ b/Makefile @@ -1388,6 +1388,14 @@ forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lec CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \ playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS) +.PHONY: forge-comptes +forge-comptes: ## Les comptes de la forge du site : un par mainteneur (plan du site), et le lecteur de la console — CONFIRMER=true pour ecrire + @set -e; \ + voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ + CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \ + playbooks/maintenance/forge_comptes.yml "$${supp[@]}" $(ARGS) + .PHONY: forge-etancheite forge-etancheite: ansible-runtime ## La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ? (mesure, aucune ecriture) python3 scripts/forge_etancheite.py diff --git a/docs/audit/preuve-2026-10-11.md b/docs/audit/preuve-2026-10-11.md index 4927c05..641e341 100644 --- a/docs/audit/preuve-2026-10-11.md +++ b/docs/audit/preuve-2026-10-11.md @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 80 scripts expliques et atteignables, 153 cibles make documentees, 69 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 81 scripts expliques et atteignables, 154 cibles make documentees, 69 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (150 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 51 document(s) declarent leur lecteur (48 genere(s) exempte(s)). | @@ -53,7 +53,7 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. | | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | -| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 76 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 77 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 175 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | @@ -95,7 +95,7 @@ | P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. | | P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. | | P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio | -| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 147 etapes, 152 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. | +| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 148 etapes, 153 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. | | P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). | | P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). | | P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. | diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index 3499c21..8e4f39f 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -225,6 +225,13 @@ runbooks: pourquoi: >- Enregistrer sur le depot de chaque locataire la cle de lecture que sa face publie, et retirer celle qui ne l'est plus. Sans elle, son runner ne lit plus son depot prive. + - cible: forge-comptes + nature: ecriture + fixes: {CONFIRMER: "true"} + pourquoi: >- + Un compte par mainteneur declare, ouvert sur les seuls depots de ses locataires : + c'est par un ticket qu'il demande au site. Et le lecteur de la console du site, qui + met ces demandes dans « A traiter ». - cible: forge-etancheite nature: mesure portee: poste diff --git a/playbooks/maintenance/forge_comptes.yml b/playbooks/maintenance/forge_comptes.yml new file mode 100644 index 0000000..534c003 --- /dev/null +++ b/playbooks/maintenance/forge_comptes.yml @@ -0,0 +1,46 @@ +--- +# LES COMPTES DE LA FORGE DU SITE (2026-10-11, lot 3b). +# +# Voir `docs/depots-prives-conception.md` et `scripts/forge_comptes.py`. Le script tourne SUR le +# runner du site : il y lit le plan du site (`mainteneurs`) et les faces de ses locataires, et +# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par +# l'environnement, et n'est jamais affiche. +# +# make forge-comptes montre ce qui serait fait +# make forge-comptes CONFIRMER=true l'ecrit, puis relit +- name: Les comptes de la forge du site + hosts: serveur_ops_site + gather_facts: false + become: true + become_user: "{{ serveur_ops_utilisateur | default('setops') }}" + vars: + forge_comptes_racine: "{{ serveur_ops_racine | default('/opt/setops') }}" + forge_comptes_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}" + forge_comptes_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}" + tasks: + - name: Exiger le mot de passe d'administration de la forge (voute du site) + ansible.builtin.assert: + that: vault_forgejo_admin | default('') | length > 0 + fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee." + no_log: true + + - name: Confronter le plan du site aux comptes de la forge + ansible.builtin.command: + argv: "{{ ['python3', 'scripts/forge_comptes.py'] + + (['--confirmer'] if (forge_comptes_confirmer | bool) else []) }}" + chdir: "{{ forge_comptes_racine }}/{{ forge_comptes_moteur }}" + environment: + FORGEJO_ADMIN: "{{ vault_forgejo_admin }}" + PATH: "{{ forge_comptes_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin" + # En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner. + check_mode: false + register: forge_comptes + changed_when: >- + ['cree ', 'collab ', 'retire ', 'jeton '] | select('in', forge_comptes.stdout) | list | length > 0 + # 1 = un ecart DIT (un courriel a remplir, par exemple) : il s'affiche, il n'empeche pas + # le reste. 2 = le script a refuse de travailler. + failed_when: forge_comptes.rc not in [0, 1] + + - name: Ce que la forge porte + ansible.builtin.debug: + msg: "{{ forge_comptes.stdout_lines }}" diff --git a/scripts/forge_comptes.py b/scripts/forge_comptes.py new file mode 100644 index 0000000..65ffc46 --- /dev/null +++ b/scripts/forge_comptes.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Les comptes de la forge du site : un par mainteneur, et le lecteur de la console du site. + +POURQUOI (2026-10-11, lot 3b). Un mainteneur demande quelque chose au site (une machine a +creer, une ouverture) par un TICKET dans le depot prive de son locataire, sur la forge du site. +Il ecrit avec SON compte : chaque demande dit qui l'a faite. La console du site lit toutes les +demandes avec un compte qui ne peut QUE lire. + +CE QUE LE SCRIPT TIENT, d'apres le plan du site (`plan/mainteneurs.yml`) : + - chaque mainteneur declare a un compte, collaborateur en LECTURE sur les seuls depots des + locataires qu'il maintient (la lecture suffit a ouvrir et commenter un ticket) ; une + collaboration qui n'est plus declaree est retiree. Un compte neuf nait avec un mot de passe + aleatoire que PERSONNE ne voit : la personne choisit le sien par « mot de passe oublie » ; + - le compte `console-site`, membre de l'equipe `Lecteurs` de l'organisation (lecture des + tickets et du code, sur tous ses depots), et son jeton a portee LECTURE, range sur le runner + du site hors de tout depot (`~/.config/setops-forge-jeton-console`, 0600). Absent, il est + recree ; l'ancien est revoque. + +Lance sur le runner du site par `make forge-comptes` (mot de passe d'administration par +l'environnement, `FORGEJO_ADMIN`, depuis la voute du site ; jamais affiche). +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import secrets +import ssl +import sys +import urllib.error +import urllib.request +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +COMPTE_CONSOLE = "console-site" +EQUIPE_LECTEURS = "Lecteurs" +JETON_NOM = "console-site-lecture" +JETON_FICHIER = Path.home() / ".config" / "setops-forge-jeton-console" + + +def _basic(utilisateur: str, mdp: str) -> str: + return "Basic " + base64.b64encode(f"{utilisateur}:{mdp}".encode()).decode() + + +class Forge: + def __init__(self, base: str, mdp_admin: str, organisation: str = "genome"): + self.base = base.rstrip("/") + self.org = organisation + self.admin = _basic("forgejo-admin", mdp_admin) + self.ctx = ssl.create_default_context() + + def api(self, chemin: str, methode: str = "GET", corps=None, auth: str | None = None): + req = urllib.request.Request(f"{self.base}/api/v1{chemin}", method=methode, + data=json.dumps(corps).encode() if corps is not None else None) + req.add_header("Authorization", auth or self.admin) + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=20, context=self.ctx) as r: + texte = r.read().decode() + return r.status, (json.loads(texte) if texte else None) + except urllib.error.HTTPError as e: + return e.code, (e.read().decode(errors="replace") or "")[:200] + + +def mainteneurs_declares() -> dict[str, dict]: + import underlay as underlay_mod + m = (underlay_mod.lire_plan_site("mainteneurs.yml") or {}).get("mainteneurs") or {} + return {str(k): dict(v or {}) for k, v in m.items()} + + +def depots_des_locataires() -> dict[str, str]: + """locataire -> son depot du genome, d'apres sa face publiee (`genome.depot`).""" + import contexte as _ctx + out = {} + for site, loc in _ctx.couples_hebergeur(): + face = loc.face_publiee() or {} + if face.get("hebergeur") == site.nom and (face.get("genome") or {}).get("depot"): + out[loc.nom] = str(face["genome"]["depot"]) + return out + + +def plan_collaborations(voulues: dict[str, set[str]], actuelles: dict[str, set[str]]): + """(ajouter, retirer) : listes de (compte, depot). Pur. `actuelles` ne porte que les + depots de locataires, jamais le moteur : on ne retire que ce que ce script gere.""" + ajouter = sorted((c, d) for c, ds in voulues.items() for d in ds - actuelles.get(c, set())) + retirer = sorted((c, d) for c, ds in actuelles.items() for d in ds - voulues.get(c, set())) + return ajouter, retirer + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--confirmer", action="store_true") + a = ap.parse_args(argv) + mdp = os.environ.get("FORGEJO_ADMIN", "") + if not mdp: + print("REFUS : FORGEJO_ADMIN absent (voute du site).", file=sys.stderr) + return 2 + import underlay as underlay_mod + dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "") + forge = Forge(f"https://forge.{dom}", mdp) + mainteneurs = mainteneurs_declares() + depots = depots_des_locataires() + refus = [] + voulues: dict[str, set[str]] = {} + for compte, m in sorted(mainteneurs.items()): + if not str(m.get("courriel") or "").strip(): + refus.append(f"{compte} : `courriel` vide au plan du site (la forge l'exige, et c'est " + f"par lui que la personne choisit son mot de passe)") + continue + inconnus = [l for l in m.get("locataires") or [] if l not in depots] + if inconnus: + refus.append(f"{compte} : locataire(s) sans face ni depot ici : {', '.join(inconnus)}") + voulues[compte] = {depots[l] for l in m.get("locataires") or [] if l in depots} + + # L'ETAT DE LA FORGE + st, users = forge.api("/admin/users?limit=100") + if st != 200: + print(f"REFUS : lecture des comptes : HTTP {st}", file=sys.stderr) + return 2 + existants = {u["login"] for u in users} + actuelles: dict[str, set[str]] = {} + for d in sorted(set(depots.values())): + st, collab = forge.api(f"/repos/{forge.org}/{d}/collaborators?limit=100") + for c in (collab or []) if st == 200 else []: + actuelles.setdefault(c["login"], set()).add(d) + ajouter, retirer = plan_collaborations(voulues, actuelles) + a_creer = sorted(c for c in voulues if c not in existants) + st, equipes = forge.api(f"/orgs/{forge.org}/teams") + lecteurs = next((t for t in (equipes or []) if t.get("name") == EQUIPE_LECTEURS), None) if st == 200 else None + + print("Mainteneurs (plan du site) :") + for c in sorted(mainteneurs): + print(f" {c:<16} {'a creer' if c in a_creer else 'existe' if c in existants else 'refuse'}" + f" depots : {', '.join(sorted(voulues.get(c, set()))) or '—'}") + for c, d in ajouter: + print(f" + collaboration (lecture) {c} sur {d}") + for c, d in retirer: + print(f" - collaboration {c} sur {d} (plus declaree)") + print(f"Lecteur de la console : compte {COMPTE_CONSOLE} " + f"{'existe' if COMPTE_CONSOLE in existants else 'a creer'}, equipe {EQUIPE_LECTEURS} " + f"{'existe' if lecteurs else 'a creer'}, jeton {'present' if JETON_FICHIER.is_file() else 'a creer'}") + for r in refus: + print(f" REFUS {r}") + if not a.confirmer: + print("\nRien n'a ete ecrit. Relancer avec --confirmer (make forge-comptes CONFIRMER=true).") + return 1 if refus else 0 + + echecs = 0 + + def creer_compte(login: str, courriel: str, nom: str, oblige_changer: bool) -> str: + nonlocal echecs + mdp_compte = secrets.token_urlsafe(32) + st, rep = forge.api("/admin/users", "POST", { + "username": login, "email": courriel, "full_name": nom, "password": mdp_compte, + "must_change_password": oblige_changer, "send_notify": False, "visibility": "private"}) + if st != 201: + echecs += 1 + print(f" ECHEC creation {login} : HTTP {st} {rep}") + else: + print(f" cree {login}") + return mdp_compte + + for c in a_creer: + m = mainteneurs[c] + creer_compte(c, str(m["courriel"]).strip(), str(m.get("nom") or c), True) + print(f" -> {c} choisit son mot de passe par « mot de passe oublie » sur " + f"https://forge.{dom}/user/forgot_password") + for c, d in ajouter: + st, _ = forge.api(f"/repos/{forge.org}/{d}/collaborators/{c}", "PUT", {"permission": "read"}) + echecs += st != 204 + print(f" collab {c} sur {d} : HTTP {st}") + for c, d in retirer: + st, _ = forge.api(f"/repos/{forge.org}/{d}/collaborators/{c}", "DELETE") + echecs += st != 204 + print(f" retire {c} de {d} : HTTP {st}") + + # LE LECTEUR DE LA CONSOLE DU SITE + mdp_console = None + if COMPTE_CONSOLE not in existants: + mdp_console = creer_compte(COMPTE_CONSOLE, f"{COMPTE_CONSOLE}@{dom}", + "Console du site (lecture seule)", False) + if lecteurs is None: + st, lecteurs = forge.api(f"/orgs/{forge.org}/teams", "POST", { + "name": EQUIPE_LECTEURS, "permission": "read", "includes_all_repositories": True, + "description": "Lecture seule des tickets et du code — consoles", + "units": ["repo.code", "repo.issues"]}) + echecs += st != 201 + print(f" equipe {EQUIPE_LECTEURS} : HTTP {st}") + if isinstance(lecteurs, dict) and lecteurs.get("id"): + st, _ = forge.api(f"/teams/{lecteurs['id']}/members/{COMPTE_CONSOLE}", "PUT") + echecs += st not in (204,) + if not JETON_FICHIER.is_file(): + # UN JETON NE SE CREE QU'AVEC L'IDENTITE DE SON PROPRIETAIRE. Le compte console n'a pas + # de mot de passe connu : on lui en donne un neuf, le temps de creer le jeton, puis il + # est jete. L'ancien jeton du meme nom est revoque d'abord. + if mdp_console is None: + mdp_console = secrets.token_urlsafe(32) + st, _ = forge.api(f"/admin/users/{COMPTE_CONSOLE}", "PATCH", + {"login_name": COMPTE_CONSOLE, "source_id": 0, "password": mdp_console, + "must_change_password": False}) + echecs += st != 200 + auth = _basic(COMPTE_CONSOLE, mdp_console) + forge.api(f"/users/{COMPTE_CONSOLE}/tokens/{JETON_NOM}", "DELETE", auth=auth) + st, rep = forge.api(f"/users/{COMPTE_CONSOLE}/tokens", "POST", + {"name": JETON_NOM, "scopes": ["read:issue", "read:repository"]}, auth=auth) + if st == 201 and isinstance(rep, dict) and rep.get("sha1"): + JETON_FICHIER.parent.mkdir(parents=True, exist_ok=True) + JETON_FICHIER.touch(mode=0o600) + JETON_FICHIER.write_text(rep["sha1"] + "\n", encoding="utf-8") + os.chmod(JETON_FICHIER, 0o600) + print(f" jeton {JETON_NOM} : cree, range dans {JETON_FICHIER} (0600)") + else: + echecs += 1 + print(f" ECHEC jeton : HTTP {st} {rep}") + + if echecs or refus: + print(f"\nECART : {echecs} echec(s), {len(refus)} refus.") + return 1 + print("\nComptes et collaborations conformes au plan du site ; la console du site peut lire.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index fe50522..c41ca83 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -1001,6 +1001,7 @@ def resume_face(face: dict) -> dict: "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"), + "adresses_planifiees": {h: m.get("ip") 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, @@ -1009,6 +1010,7 @@ def resume_face(face: dict) -> dict: "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")), + "genome_depot": str((face.get("genome") or {}).get("depot") or ""), } @@ -1062,9 +1064,21 @@ def lien_du_locataire(instance: Path) -> dict: # 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, + # OU DEMANDER AU SITE (lot 3b) : un ticket dans SON depot prive, sur la forge du site, + # ecrit avec le compte du mainteneur. La console n'a pas de jeton : elle donne le lien. + "demandes": _ou_demander(fiche, face), } +def _ou_demander(fiche: dict, face: dict | None) -> dict: + base = url_forge_du_site(str((fiche.get("offre") or {}).get("domaine_site") or "")) + depot = str(((face or {}).get("genome") or {}).get("depot") or "") + if not (base and depot): + return {} + return {"liste": f"{base}/genome/{depot}/issues", "nouvelle": f"{base}/genome/{depot}/issues/new", + "depot": depot} + + 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. @@ -1258,6 +1272,54 @@ def veille_locataire(instance: Path) -> list[dict]: return out +# --- LES DEMANDES AU SITE (lot 3b, 2026-10-11) ------------------------------------------- +# +# Un mainteneur demande au site par un TICKET dans le depot prive de son locataire, sur la +# forge du site, avec son propre compte. La console du site les lit avec un compte qui ne peut +# que lire (`make forge-comptes`), dont le jeton vit sur le runner du site, hors de tout depot. +# La console d'un locataire n'en a pas encore : elle offre le lien, pre-rempli. + +JETON_CONSOLE_SITE = Path.home() / ".config" / "setops-forge-jeton-console" + + +def url_forge_du_site(domaine_site: str) -> str: + return f"https://forge.{domaine_site}" if domaine_site else "" + + +def demandes_ouvertes(base: str, depot: str, jeton: str, organisation: str = "genome") -> list[dict]: + """Les tickets ouverts d'un depot, en lecture seule. Leve si la forge ne repond pas.""" + import ssl + import urllib.request + req = urllib.request.Request( + f"{base}/api/v1/repos/{organisation}/{depot}/issues?state=open&type=issues&limit=50") + req.add_header("Authorization", f"token {jeton}") + with urllib.request.urlopen(req, timeout=10, context=ssl.create_default_context()) as r: + tickets = json.loads(r.read().decode() or "[]") + return [{"numero": t.get("number"), "titre": t.get("title", ""), "url": t.get("html_url", ""), + "auteur": (t.get("user") or {}).get("login", ""), "creee": t.get("created_at", ""), + "commentaires": t.get("comments", 0)} for t in tickets] + + +def _demandes_des_locataires(site_depot: Path, locataires: list[dict]) -> tuple[dict, str]: + """(locataire -> demandes ouvertes, raison si illisible).""" + dom = str((yaml.safe_load((site_depot / "plan" / "10-intrants.yml").read_text(encoding="utf-8")) + or {}).get("domaine_interne") or "") if (site_depot / "plan" / "10-intrants.yml").is_file() else "" + base = url_forge_du_site(dom) + if not JETON_CONSOLE_SITE.is_file(): + return {}, "jeton de lecture absent sur cette machine (`make forge-comptes CONFIRMER=true` au site)" + jeton = JETON_CONSOLE_SITE.read_text(encoding="utf-8").strip() + out = {} + for l in locataires: + depot = ((l.get("face") or {}).get("resume") or {}).get("genome_depot") + if not depot: + continue + try: + out[l["nom"]] = demandes_ouvertes(base, depot, jeton) + except Exception as exc: + return out, f"la forge ne repond pas ({type(exc).__name__}: {exc})" + return out, "" + + def veille_site(depot: Path) -> list[dict]: """CE QUI ATTEND L'HEBERGEUR : son genome, et ce que ses locataires lui ont publie.""" charge = locataires_du_site(depot) @@ -1269,6 +1331,23 @@ def veille_site(depot: Path) -> list[dict]: depots += [(f"Le dépôt {l.nom}", l.depot, "make publier") for l in site.locataires()] out = _veille_genome(depots, "Le site lirait un moteur, une carte ou une face dépassés : " "`make publier` et le rôle serveur_ops le remettent au niveau.") + # LES DEMANDES DES MAINTENEURS, d'abord : c'est quelqu'un qui attend. + demandes, raison = _demandes_des_locataires(depot, charge["locataires"]) + if raison: + out.append(_element("DEMANDES", "savoir", "Demandes des locataires non lues", raison, "", + {"libelle": "Créer le compte de lecture de la console", + "commande": "make forge-comptes CONFIRMER=true"})) + else: + for nom, liste in sorted(demandes.items()): + for t in liste: + out.append(_element(nom, "agir", f"Demande : {t['titre']}", + f"De son mainteneur ({t['auteur']}), ouverte le {t['creee'][:10]}" + + (f" · {t['commentaires']} commentaire(s)" if t["commentaires"] else ""), + f"#{t['numero']} · {t['url']}", + {"libelle": "Ouvrir la demande sur la forge", "url": t["url"]})) + if not any(demandes.values()): + out.append(_element("DEMANDES", "ok", "Aucune demande ouverte par les locataires", "", + ", ".join(sorted(demandes)) or "aucun dépôt lu")) for nom in charge["absents"]: out.append(_element(nom, "agir", "Locataire déclaré sans dépôt ici", "Le site ne peut rien lire de lui : ni face, ni fiche.", @@ -2593,6 +2672,8 @@ button.prod:hover:not(:disabled) { background: #8f2424; border-color: #c94a4a; } .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; } +.btn-lien { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border-radius: 7px; border: 1px solid var(--ligne-forte); color: var(--texte); text-decoration: none; font-size: 13px; font-weight: 600; white-space: nowrap; } +.btn-lien:hover { background: var(--surface-2); } .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; } @@ -4550,9 +4631,33 @@ let selMachine = null; Ce que la fiche offre est déclaré, pas mesuré : c'est la supervision qui dit si chaque service répond. `; + // DEMANDER AU SITE (lot 3b) : un ticket dans mon depot prive, sur la forge du site, ecrit + // avec mon compte. Pre-rempli ici ; c'est la forge qui l'enregistre et qui le trace. + const D = L.demandes || {}; + const lienNouvelle = (titre, corps) => D.nouvelle + ? `${D.nouvelle}?title=${encodeURIComponent(titre)}&body=${encodeURIComponent(corps)}` : ''; + const commit = (F.trace && F.trace.commit) || '?'; + const pied = `\n\nFace réseau publiée : commit ${commit}.\nDemande ouverte depuis la console de ${L.locataire}.`; + const demandesVm = (R && R.planifiees || []).map(h => { + const ip = ((R.adresses_planifiees || {})[h]) || '?'; + const corps = `Le locataire ${L.locataire} demande au site de créer la machine \`${h}\` (${ip}).` + + `\n\nElle est au plan, l'inventaire est appliqué et la face republiée.` + + `\nGeste du site : \`make creer-vm TENANT=${L.locataire} HOTE=${h}\`` + pied; + return `
Créer ${echapper(h)} ${echapper(ip)} + Demander…
`; + }).join(''); + const demander = !D.nouvelle ? '' : ` +

Ce que je demande au site

tickets · ${echapper(D.depot)} + Mes demandes
+
+ ${demandesVm ? `

Machines prévues, à faire créer

${demandesVm}
` : ''} +
Nouvelle demande au site…
+
La demande s'écrit sur la forge du site, avec ton compte, dans le dépôt privé de ce locataire : seuls lui et le site la lisent. Cette console ne lit pas encore les réponses ; « Mes demandes » les montre.
+
`; 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}
+ ${demander}
`; dessinerDetail(); } @@ -4699,6 +4804,7 @@ let selMachine = null; if (!e || !e.geste) return; if (e.geste.runbook) ouvrirAssistant(e.geste.runbook); else if (e.geste.vue) setVue(e.geste.vue); + else if (e.geste.url) window.open(e.geste.url, '_blank', 'noopener'); } function copierCommande(texte) { try { navigator.clipboard.writeText(texte); message('Commande copiée.', 'ok'); } catch (e) { /* rien */ } @@ -4755,6 +4861,8 @@ let selMachine = null; const geste = !g ? '' : g.commande ? `
${echapper(g.commande)}
${echapper(g.libelle)}
` + : g.url + ? `
${echapper(g.libelle)}${echapper(g.url)}Ouvrir
` : `
${echapper(g.libelle)}
`; cible.innerHTML = `
${echapper(e.source)}
diff --git a/scripts/tests/test_forge_cles.py b/scripts/tests/test_forge_cles.py index cee97d0..afbbb14 100644 --- a/scripts/tests/test_forge_cles.py +++ b/scripts/tests/test_forge_cles.py @@ -67,6 +67,21 @@ class TestVisibilite(unittest.TestCase): [("set-ops-public", False)]) +class TestCollaborations(unittest.TestCase): + """`forge_comptes` : chaque mainteneur sur les seuls depots de ses locataires.""" + + def test_ajoute_ce_qui_manque_et_retire_ce_qui_n_est_plus_declare(self): + import forge_comptes as fco + voulues = {"daniel": {"ops-a", "ops-b"}} + actuelles = {"daniel": {"ops-a"}, "ancien": {"ops-b"}} + self.assertEqual(fco.plan_collaborations(voulues, actuelles), + ([("daniel", "ops-b")], [("ancien", "ops-b")])) + + def test_rien_a_faire(self): + import forge_comptes as fco + self.assertEqual(fco.plan_collaborations({"d": {"x"}}, {"d": {"x"}}), ([], [])) + + class TestClassement(unittest.TestCase): """`forge_etancheite` : un refus RESEAU n'est jamais lu comme une protection."""