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:
parent
6df04888ab
commit
548d35b1b5
8 changed files with 448 additions and 3 deletions
34
CHANGELOG.md
34
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
|
||||
|
|
|
|||
8
Makefile
8
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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
playbooks/maintenance/forge_comptes.yml
Normal file
46
playbooks/maintenance/forge_comptes.yml
Normal 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
227
scripts/forge_comptes.py
Normal 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())
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue