Set-OPS-Public/scripts/forge_comptes.py
Daniel Allaire 548d35b1b5 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>
2026-10-11 22:47:06 -04:00

227 lines
11 KiB
Python

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