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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 20:58:08 -04:00
parent d7717a4f98
commit 82a2c4a4c2
5 changed files with 125 additions and 10 deletions

View file

@ -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/<dépôt>.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

View file

@ -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

View file

@ -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

View file

@ -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

View file

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