Un runner neuf face a une forge neuve n'avait aucun clone a avancer : recevoir() refusait. Il clone desormais depuis le colis, puis suit le chemin ordinaire (verification, avance rapide, poussee). Une destination existante qui n'est pas un depot reste un refus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
8.5 KiB
Python
186 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Porter des commits jusqu'a une forge qu'on ne route pas — par colis, pas par reseau.
|
|
|
|
POURQUOI CE DETOUR, ET POURQUOI IL EST BON.
|
|
|
|
La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route
|
|
pas — et c'est voulu : le poste n'a de patte que sur l'administration, la frontiere police
|
|
le reste. Il ne peut donc pas pousser lui-meme.
|
|
|
|
On ne perce pas un chemin pour lui. Le RUNNER existe exactement pour ca : il est dans le
|
|
site, il porte le genome, il a une identite. On lui PORTE les commits dans un `git bundle`
|
|
— un fichier, verifiable, qui ne demande aucun reseau — et c'est LUI qui pousse.
|
|
|
|
Mesure du 2026-08-26 : la forge du site etait restee quatre commits derriere le poste, sans
|
|
que rien ne le signale. Le runner clonait donc un moteur perime — dont, ce jour-la, le
|
|
correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une forge
|
|
en retard reproduit ses defauts.
|
|
|
|
DEUX GESTES, DEUX COTES :
|
|
|
|
genome_colis.py emballer <depot> <colis>
|
|
Sur le CONTROLEUR. Fabrique le colis a partir de `main`.
|
|
|
|
genome_colis.py recevoir <depot> <colis> <pushurl> [--cle <fichier>]
|
|
Sur le RUNNER. Verifie le colis, avance `main` EN AVANCE RAPIDE SEULEMENT,
|
|
puis pousse. Rend du JSON : {avant, apres, pousse}.
|
|
|
|
genome_colis.py etat <depot>
|
|
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge.
|
|
|
|
LES ETIQUETTES VOYAGENT AVEC LA BRANCHE (2026-10-07). Le colis ne portait que `main` : les
|
|
releases n'arrivaient jamais sur la forge du site, qui fait pourtant autorite (D-81).
|
|
`v2026.08.21` et `v2026.10.07` y manquaient, et il a fallu les pousser a la main. Le colis
|
|
emporte desormais toutes les etiquettes du poste ; le runner les recoit et les pousse. Une
|
|
etiquette qui existe deja SOUS UN AUTRE OBJET est refusee, des deux cotes : une release ne
|
|
bouge pas.
|
|
|
|
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
|
|
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
|
|
ce qu'il faut reconcilier.
|
|
|
|
CE SCRIPT VIT DANS LE MOTEUR, donc le runner en a une copie des qu'il l'a clone : on ne
|
|
depose rien de special sur la machine pour qu'elle sache recevoir.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _git(depot: Path, *args: str, env: dict | None = None) -> str:
|
|
"""Un git, dans un depot, qui parle clair quand il echoue."""
|
|
r = subprocess.run(["git", "-C", str(depot), *args],
|
|
capture_output=True, text=True, env=env)
|
|
if r.returncode != 0:
|
|
sortie = (r.stderr or r.stdout or "").strip()
|
|
raise SystemExit(f"git {' '.join(args)} a echoue dans {depot} :\n {sortie}")
|
|
return (r.stdout or "").strip()
|
|
|
|
|
|
def etiquettes(depot: Path) -> list[str]:
|
|
return sorted(e for e in _git(depot, "tag", "-l").splitlines() if e)
|
|
|
|
|
|
def emballer(depot: Path, colis: Path, branche: str) -> int:
|
|
if not (depot / ".git").exists():
|
|
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
|
|
colis.parent.mkdir(parents=True, exist_ok=True)
|
|
_git(depot, "bundle", "create", str(colis), branche, "--tags")
|
|
print(json.dumps({"depot": str(depot), "colis": str(colis),
|
|
"tete": _git(depot, "log", "-1", "--format=%H"),
|
|
"etiquettes": etiquettes(depot)}))
|
|
return 0
|
|
|
|
|
|
def etat(depot: Path, branche: str) -> int:
|
|
"""Ce que cette copie porte — pour le confronter a ce que la forge porte.
|
|
|
|
LA DERIVE NE SE VOIT PAS TOUTE SEULE. Le 2026-08-26, la forge du site etait restee
|
|
quatre commits derriere le poste sans que rien ne le signale ; le runner clonait donc
|
|
un moteur perime, dont le correctif qui desarme le pare-feu Proxmox. On rend donc
|
|
l'ecart LISIBLE, plutot que d'esperer s'en souvenir.
|
|
"""
|
|
if not (depot / ".git").exists():
|
|
raise SystemExit(f"{depot} n'est pas un depot git.")
|
|
print(json.dumps({
|
|
"tete": _git(depot, "log", "-1", "--format=%H", branche),
|
|
"courte": _git(depot, "log", "-1", "--format=%h", branche),
|
|
"sujet": _git(depot, "log", "-1", "--format=%s", branche)[:80],
|
|
"propre": _git(depot, "status", "--porcelain") == "",
|
|
}))
|
|
return 0
|
|
|
|
|
|
def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
|
|
branche: str) -> int:
|
|
if not colis.is_file():
|
|
raise SystemExit(f"{colis} est introuvable : le colis n'est pas arrive.")
|
|
# LA PREMIERE RECEPTION (2026-10-13). Un runner NEUF, face a une forge NEUVE, n'a aucun
|
|
# clone a avancer : il ne peut cloner que ce que la forge porte, et la forge ne porte que
|
|
# ce que ce colis va lui donner. On clone donc DEPUIS LE COLIS — puis le chemin ordinaire
|
|
# s'applique (verification, avance rapide, poussee). Une destination qui existe sans etre
|
|
# un depot reste un refus : on n'ecrase jamais un dossier qu'on ne connait pas.
|
|
if not (depot / ".git").exists():
|
|
if depot.exists() and any(depot.iterdir()):
|
|
raise SystemExit(f"{depot} existe et n'est pas un depot git — rien a recevoir.")
|
|
r = subprocess.run(["git", "clone", "-q", "--branch", branche, str(colis), str(depot)],
|
|
capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise SystemExit(f"premiere reception impossible depuis le colis :\n "
|
|
f"{((r.stdout or '') + (r.stderr or '')).strip()}")
|
|
_git(depot, "remote", "set-url", "origin", pushurl)
|
|
|
|
# UN COLIS DOIT ETRE VERIFIABLE AVANT D'ETRE CRU. `git bundle verify` exige d'etre
|
|
# lance DEPUIS un depot — il verifie que les prerequis du colis sont deja dans cette
|
|
# histoire-ci. Lance ailleurs : « la verification d'un colis requiert un depot ».
|
|
_git(depot, "bundle", "verify", str(colis))
|
|
|
|
avant = _git(depot, "log", "-1", "--format=%H")
|
|
_git(depot, "fetch", str(colis),
|
|
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
|
|
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
|
|
apres = _git(depot, "log", "-1", "--format=%H")
|
|
# Sans `--force` : une etiquette deja presente sous un autre objet fait echouer la
|
|
# recuperation (« would clobber existing tag ») — c'est voulu.
|
|
if any("refs/tags/" in l for l in _git(depot, "bundle", "list-heads", str(colis)).splitlines()):
|
|
_git(depot, "fetch", str(colis), "refs/tags/*:refs/tags/*")
|
|
|
|
env = None
|
|
if cle:
|
|
import os
|
|
env = dict(os.environ)
|
|
env["GIT_SSH_COMMAND"] = (
|
|
f"ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i {cle}")
|
|
|
|
# `-c remote.origin.pushurl=…` plutot qu'un remote ecrit : la LECTURE reste anonyme
|
|
# (doctrine de `serveur_ops` — aucun justificatif a detenir pour se reconstruire),
|
|
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
|
|
r = subprocess.run(
|
|
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
|
|
"push", "origin", branche, "refs/tags/*:refs/tags/*"],
|
|
capture_output=True, text=True, env=env)
|
|
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
|
|
if r.returncode != 0:
|
|
raise SystemExit(f"la poussee a echoue :\n {sortie}")
|
|
|
|
print(json.dumps({"avant": avant, "apres": apres,
|
|
"pousse": f"{branche} -> {branche}" in sortie,
|
|
"etiquettes": etiquettes(depot), "sortie": sortie[:400]}))
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
sous = p.add_subparsers(dest="geste", required=True)
|
|
|
|
e = sous.add_parser("emballer", help="fabriquer un colis depuis `main` (controleur)")
|
|
e.add_argument("depot")
|
|
e.add_argument("colis")
|
|
e.add_argument("--branche", default="main")
|
|
|
|
r = sous.add_parser("recevoir", help="verifier, avancer, pousser (runner)")
|
|
r.add_argument("depot")
|
|
r.add_argument("colis")
|
|
r.add_argument("pushurl")
|
|
r.add_argument("--cle", default=None, help="cle SSH du runner")
|
|
r.add_argument("--branche", default="main")
|
|
|
|
d = sous.add_parser("etat", help="ce que cette copie porte")
|
|
d.add_argument("depot")
|
|
d.add_argument("--branche", default="main")
|
|
|
|
a = p.parse_args(argv)
|
|
if a.geste == "etat":
|
|
return etat(Path(a.depot), a.branche)
|
|
if a.geste == "emballer":
|
|
return emballer(Path(a.depot), Path(a.colis), a.branche)
|
|
return recevoir(Path(a.depot), Path(a.colis), a.pushurl, a.cle, a.branche)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|