Files
takana/crates/hammer-build/src/fetch.rs
T
sergioandClaude Opus 4.8 4291b2adeb Etapa G frente Go: vendoring integrado en fetch — amfora construye, repro
Cierra el muro del frente Go (vendoring) replicando el patron de Rust:
vendorear in-situ en el fetch (host, con red), no tarball-al-mirror.

- fetch.rs: vendor_go_deps() analoga a vendor_cargo_deps — corre go mod vendor
  en el host si hay go.mod; el sandbox compila offline con -mod=vendor.
- lib.rs: engancha vendor_go_deps tras los patches cuando el arbol trae go.mod
  (sin BuildSys::Go; el compile lo fija la receta en build.phases).
- bootstrap-devfs.sh: instala el toolchain go del host (1.26.4, estatico) en
  .dev-fs/tools/go + symlink en ~/.cargo/bin (PATH del worker via .cargo/env).
- amfora: PRIMERA receta Go del corpus. Build end-to-end validado (repo+commit
  -> fetch vendorea -> sandbox offline -> binario estatico que corre, Amfora
  v1.11.0). REPRODUCIBLE bit-a-bit: dos stores independientes dan of_tree
  b3:9ec1d5fe (trimpath + buildid= en el compile). Promovida al repo (282).

Quedan 12 recetas Go en tandas/staged-2026-06-26/ (cheat/dnsx/croc/dyff/csvtk/
git-town/miller/jump/gron/qrcp/walk/gtrash): mecanico, copiar el patron de
amfora ajustando el nombre del binario.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 04:34:56 -04:00

390 lines
15 KiB
Rust

//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
//!
//! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`):
//!
//! * **Git** — mirror local en `<work_root>/repos/<name>.git` + `git archive` del commit
//! fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable.
//! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se
//! cachean los archivos descargados en `<work_root>/tarballs/`.
//!
//! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del
//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
//! copia (sin red).
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use hammer_core::{Recipe, SourceKind};
/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
match recipe.source.kind()? {
SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
}
}
fn fetch_git(
recipe: &Recipe,
repo: &str,
commit: &str,
work_root: &Path,
) -> hammer_core::Result<PathBuf> {
let repos_dir = work_root.join("repos");
let sources_dir = work_root.join("sources");
std::fs::create_dir_all(&repos_dir)?;
std::fs::create_dir_all(&sources_dir)?;
let mirror = repos_dir.join(format!("{}.git", recipe.name));
if !mirror.is_dir() {
run_git(
&[
"clone",
"--mirror",
"--filter=blob:none",
repo,
mirror.to_str().unwrap(),
],
None,
)?;
}
// Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
hammer_core::Error::Other(anyhow::anyhow!(
"commit {commit} no existe en {repo} tras fetch"
))
})?;
}
// Materializa un árbol trabajable mediante `git archive` → tar → extract.
// Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
if work_tree.is_dir() {
std::fs::remove_dir_all(&work_tree)?;
}
std::fs::create_dir_all(&work_tree)?;
let archive = Command::new("git")
.arg("-C")
.arg(&mirror)
.args(["archive", "--format=tar", commit])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
let tar_status = Command::new("tar")
.args(["-x", "-C"])
.arg(&work_tree)
.stdin(archive.stdout.unwrap())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
if !tar_status.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"git archive | tar -x falló (tar exit {:?})",
tar_status.code()
)));
}
Ok(work_tree)
}
fn fetch_tarball(
recipe: &Recipe,
url: &str,
sha256: &str,
work_root: &Path,
) -> hammer_core::Result<PathBuf> {
let tarballs_dir = work_root.join("tarballs");
let sources_dir = work_root.join("sources");
std::fs::create_dir_all(&tarballs_dir)?;
std::fs::create_dir_all(&sources_dir)?;
// Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
// cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
let cached = tarballs_dir.join(format!("{sha256}.tar"));
if !cached.is_file() {
download_verify(url, sha256, &cached)?;
} else {
verify_sha256(&cached, sha256)?;
}
let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
if work_tree.is_dir() {
std::fs::remove_dir_all(&work_tree)?;
}
std::fs::create_dir_all(&work_tree)?;
// `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
// identificador interno (no afecta a la detección).
let strip = recipe.source.strip_components.to_string();
let status = Command::new("tar")
.arg("-x")
.arg("-f")
.arg(&cached)
.arg("--strip-components")
.arg(&strip)
.arg("-C")
.arg(&work_tree)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
if !status.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"tar -x falló (exit {:?})",
status.code()
)));
}
Ok(work_tree)
}
/// 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");
let status = Command::new("curl")
.args(["-fL", "--retry", "3", "--connect-timeout", "30", "-o"])
.arg(&tmp)
.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()
)));
}
if let Err(e) = verify_sha256(&tmp, sha256) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, dst)?;
Ok(())
}
fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
use sha2::{Digest, Sha256};
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut f, &mut hasher)?;
let got = hex::encode(hasher.finalize());
if !got.eq_ignore_ascii_case(expected) {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"sha256 mismatch en {}: esperado {expected}, obtenido {got}",
path.display()
)));
}
Ok(())
}
/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
///
/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
///
/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
///
/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
pub fn vendor_cargo_deps(src: &Path) -> hammer_core::Result<()> {
let locked = src.join("Cargo.lock").is_file();
let mut args: Vec<&str> = vec!["vendor"];
if locked {
args.push("--locked");
} else {
tracing::warn!(
src = %src.display(),
"sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
);
}
args.push("vendor");
let output = Command::new("cargo")
.current_dir(src)
.args(&args)
.stderr(Stdio::inherit())
.output()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
if !output.status.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"cargo vendor falló (exit {:?})",
output.status.code()
)));
}
// `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
// árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
// resuelve igual dentro del sandbox donde el source se monta en /src).
let cargo_dir = src.join(".cargo");
std::fs::create_dir_all(&cargo_dir)?;
std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
Ok(())
}
/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
///
/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
///
/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> {
let output = Command::new("go")
.current_dir(src)
.args(["mod", "vendor"])
.env("GOFLAGS", "-mod=mod")
.env("GOTOOLCHAIN", "local")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.output()
.map_err(|e| {
hammer_core::Error::Other(anyhow::anyhow!(
"spawn go mod vendor: {e} (¿está `go` en el PATH del host? lo provee el lab, \
cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
))
})?;
if !output.status.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"go mod vendor falló (exit {:?})",
output.status.code()
)));
}
Ok(())
}
/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
for p in &recipe.source.patches {
let patch_path = recipe.base_dir.join(p);
let bytes = std::fs::read(&patch_path).map_err(|e| {
hammer_core::Error::Recipe(format!(
"no pude leer patch {}: {e}",
patch_path.display()
))
})?;
let mut child = Command::new("patch")
.args(["-p1", "--batch", "--silent"])
.current_dir(tree)
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin
.write_all(&bytes)
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
}
let status = child
.wait()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
if !status.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"patch {} falló (exit {:?})",
patch_path.display(),
status.code()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vendor_cargo_deps_runs_and_writes_config() {
// Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
// Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path();
std::fs::write(
src.join("Cargo.toml"),
"[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
)
.unwrap();
std::fs::create_dir_all(src.join("src")).unwrap();
std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
// `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
let lock_ok = Command::new("cargo")
.args(["generate-lockfile", "--offline"])
.current_dir(src)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !lock_ok {
eprintln!("SKIP: cargo no disponible para generate-lockfile");
return;
}
vendor_cargo_deps(src).unwrap();
assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
}
#[test]
fn vendor_cargo_deps_without_committed_lock() {
// Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
// generando el lock al vuelo. Para un crate depless es offline.
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path();
std::fs::write(
src.join("Cargo.toml"),
"[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
)
.unwrap();
std::fs::create_dir_all(src.join("src")).unwrap();
std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
eprintln!("SKIP: cargo no disponible");
return;
}
// No debe fallar por falta de --locked; genera el lock y escribe el config.
vendor_cargo_deps(src).unwrap();
assert!(src.join(".cargo/config.toml").is_file());
assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
}
}
fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
let mut cmd = Command::new("git");
if let Some(c) = cwd {
cmd.arg("-C").arg(c);
}
cmd.args(args);
let status = cmd
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
if !status.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"git {args:?} falló (exit {:?})",
status.code()
)));
}
Ok(())
}