diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs index 1de5371b..0a3a3947 100644 --- a/crates/hammer-build/src/fetch.rs +++ b/crates/hammer-build/src/fetch.rs @@ -36,6 +36,15 @@ fn fetch_git( std::fs::create_dir_all(&sources_dir)?; let mirror = repos_dir.join(format!("{}.git", recipe.name)); + + // Mirror propio de fuentes git (ADR 0013). Se intenta ANTES que el remoto, por la misma razón + // que en los tarballs: git verifica cada objeto por su SHA al desempaquetar, así que no hay + // diferencia de contenido posible entre las dos rutas — y un mirror que sólo se usa cuando + // upstream falla es un mirror que nadie prueba. + if !mirror.is_dir() || run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() { + let _ = hidratar_desde_bundle(commit, &mirror); + } + if !mirror.is_dir() { run_git( &[ @@ -94,6 +103,69 @@ fn fetch_git( Ok(work_tree) } +/// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013). +/// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el +/// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre. +/// +/// # Por qué un bundle SHALLOW y no un clon completo +/// +/// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez +/// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es +/// `git archive | tar -x`, o sea materializar UN árbol. +/// +/// # El detalle que hace que esto funcione: el fichero `shallow` +/// +/// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al +/// desempaquetarlo git intenta recorrer los padres y aborta: +/// +/// ```text +/// fatal: Failed to traverse parents of commit 4f41128… +/// error: … did not send all necessary objects +/// ``` +/// +/// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de +/// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la +/// historia. Se arregla escribiendo el propio commit en `/shallow` ANTES del fetch — y no +/// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit. +/// +/// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al +/// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa. +fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool { + let Ok(base) = std::env::var("HAMMER_MIRROR_GIT") else { + return false; + }; + let base = base.trim_end_matches('/'); + if base.is_empty() { + return false; + } + let tmp = destino.with_extension("bundle.partial"); + if let Some(padre) = tmp.parent() { + let _ = std::fs::create_dir_all(padre); + } + let _ = std::fs::remove_file(&tmp); + if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() { + let _ = std::fs::remove_file(&tmp); + return false; + } + if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() { + let _ = std::fs::remove_file(&tmp); + return false; + } + // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos. + let _ = std::fs::write(destino.join("shallow"), format!("{commit}\n")); + // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C …`, y git resuelve una ruta + // relativa de bundle DENTRO de ese `-C` — o sea buscaría `/work/repos/x.bundle.partial`. + // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build + // muere diciendo «commit … no existe en tras fetch», culpando a upstream de un error de + // ruta local. + let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone()); + let refspec = "refs/heads/hammer:refs/heads/hammer"; + let ok = run_git(&["fetch", bundle_abs.to_str().unwrap(), refspec], Some(destino)).is_ok() + && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok(); + let _ = std::fs::remove_file(&tmp); + ok +} + fn fetch_tarball( recipe: &Recipe, url: &str, @@ -199,8 +271,9 @@ fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result< /// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo /// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché). -fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { - let tmp = dst.with_extension("tar.partial"); +/// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba — +/// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles). +fn descargar(url: &str, dst: &Path) -> hammer_core::Result<()> { let mut cmd = Command::new("curl"); cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]); // Clave para el mirror por sftp (Storage Box). Inocua para http/file. @@ -211,19 +284,27 @@ fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<( } let status = cmd .arg("-o") - .arg(&tmp) + .arg(dst) .arg(url) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?; if !status.success() { - let _ = std::fs::remove_file(&tmp); return Err(hammer_core::Error::Other(anyhow::anyhow!( "curl {url} falló (exit {:?})", status.code() ))); } + Ok(()) +} + +fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { + let tmp = dst.with_extension("tar.partial"); + if let Err(e) = descargar(url, &tmp) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } if let Err(e) = verify_sha256(&tmp, sha256) { let _ = std::fs::remove_file(&tmp); return Err(e); diff --git a/docs/adr/0013-mirror-de-fuentes.md b/docs/adr/0013-mirror-de-fuentes.md index 430806d6..f66e0b65 100644 --- a/docs/adr/0013-mirror-de-fuentes.md +++ b/docs/adr/0013-mirror-de-fuentes.md @@ -155,10 +155,54 @@ artefacto tiene el contenido real. Los bytes no pudieron venir de ningún otro s la verdad: la verdad son las recetas en git. Si el mirror se pierde, se repuebla desde cualquier máquina que tenga `work/tarballs`. +## Fuentes git: bundles shallow por commit + +Los 606 repos por commit son el 52% de las fuentes y no los cubre el mirror de tarballs. Se espejan +como **bundles**, en un espacio de nombres propio: `hammer/fuentes-git/{commit}.bundle` (571 commits +distintos — hay commits compartidos entre colas, y se espeja uno solo). + +**La identidad es el commit y la verificación la hace git.** Al desempaquetar, git comprueba cada +objeto contra su SHA: un bundle alterado no pasa. No hace falta índice ni un `sha256` aparte, igual +que en los tarballs el nombre del fichero ES su verificación. + +### Shallow, no clones completos + +El bundle se genera desde un `git fetch --depth 1` del commit exacto. Para `act` son **9,3 MB en vez +del repositorio entero**, y con 571 fuentes esa diferencia decide si el mirror cabe. Es legítimo +porque **hammer nunca usa la historia**: lo único que hace con un repo es `git archive | +tar -x`, o sea materializar un árbol. + +### El detalle que costó encontrar: el fichero `shallow` + +Un bundle hecho desde un repo shallow **no lleva la frontera de historia**. Al desempaquetarlo: + +```text +fatal: Failed to traverse parents of commit 4f41128… +error: … did not send all necessary objects +``` + +El mensaje dice «no envió todos los objetos necesarios» y **es engañoso**: los objetos del commit +llegan enteros — de hecho `git archive` ya funciona pese al error. Lo que falta no es un objeto, es +decirle a git dónde termina la historia. Se arregla escribiendo el propio commit en +`/shallow` **antes** del fetch, y no hay que transportar nada: la frontera de un clon +`--depth 1` es exactamente ese commit. + +### Y un bug propio que vale documentar + +`hidratar_desde_bundle` pasaba al `git fetch` la ruta **relativa** del bundle. Como `run_git` invoca +`git -C …`, git la resolvía **dentro de ``** y no encontraba nada. Como el fallo +del mirror se traga a propósito (para caer a upstream), el síntoma salía lejísimos del origen: el +build moría con *«commit … no existe en \ tras fetch»*, **culpando a upstream de un error de +ruta local**. Se arregla con `canonicalize`. Es el precio de que el mirror falle en silencio, y por +eso el silencio se paga con comentarios explícitos en el código. + ## Lo que este ADR NO decide - Qué hacer con la concentración en GitHub. - Si el mirror debe replicarse fuera de Hetzner (hoy el repo, el respaldo y el mirror están todos en la misma cuenta y el mismo proveedor). -- El mirror de fuentes **git**: hoy sólo se espeja el tarball. Los 606 repos por commit siguen - dependiendo del remoto. Es el siguiente eslabón, y es más grande. +- El **coste de mantener el mirror git al día**: un commit nuevo en una receta es un bundle nuevo, y + hoy eso lo dispara una persona corriendo `mirror-git-poblar.sh`, no el latido. +- Qué hacer con los repos cuyo servidor **no permite fetch por SHA suelto** + (`uploadpack.allowReachableSHA1InWant` desactivado): ahí el `--depth 1` del commit exacto falla y + hay que caer a un clon completo. El poblador los reporta como `✗ upstream no da el commit`. diff --git a/scripts/fuentes/mirror-env.sh b/scripts/fuentes/mirror-env.sh index edec4c39..edba354b 100644 --- a/scripts/fuentes/mirror-env.sh +++ b/scripts/fuentes/mirror-env.sh @@ -12,4 +12,7 @@ # ruta ABSOLUTA del servidor, así que `…:23/hammer/fuentes` busca en la raíz y devuelve # «(78) Could not open remote file for reading» aunque el fichero exista. `/~/` lo ancla al home. export HAMMER_MIRROR="${HAMMER_MIRROR:-sftp://u647150@u647150.your-storagebox.de:23/~/hammer/fuentes}" +# Fuentes git: bundles shallow por commit. Variable APARTE de HAMMER_MIRROR porque son dos espacios +# de nombres distintos —`{sha256}.tar` vs `{commit}.bundle`— y se pueblan y se pierden por separado. +export HAMMER_MIRROR_GIT="${HAMMER_MIRROR_GIT:-sftp://u647150@u647150.your-storagebox.de:23/~/hammer/fuentes-git}" export HAMMER_MIRROR_KEY="${HAMMER_MIRROR_KEY:-$HOME/.ssh/github5}" diff --git a/scripts/fuentes/mirror-git-poblar.py b/scripts/fuentes/mirror-git-poblar.py new file mode 100755 index 00000000..861cab04 --- /dev/null +++ b/scripts/fuentes/mirror-git-poblar.py @@ -0,0 +1,99 @@ +#!/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. None si upstream no lo da.""" + 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)) + if git("init", "-q").returncode != 0: return None + if git("remote", "add", "origin", e["repo"]).returncode != 0: return None + # --depth 1 del SHA exacto: el servidor debe permitir fetch por sha (uploadpack.allowReachableSHA1). + if git("fetch", "-q", "--depth", "1", "origin", e["commit"], t=1800).returncode != 0: + return None + if git("update-ref", "refs/heads/hammer", e["commit"]).returncode != 0: return None + out = os.path.join(tmpdir, f'{e["commit"]}.bundle') + if git("bundle", "create", out, "refs/heads/hammer", t=1800).returncode != 0: return None + return out if os.path.exists(out) else None + +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): + b = bundle(e, tmpdir) + if not b: + fallo += 1 + print(f" [{i}/{len(faltan)}] {e['receta']:<24} ✗ upstream no da el commit", 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()) diff --git a/scripts/fuentes/mirror-git-poblar.sh b/scripts/fuentes/mirror-git-poblar.sh new file mode 100755 index 00000000..2a859637 --- /dev/null +++ b/scripts/fuentes/mirror-git-poblar.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# mirror-git-poblar.sh — espeja las fuentes GIT del corpus como bundles (ADR 0013). +# +# ── POR QUÉ BUNDLES SHALLOW Y NO CLONES COMPLETOS ────────────────────────────────────────────── +# Hammer no usa la historia: lo único que hace con un repo es `git archive | tar -x`, o sea +# materializar UN árbol. Un clon `--depth 1` del commit exacto basta, y para `act` son 8,9 MB en +# lugar del repositorio entero. Con 606 fuentes git, la diferencia decide si el mirror cabe. +# +# El objeto se nombra por CONTENIDO igual que los tarballs: `hammer/fuentes-git/{commit}.bundle`. +# El commit ES su verificación — git comprueba cada objeto contra su SHA al desempaquetar, así que +# un bundle alterado no pasa. No hace falta índice ni sha256 aparte. +# +# ── EL FICHERO `shallow` NO SE SUBE ──────────────────────────────────────────────────────────── +# Un bundle hecho desde un repo shallow no lleva la frontera de historia, y al desempaquetarlo git +# aborta con «Failed to traverse parents». Suena a bundle incompleto y NO lo es: los objetos llegan +# enteros. La frontera de un clon `--depth 1` es exactamente el commit pedido, así que el lado que +# consume la reconstruye sola (lo hace `hidratar_desde_bundle` en fetch.rs). Nada que transportar. +# +# Uso: scripts/fuentes/mirror-git-poblar.sh # todas las que falten +# LIMITE=20 scripts/fuentes/mirror-git-poblar.sh # sólo las 20 primeras +# SOLO=mesa scripts/fuentes/mirror-git-poblar.sh # una receta concreta +set -eu +ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT" +exec python3 scripts/fuentes/mirror-git-poblar.py "$@"