#!/usr/bin/env python3 """Genera y sube bundles shallow de las fuentes git del corpus. Ver ADR 0013 y el .sh hermano. Es INCREMENTAL: consulta primero qué commits ya están en el mirror y sólo trabaja los que faltan. Interrumpible sin penalización — cada bundle es independiente. """ import os, re, subprocess, sys, glob, tempfile, shutil ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.chdir(ROOT) SB_USER = os.environ.get("SB_USER", "u647150") SB_HOST = os.environ.get("SB_HOST", "u647150.your-storagebox.de") SB_PORT = os.environ.get("SB_PORT", "23") KEY = os.environ.get("KEY", os.path.expanduser("~/.ssh/github5")) LIMITE = int(os.environ.get("LIMITE", "0")) SOLO = os.environ.get("SOLO", "") DEST = "hammer/fuentes-git" SSH = ["ssh", "-4", "-p", SB_PORT, "-i", KEY, "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=30"] def fuentes_git(): vistos, out = set(), [] for f in sorted(glob.glob("recipes/**/*.toml", recursive=True)): s = open(f, errors="ignore").read() repo = re.search(r'^\s*repo\s*=\s*"([^"]+)"', s, re.M) com = re.search(r'^\s*commit\s*=\s*"([0-9a-fA-F]{7,40})"', s, re.M) if not (repo and com): continue nombre = (re.search(r'^\s*name\s*=\s*"([^"]+)"', s, re.M) or [None, os.path.basename(f)[:-5]])[1] c = com.group(1).lower() if c in vistos: # el mismo commit desde dos colas = un solo bundle continue vistos.add(c) out.append({"receta": nombre, "repo": repo.group(1), "commit": c}) return out def ya_en_mirror(): r = subprocess.run(SSH + [f"{SB_USER}@{SB_HOST}", f"ls {DEST}"], capture_output=True, text=True, timeout=180) return {l.strip()[:-7] for l in r.stdout.splitlines() if l.strip().endswith(".bundle")} def bundle(e, tmpdir): """Clona --depth 1 el commit exacto y emite un bundle. Devuelve (ruta, None) si sale bien y (None, motivo) si no. El motivo es el stderr REAL de git, no una conjetura: un fallo acá puede ser el servidor negando el fetch por sha suelto, pero tambien un timeout, DNS, o un rate-limit, y cada uno se arregla distinto. Ver ADR 0013. """ d = os.path.join(tmpdir, "r") shutil.rmtree(d, ignore_errors=True) os.makedirs(d) def git(*a, **kw): return subprocess.run(["git", "-C", d] + list(a), capture_output=True, text=True, timeout=kw.get("t", 900)) def paso(etiqueta, *a, **kw): try: r = git(*a, **kw) except subprocess.TimeoutExpired: return f"{etiqueta}: timeout" return None if r.returncode == 0 else f"{etiqueta}: {(r.stderr or r.stdout).strip().splitlines()[-1][:160] if (r.stderr or r.stdout).strip() else 'rc=' + str(r.returncode)}" out = os.path.join(tmpdir, f'{e["commit"]}.bundle') for etiqueta, args, kw in ( ("init", ("init", "-q"), {}), ("remote", ("remote", "add", "origin", e["repo"]), {}), # --depth 1 del SHA exacto: el servidor debe permitir fetch por sha (uploadpack.allowReachableSHA1). ("fetch", ("fetch", "-q", "--depth", "1", "origin", e["commit"]), {"t": 1800}), ): motivo = paso(etiqueta, *args, **kw) if motivo: return None, motivo # El campo `commit` de una receta NO siempre es un commit: puede pinear un objeto TAG anotado. # Un SHA de tag es una identidad igual de buena (`git archive` lo acepta) y por eso hash_inputs # no lo distingue, pero `refs/heads/*` sólo apunta a commits. Se pela para la rama y, si hubo # que pelar, el objeto tag viaja aparte: sin él, el `cat-file -e ` del lado de hammer # falla y el mirror queda correcto-pero-inútil justo para estas recetas. r = git("rev-parse", e["commit"] + "^{commit}") if r.returncode != 0: return None, f"rev-parse: {(r.stderr or '').strip().splitlines()[-1][:160]}" pelado = r.stdout.strip() refs = ["refs/heads/hammer"] pasos = [("update-ref", ("update-ref", "refs/heads/hammer", pelado), {})] if pelado != e["commit"]: refs.append("refs/tags/hammer-objeto") pasos.append(("update-ref-tag", ("update-ref", "refs/tags/hammer-objeto", e["commit"]), {})) pasos.append(("bundle", tuple(["bundle", "create", out] + refs), {"t": 1800})) for etiqueta, args, kw in pasos: motivo = paso(etiqueta, *args, **kw) if motivo: return None, motivo return (out, None) if os.path.exists(out) else (None, "bundle: git dijo OK pero no hay fichero") def main(): fs = fuentes_git() if SOLO: fs = [e for e in fs if e["receta"] == SOLO] print(f"== fuentes git en el corpus: {len(fs)} commits distintos", flush=True) try: hay = ya_en_mirror() except Exception as ex: print(f"!! no pude listar el mirror ({ex}); asumo vacío", flush=True); hay = set() print(f"== ya en el mirror: {len(hay)}", flush=True) faltan = [e for e in fs if e["commit"] not in hay] if LIMITE: faltan = faltan[:LIMITE] print(f"== a espejar ahora: {len(faltan)}", flush=True) subprocess.run(SSH + [f"{SB_USER}@{SB_HOST}", f"mkdir hammer {DEST}"], capture_output=True, text=True) ok = fallo = 0 tmpdir = tempfile.mkdtemp(prefix="hammer-git-mirror-") try: for i, e in enumerate(faltan, 1): # Placeholder deliberado (llimphi-counter): su commit es 000…0 y no se puede espejar # nunca. Se distingue del fallo real a propósito — un ✗ permanente en cada tanda entrena # a no mirar los rojos, y entonces el que sí importa pasa desapercibido. if set(e["commit"]) == {"0"}: print(f" [{i}/{len(faltan)}] {e['receta']:<24} · placeholder, no espejable", flush=True) continue b, motivo = bundle(e, tmpdir) if not b: fallo += 1 print(f" [{i}/{len(faltan)}] {e['receta']:<24} ✗ {motivo}", flush=True) continue mb = os.path.getsize(b) / 1e6 r = subprocess.run(["rsync", "-a", "-e", " ".join(SSH), b, f"{SB_USER}@{SB_HOST}:{DEST}/"], capture_output=True, text=True, timeout=3600) if r.returncode == 0: ok += 1 print(f" [{i}/{len(faltan)}] {e['receta']:<24} ✓ {mb:6.1f} MB", flush=True) else: fallo += 1 print(f" [{i}/{len(faltan)}] {e['receta']:<24} ✗ subida: {r.stderr.strip()[:60]}", flush=True) os.remove(b) finally: shutil.rmtree(tmpdir, ignore_errors=True) print(f"== espejados {ok} fallidos {fallo}") return 0 sys.exit(main())