console : un mainteneur demande au site par un ticket, la console du site le voit

Lot 3b. make forge-comptes cree un compte par mainteneur declare (plan/mainteneurs.yml
du site), collaborateur en lecture sur les seuls depots de ses locataires, sans mot de
passe connu ; et le compte console-site en lecture seule, son jeton range sur le runner
du site hors de tout depot. La console du site met les demandes ouvertes dans A traiter ;
celle du locataire propose des demandes pre-remplies et le lien vers les siennes.

95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 22:47:06 -04:00
parent 6df04888ab
commit 548d35b1b5
8 changed files with 448 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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. |

View file

@ -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

View file

@ -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 }}"

227
scripts/forge_comptes.py Normal file
View file

@ -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())

View file

@ -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.</div>
</div>`;
// 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 `<div class="ligne deux"><span>Créer <span class="mono">${echapper(h)}</span> <span class="mono">${echapper(ip)}</span></span>
<span style="text-align:right"><a class="btn-lien" href="${echapper(lienNouvelle('Créer la machine ' + h, corps))}" target="_blank" rel="noopener">Demander…</a></span></div>`;
}).join('');
const demander = !D.nouvelle ? '' : `
<section class="carte-contrat"><div class="tete"><h3>Ce que je demande au site</h3><span class="fichier">tickets · ${echapper(D.depot)}</span><span class="grandit"></span>
<a class="btn-lien" href="${echapper(D.liste)}" target="_blank" rel="noopener">Mes demandes</a></div>
<div class="corps">
${demandesVm ? `<p class="sous-tete">Machines prévues, à faire créer</p><div class="lignes">${demandesVm}</div>` : ''}
<div><a class="btn-lien" href="${echapper(lienNouvelle('', 'Le locataire ' + L.locataire + ' demande au site :\n\n' + pied))}" target="_blank" rel="noopener">Nouvelle demande au site…</a></div>
<div class="note-contrat">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.</div>
</div></section>`;
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>
${demander}
</div>`;
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
? `<div class="commande"><span>${echapper(g.commande)}</span><button type="button" class="fantome" onclick="copierCommande(${echapper(JSON.stringify(g.commande))})">Copier</button></div>
<div class="gestes-note">${echapper(g.libelle)}</div>`
: g.url
? `<div class="geste"><span class="quoi"><b>${echapper(g.libelle)}</b><span class="mono">${echapper(g.url)}</span></span><a class="btn-lien" href="${echapper(g.url)}" target="_blank" rel="noopener">Ouvrir</a></div>`
: `<div class="geste"><span class="quoi"><b>${echapper(g.libelle)}</b></span><button type="button" class="primaire" onclick="lancerGeste(${selVeille})">Ouvrir</button></div>`;
cible.innerHTML = `<div class="detail">
<div class="section-fiche"><div class="section-tete">${echapper(e.source)}</div>

View file

@ -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."""