#!/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())