From 82a2c4a4c230b306ec8354a1cecd291d1278d528 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 11 Oct 2026 20:58:08 -0400 Subject: [PATCH] forge : le colis se recoit hors d'un depot, et forge-cles reconcilie la visibilite L'epreuve du colis sur le runner de Technolibre : git bundle verify exige un depot existant, et l'insemination est le cas ou il n'existe pas encore. Le clone verifie le colis lui-meme ; un depot existant le verifie avant de tirer. Les colis restes d'un passage interrompu sont retires. make forge-cles rend aussi chaque depot prive ou public selon le role que lui donne le plan du site, et seulement une fois les cles de lecture en place. 95 preuves. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 42 +++++++++++++++++++ playbooks/maintenance/forge_cles.yml | 2 +- roles/serveur_ops/tasks/main.yml | 15 ++++++- scripts/forge_cles.py | 62 ++++++++++++++++++++++++---- scripts/tests/test_forge_cles.py | 14 +++++++ 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 725a479..e6cc13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # CHANGELOG — Set-OPS +## 2026-10-11 (134) — L'épreuve du colis a trouvé le défaut qui aurait bloqué l'insémination + +**95 preuves.** `make verifier` passe, et `test_forge_cles.py` couvre la visibilité. Les trois +runners lisent leurs dépôts privés en SSH ; les dépôts sont encore publics. + +### Déployé sans basculer (étape 3 du chantier) + +`serveur_ops` a été simulé puis appliqué sur les deux runners des locataires et sur +`site-ops-01`. Chacun a épinglé la clé d'hôte de la forge et tiré ses dépôts privés en SSH : +`origin` vise désormais `git@…:genome/.git`, et chaque dépôt est au niveau de la forge. +`make forge-etancheite` le confirme : chaque runner lit son dépôt, la forge lui refuse les +autres. Il ne reste que 3 écarts, attendus, que la bascule doit fermer : les trois dépôts +privés se lisent encore sans identifiant. + +### Le colis, éprouvé, puis corrigé + +Une première épreuve n'a rien prouvé : pointer la clé du runner vers un fichier absent ne la +retire pas, car OpenSSH se rabat sur la clé par défaut du compte, qui était la bonne. Forcée +pour de bon (commande SSH remplacée par `false`), la voie du colis a échoué : + + erreur : la vérification d'un colis requiert un dépôt + +`git bundle verify` ne fonctionne que dans un dépôt existant, alors que l'insémination est +précisément le cas où le dépôt n'existe pas encore. La première insémination avec des dépôts +privés se serait arrêtée là. Mesuré sur le poste, dans des dossiers jetables : hors d'un dépôt, +`git clone` depuis un colis fonctionne et refuse un colis tronqué, tandis que +`git bundle list-heads` accepte même un colis tronqué. Le rôle vérifie donc le colis dans le +dépôt quand il existe, et laisse le clonage le vérifier quand il n'existe pas. Rejouée, la +voie du colis passe ; un passage normal remet la commande SSH et retire le colis resté du +passage interrompu. + +**Limite.** Le clonage neuf depuis un colis n'est éprouvé que sur le poste. Sur un runner, il +le sera à la reconstruction de Technolibre. + +### La visibilité devient une réconciliation + +`make forge-cles` met aussi chaque dépôt privé ou public selon le rôle que lui donne le plan +du site (`instance`, `tenant` et `hebergeur` sont privés). Il ne change une visibilité que si +les clés de lecture sont déjà en place : un dépôt ne devient jamais privé avant que son runner +puisse le lire. La bascule est ainsi reproductible, et un dépôt redevenu public par erreur est +remis en ordre au passage suivant. + ## 2026-10-11 (133) — Chaque runner lit son seul dépôt : mesuré, puis écrit dans le rôle **95 preuves.** `make verifier` passe. `test_forge_cles.py` couvre aussi le classement des diff --git a/playbooks/maintenance/forge_cles.yml b/playbooks/maintenance/forge_cles.yml index 1d68b75..7f36396 100644 --- a/playbooks/maintenance/forge_cles.yml +++ b/playbooks/maintenance/forge_cles.yml @@ -35,7 +35,7 @@ # En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner. check_mode: false register: forge_cles - changed_when: "'ajoutee' in forge_cles.stdout or 'retiree' in forge_cles.stdout" + changed_when: "'ajoutee' in forge_cles.stdout or 'retiree' in forge_cles.stdout or 'rendu ' in forge_cles.stdout" failed_when: forge_cles.rc not in [0] - name: Ce que la forge porte diff --git a/roles/serveur_ops/tasks/main.yml b/roles/serveur_ops/tasks/main.yml index e2c4e7a..f91bb01 100644 --- a/roles/serveur_ops/tasks/main.yml +++ b/roles/serveur_ops/tasks/main.yml @@ -470,11 +470,14 @@ d="{{ serveur_ops_racine }}/{{ item.item.dest }}" c="{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle" b="{{ item.item.branche | default('main') }}" - git bundle verify -q "$c" + # `git bundle verify` EXIGE UN DEPOT (mesure du 2026-10-11, a l'epreuve du colis) : hors + # d'un depot il refuse, et c'est justement le cas de l'insemination. Le clone verifie le + # colis lui-meme (un colis tronque y echoue) ; un depot existant le verifie avant de tirer. if [ ! -d "$d/.git" ]; then git clone -q --branch "$b" "$c" "$d" echo "CLONE" else + git -C "$d" bundle verify -q "$c" >/dev/null avant=$(git -C "$d" rev-parse HEAD) git -C "$d" fetch -q "$c" "refs/heads/$b:refs/remotes/colis/$b" git -C "$d" merge -q --ff-only "colis/$b" @@ -498,12 +501,20 @@ ansible.builtin.debug: msg: >- {{ item.item.depot }} : le runner ne le lit pas encore en SSH ({{ (item.stderr | default('') - | trim).splitlines()[-1:] | join('') }}). Apporte en colis par le controleur. S'il est + | trim).splitlines()[:1] | join('') }}). Apporte en colis par le controleur. S'il est arme et que ca dure : `make forge-cles` au site, et la cle de sa voute. loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}" loop_control: label: "{{ item.item.depot }}" +- name: Retirer les colis qui resteraient (un passage interrompu en laisse) + ansible.builtin.file: + path: "{{ serveur_ops_racine }}/.colis" + state: absent + when: + - not ansible_check_mode + - serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length == 0 + - name: Poser la commande SSH de la forge dans chaque depot prive community.general.git_config: name: core.sshCommand diff --git a/scripts/forge_cles.py b/scripts/forge_cles.py index fa29c4b..6210257 100644 --- a/scripts/forge_cles.py +++ b/scripts/forge_cles.py @@ -41,6 +41,9 @@ RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) PREFIXE_TITRE = "lecture — runner de " +# LA VISIBILITE SUIT LE ROLE (2026-10-11) : le plan d'un ecosysteme et la carte du site sont +# prives ; le moteur et les modeles, publics. La MEME liste que `serveur_ops_roles_prives`. +ROLES_PRIVES = {"instance", "tenant", "hebergeur"} def titre_de(locataire: str) -> str: @@ -94,6 +97,22 @@ def plan(voulues: list[dict], existantes: dict[str, list[dict]]) -> tuple[list, return ajouter, retirer, refus +def visibilites_voulues() -> dict[str, bool]: + """depot -> prive ?, d'apres `serveur_ops_depots` du plan du site monte (son runner).""" + import underlay as underlay_mod + plan = underlay_mod.lire_plan_site("serveurs.yml") or {} + for srv in (plan.get("serveurs") or {}).values(): + d = (srv.get("variables") or {}).get("serveur_ops_depots") + if d: + return {str(x["depot"]): str(x.get("role")) in ROLES_PRIVES for x in d} + return {} + + +def plan_visibilite(voulues: dict[str, bool], actuelles: dict[str, bool]) -> list[tuple[str, bool]]: + """(depot, prive voulu) pour chaque depot dont la visibilite differe. Pur.""" + return [(d, v) for d, v in sorted(voulues.items()) if d in actuelles and actuelles[d] != v] + + class Forge: def __init__(self, base: str, organisation: str, mot_de_passe: str, utilisateur: str = "forgejo-admin"): @@ -127,6 +146,13 @@ class Forge: def retirer(self, depot: str, ident: int) -> int: return self._api(f"/repos/{self.org}/{depot}/keys/{ident}", "DELETE")[0] + def prive(self, depot: str) -> bool | None: + st, rep = self._api(f"/repos/{self.org}/{depot}") + return bool(rep.get("private")) if st == 200 and isinstance(rep, dict) else None + + def rendre(self, depot: str, prive: bool) -> int: + return self._api(f"/repos/{self.org}/{depot}", "PATCH", {"private": prive})[0] + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) @@ -150,10 +176,15 @@ def main(argv: list[str] | None = None) -> int: return 2 base = f"https://forge.{dom}" voulues = demandes(a.site) - if not voulues: - print("Aucun locataire ne nomme ce site hebergeur : rien a enregistrer.") - return 0 forge = Forge(base, a.organisation, mdp) + # LES CLES D'ABORD, LA VISIBILITE ENSUITE : un depot ne devient prive que quand son runner + # peut deja le lire. L'ordre inverse couperait le runner entre les deux. + vis_voulues = visibilites_voulues() + vis_actuelles = {d: v for d in vis_voulues if (v := forge.prive(d)) is not None} + a_basculer = plan_visibilite(vis_voulues, vis_actuelles) + if not voulues and not a_basculer: + print("Aucun locataire ne nomme ce site hebergeur, et chaque depot a sa visibilite.") + return 0 existantes = {v["depot"]: forge.cles(v["depot"]) for v in voulues if v["depot"]} ajouter, retirer, refus = plan(voulues, existantes) for v in voulues: @@ -166,6 +197,12 @@ def main(argv: list[str] | None = None) -> int: print(f" {x['locataire']:<18} {x['depot']:<18} cle perimee #{x['id']} a retirer") for r in refus: print(f" REFUS {r}") + print("Visibilite des depots (d'apres le role que leur donne le plan du site) :") + for d, v in sorted(vis_voulues.items()): + act = vis_actuelles.get(d) + etat = ("introuvable" if act is None else "ok" if act == v + else f"a rendre {'prive' if v else 'public'}") + print(f" {d:<18} {'prive ' if v else 'public'} {etat}") if not a.confirmer: print("\nRien n'a ete ecrit. Relancer avec --confirmer (make forge-cles CONFIRMER=true).") return 1 if refus else 0 @@ -178,14 +215,25 @@ def main(argv: list[str] | None = None) -> int: st = forge.ajouter(v["depot"], titre_de(v["locataire"]), v["cle"]) echecs += st not in (201,) print(f" ajoutee {v['depot']} ({v['locataire']}) : HTTP {st}") - # RELIRE, PAS SE FIER AU CODE DE RETOUR (D-68). + # LA VISIBILITE, SEULEMENT SI LES CLES SONT EN PLACE (voir plus haut). relu = {v["depot"]: forge.cles(v["depot"]) for v in voulues if v["depot"]} reste_a, reste_r, _ = plan(voulues, relu) if reste_a or reste_r or echecs or refus: - print(f"\nECART apres ecriture : {len(reste_a)} a ajouter, {len(reste_r)} a retirer, " - f"{echecs} echec(s), {len(refus)} refus.") + print(f"\nECART apres ecriture des cles : {len(reste_a)} a ajouter, {len(reste_r)} a retirer, " + f"{echecs} echec(s), {len(refus)} refus. Aucune visibilite n'a ete changee.") return 1 - print("\nChaque depot de locataire porte exactement la cle de lecture que sa face publie.") + for d, v in a_basculer: + st = forge.rendre(d, v) + echecs += st != 200 + print(f" rendu {'prive ' if v else 'public'} {d} : HTTP {st}") + # RELIRE, PAS SE FIER AU CODE DE RETOUR (D-68). + vis_relues = {d: forge.prive(d) for d in vis_voulues} + reste_v = plan_visibilite(vis_voulues, {d: x for d, x in vis_relues.items() if x is not None}) + if reste_v or echecs: + print(f"\nECART apres ecriture : {len(reste_v)} visibilite(s) a corriger, {echecs} echec(s).") + return 1 + print("\nChaque depot de locataire porte exactement la cle de lecture que sa face publie, " + "et chaque depot la visibilite que son role demande.") return 0 diff --git a/scripts/tests/test_forge_cles.py b/scripts/tests/test_forge_cles.py index 829e423..cee97d0 100644 --- a/scripts/tests/test_forge_cles.py +++ b/scripts/tests/test_forge_cles.py @@ -53,6 +53,20 @@ class TestPlan(unittest.TestCase): self.assertEqual((a, r, len(x)), ([], [], 1)) +class TestVisibilite(unittest.TestCase): + def test_seuls_les_ecarts_sont_bascules(self): + voulues = {"set-ops-public": False, "ops-essai": True, "site-essai": True} + actuelles = {"set-ops-public": False, "ops-essai": False, "site-essai": True} + self.assertEqual(fc.plan_visibilite(voulues, actuelles), [("ops-essai", True)]) + + def test_un_depot_introuvable_n_est_pas_bascule(self): + self.assertEqual(fc.plan_visibilite({"absent": True}, {}), []) + + def test_un_public_redevient_public(self): + self.assertEqual(fc.plan_visibilite({"set-ops-public": False}, {"set-ops-public": True}), + [("set-ops-public", False)]) + + class TestClassement(unittest.TestCase): """`forge_etancheite` : un refus RESEAU n'est jamais lu comme une protection."""