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