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 1576Shell 205Rust 111Markdown 993774 Definitions

⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21 crates/hammer-build/src/fetch.rs Rust · 354 lines · 13.3 KB History Who touched what raw

build: vendoring con --locked condicional al Cargo.lock committeado (Opción 2) 8d98a6053 · Sergio ·

1//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
2//!
3//! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`):
4//!
5//! * **Git** — mirror local en `<work_root>/repos/<name>.git` + `git archive` del commit
6//!   fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable.
7//! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se
8//!   cachean los archivos descargados en `<work_root>/tarballs/`.
9//!
10//! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del
11//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
12//! copia (sin red).
13
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use hammer_core::{Recipe, SourceKind};
18
19/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
20pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
21    match recipe.source.kind()? {
22        SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
23        SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
24    }
25}
26
27fn fetch_git(
28    recipe: &Recipe,
29    repo: &str,
30    commit: &str,
31    work_root: &Path,
32) -> hammer_core::Result<PathBuf> {
33    let repos_dir = work_root.join("repos");
34    let sources_dir = work_root.join("sources");
35    std::fs::create_dir_all(&repos_dir)?;
36    std::fs::create_dir_all(&sources_dir)?;
37
38    let mirror = repos_dir.join(format!("{}.git", recipe.name));
39    if !mirror.is_dir() {
40        run_git(
41            &[
42                "clone",
43                "--mirror",
44                "--filter=blob:none",
45                repo,
46                mirror.to_str().unwrap(),
47            ],
48            None,
49        )?;
50    }
51
52    // Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
53    if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
54        run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
55        run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
56            hammer_core::Error::Other(anyhow::anyhow!(
57                "commit {commit} no existe en {repo} tras fetch"
58            ))
59        })?;
60    }
61
62    // Materializa un árbol trabajable mediante `git archive` → tar → extract.
63    // Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
64    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
65    if work_tree.is_dir() {
66        std::fs::remove_dir_all(&work_tree)?;
67    }
68    std::fs::create_dir_all(&work_tree)?;
69
70    let archive = Command::new("git")
71        .arg("-C")
72        .arg(&mirror)
73        .args(["archive", "--format=tar", commit])
74        .stdout(Stdio::piped())
75        .stderr(Stdio::piped())
76        .spawn()
77        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
78
79    let tar_status = Command::new("tar")
80        .args(["-x", "-C"])
81        .arg(&work_tree)
82        .stdin(archive.stdout.unwrap())
83        .stdout(Stdio::inherit())
84        .stderr(Stdio::inherit())
85        .status()
86        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
87
88    if !tar_status.success() {
89        return Err(hammer_core::Error::Other(anyhow::anyhow!(
90            "git archive | tar -x falló (tar exit {:?})",
91            tar_status.code()
92        )));
93    }
94    Ok(work_tree)
95}
96
97fn fetch_tarball(
98    recipe: &Recipe,
99    url: &str,
100    sha256: &str,
101    work_root: &Path,
102) -> hammer_core::Result<PathBuf> {
103    let tarballs_dir = work_root.join("tarballs");
104    let sources_dir = work_root.join("sources");
105    std::fs::create_dir_all(&tarballs_dir)?;
106    std::fs::create_dir_all(&sources_dir)?;
107
108    // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
109    // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
110    let cached = tarballs_dir.join(format!("{sha256}.tar"));
111    if !cached.is_file() {
112        download_verify(url, sha256, &cached)?;
113    } else {
114        verify_sha256(&cached, sha256)?;
115    }
116
117    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
118    if work_tree.is_dir() {
119        std::fs::remove_dir_all(&work_tree)?;
120    }
121    std::fs::create_dir_all(&work_tree)?;
122
123    // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
124    // identificador interno (no afecta a la detección).
125    let strip = recipe.source.strip_components.to_string();
126    let status = Command::new("tar")
127        .arg("-x")
128        .arg("-f")
129        .arg(&cached)
130        .arg("--strip-components")
131        .arg(&strip)
132        .arg("-C")
133        .arg(&work_tree)
134        .stdout(Stdio::inherit())
135        .stderr(Stdio::inherit())
136        .status()
137        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
138    if !status.success() {
139        return Err(hammer_core::Error::Other(anyhow::anyhow!(
140            "tar -x falló (exit {:?})",
141            status.code()
142        )));
143    }
144    Ok(work_tree)
145}
146
147/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
148/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
149fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
150    let tmp = dst.with_extension("tar.partial");
151    let status = Command::new("curl")
152        .args(["-fL", "--retry", "3", "--connect-timeout", "30", "-o"])
153        .arg(&tmp)
154        .arg(url)
155        .stdout(Stdio::inherit())
156        .stderr(Stdio::inherit())
157        .status()
158        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
159    if !status.success() {
160        let _ = std::fs::remove_file(&tmp);
161        return Err(hammer_core::Error::Other(anyhow::anyhow!(
162            "curl {url} falló (exit {:?})",
163            status.code()
164        )));
165    }
166    if let Err(e) = verify_sha256(&tmp, sha256) {
167        let _ = std::fs::remove_file(&tmp);
168        return Err(e);
169    }
170    std::fs::rename(&tmp, dst)?;
171    Ok(())
172}
173
174fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
175    use sha2::{Digest, Sha256};
176    let mut f = std::fs::File::open(path)?;
177    let mut hasher = Sha256::new();
178    std::io::copy(&mut f, &mut hasher)?;
179    let got = hex::encode(hasher.finalize());
180    if !got.eq_ignore_ascii_case(expected) {
181        return Err(hammer_core::Error::Other(anyhow::anyhow!(
182            "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
183            path.display()
184        )));
185    }
186    Ok(())
187}
188
189/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
190/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
191///
192/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
193///
194/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
195/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
196/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
197/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
198/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
199/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
200/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
201///
202/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
203pub fn vendor_cargo_deps(src: &Path) -> hammer_core::Result<()> {
204    let locked = src.join("Cargo.lock").is_file();
205    let mut args: Vec<&str> = vec!["vendor"];
206    if locked {
207        args.push("--locked");
208    } else {
209        tracing::warn!(
210            src = %src.display(),
211            "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
212             tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
213        );
214    }
215    args.push("vendor");
216    let output = Command::new("cargo")
217        .current_dir(src)
218        .args(&args)
219        .stderr(Stdio::inherit())
220        .output()
221        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
222    if !output.status.success() {
223        return Err(hammer_core::Error::Other(anyhow::anyhow!(
224            "cargo vendor falló (exit {:?})",
225            output.status.code()
226        )));
227    }
228    // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
229    // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
230    // resuelve igual dentro del sandbox donde el source se monta en /src).
231    let cargo_dir = src.join(".cargo");
232    std::fs::create_dir_all(&cargo_dir)?;
233    std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
234    Ok(())
235}
236
237/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
238pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
239    for p in &recipe.source.patches {
240        let patch_path = recipe.base_dir.join(p);
241        let bytes = std::fs::read(&patch_path).map_err(|e| {
242            hammer_core::Error::Recipe(format!(
243                "no pude leer patch {}: {e}",
244                patch_path.display()
245            ))
246        })?;
247        let mut child = Command::new("patch")
248            .args(["-p1", "--batch", "--silent"])
249            .current_dir(tree)
250            .stdin(Stdio::piped())
251            .stdout(Stdio::inherit())
252            .stderr(Stdio::inherit())
253            .spawn()
254            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
255        if let Some(mut stdin) = child.stdin.take() {
256            use std::io::Write;
257            stdin
258                .write_all(&bytes)
259                .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
260        }
261        let status = child
262            .wait()
263            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
264        if !status.success() {
265            return Err(hammer_core::Error::Other(anyhow::anyhow!(
266                "patch {} falló (exit {:?})",
267                patch_path.display(),
268                status.code()
269            )));
270        }
271    }
272    Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn vendor_cargo_deps_runs_and_writes_config() {
281        // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
282        // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
283        let tmp = tempfile::tempdir().unwrap();
284        let src = tmp.path();
285        std::fs::write(
286            src.join("Cargo.toml"),
287            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
288        )
289        .unwrap();
290        std::fs::create_dir_all(src.join("src")).unwrap();
291        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
292
293        // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
294        let lock_ok = Command::new("cargo")
295            .args(["generate-lockfile", "--offline"])
296            .current_dir(src)
297            .status()
298            .map(|s| s.success())
299            .unwrap_or(false);
300        if !lock_ok {
301            eprintln!("SKIP: cargo no disponible para generate-lockfile");
302            return;
303        }
304
305        vendor_cargo_deps(src).unwrap();
306        assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
307    }
308
309    #[test]
310    fn vendor_cargo_deps_without_committed_lock() {
311        // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
312        // generando el lock al vuelo. Para un crate depless es offline.
313        let tmp = tempfile::tempdir().unwrap();
314        let src = tmp.path();
315        std::fs::write(
316            src.join("Cargo.toml"),
317            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
318        )
319        .unwrap();
320        std::fs::create_dir_all(src.join("src")).unwrap();
321        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
322        assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
323
324        if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
325            eprintln!("SKIP: cargo no disponible");
326            return;
327        }
328        // No debe fallar por falta de --locked; genera el lock y escribe el config.
329        vendor_cargo_deps(src).unwrap();
330        assert!(src.join(".cargo/config.toml").is_file());
331        assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
332    }
333}
334
335fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
336    let mut cmd = Command::new("git");
337    if let Some(c) = cwd {
338        cmd.arg("-C").arg(c);
339    }
340    cmd.args(args);
341    let status = cmd
342        .stdin(Stdio::null())
343        .stdout(Stdio::inherit())
344        .stderr(Stdio::inherit())
345        .status()
346        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
347    if !status.success() {
348        return Err(hammer_core::Error::Other(anyhow::anyhow!(
349            "git {args:?} falló (exit {:?})",
350            status.code()
351        )));
352    }
353    Ok(())
354}