tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1181Shell 82Rust 62Markdown 521515 Definiciones

ADR 0013: mirror de fuentes git — bundles shallow por commit

Los 606 repos por commit son el 52% de las fuentes y el mirror de tarballs no los cubría. Se espejan
como `hammer/fuentes-git/{commit}.bundle` — 571 commits distintos, porque hay commits compartidos
entre colas y se espeja uno solo.

La identidad es el commit y la verificación la hace git: al desempaquetar comprueba cada objeto
contra su SHA, así que un bundle alterado no pasa. No hace falta índice ni sha256 aparte.

SHALLOW, NO CLONES COMPLETOS. El bundle sale de un `fetch --depth 1` del commit exacto: para `act`
son 9,3 MB en vez del repo entero, y con 571 fuentes eso decide si el mirror cabe. Es legítimo
porque hammer NUNCA usa la historia — lo único que hace con un repo es `git archive <commit> |
tar -x`, materializar un árbol.

EL DETALLE QUE COSTÓ ENCONTRAR. Un bundle hecho desde un repo shallow no lleva la frontera de
historia, y al desempaquetarlo git aborta con «Failed to traverse parents … did not send all
necessary objects». El mensaje dice que faltan objetos y es ENGAÑOSO: llegan enteros —`git archive`
ya funciona pese al error—; lo que falta es decirle a git dónde termina la historia. Se escribe el
propio commit en `<destino>/shallow` antes del fetch. No hay nada que transportar: la frontera de un
`--depth 1` es exactamente ese commit.

Y UN BUG PROPIO QUE VALE DOCUMENTAR: se pasaba al `git fetch` la ruta RELATIVA del bundle, y como
`run_git` invoca `git -C <destino>`, git la resolvía dentro de `<destino>`. Como el fallo del mirror
se traga a propósito para caer a upstream, el síntoma salía lejísimos: el build moría con «commit …
no existe en <repo> tras fetch», culpando a upstream de un error de ruta local. Ése es el precio de
que el mirror falle en silencio, y por eso el silencio se paga con comentarios explícitos.

Verificado igual que el de tarballs, con receta EFÍMERA para que no haya cache-hit: commit de `act`
ya espejado + un repo cuyo host no resuelve por DNS. Sin HAMMER_MIRROR_GIT falla en el clone; con
él, sella — y el artefacto trae el README.md real de act.

`cargo test -p hammer-build`: 5/5. Población de los 571 bundles corriendo aparte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o

8ab5040a8d151b4817b053cad18464f65d7ebb8f · por Sergio · (hace 1 mes) · padre f5decc26b · ver el árbol en este commit

Delta semántico · 9

crates/hammer-build/src/fetch.rs

scripts/fuentes/mirror-git-poblar.py

5 archivos · +257 −6

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 <commit> | 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 `<destino>/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 <destino> …`, y git resuelve una ruta
+    // relativa de bundle DENTRO de ese `-C` — o sea buscaría `<destino>/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 <repo> 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 <commit> |
+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
+`<destino>/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 <destino> …`, git la resolvía **dentro de `<destino>`** 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 \<repo\> 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 <commit> | 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 "$@"