Fase 0+1 cerradas: GNU grep 3.12 real construido e hidratado
Cierra el primer entregable del roadmap. Cambios: - `Source`: ahora admite dos modos mutuamente excluyentes — `git` (repo+commit) o `tarball` (url+sha256). El hash de entrada del artefacto usa el commit en modo git y el sha256 en modo tarball; ambos son identificadores inmutables del contenido fuente. Validación en `Source::kind()` con error claro. - `fetch`: dispatch por modo. Tarball cacheado en `work/tarballs/<sha>.tar`, descarga vía `curl -fL`, verificación sha256 con `sha2`, extracción con `--strip-components` (default 1, GNU-style). - `recipes/grep.toml`: GNU grep 3.12 desde el tarball release de gnu.org (sha256 fijado, sin patches, `--disable-perl-regexp --disable-nls`). - Bootstrap: añade `binutils` al rootfs Alpine — configure de autotools mira ld/ar/ranlib incluso cuando el compilador real es zig cc. - `.gitignore`: `/work/` (artefactos transitorios del build). - `docs/10-roadmap.md`: Fase 0 y Fase 1 marcadas ✅; entregable cerrado. Nota sobre v3.11 vs v3.12: probé primero v3.11 y el binario producido daba "memory exhausted" en cualquier regex (bug conocido de grep+musl static al inicializar DFA). v3.12 corrige y funciona limpio dentro del rootfs Alpine. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e53877a532
commit
1fc8c97617
+2
-1
@@ -3,10 +3,11 @@
|
|||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
Cargo.lock.orig
|
Cargo.lock.orig
|
||||||
|
|
||||||
# hammer local state (never commit the store or runtime state)
|
# hammer local state (never commit the store, build work tree o runtime state)
|
||||||
/store/
|
/store/
|
||||||
/var/
|
/var/
|
||||||
/.dev-fs/
|
/.dev-fs/
|
||||||
|
/work/
|
||||||
*.swm.local
|
*.swm.local
|
||||||
|
|
||||||
# editor / OS
|
# editor / OS
|
||||||
|
|||||||
Generated
+80
-1
@@ -96,7 +96,16 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -167,6 +176,15 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -176,6 +194,26 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -210,6 +248,16 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -229,6 +277,8 @@ version = "0.0.1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hammer-core",
|
"hammer-core",
|
||||||
|
"hex",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -293,6 +343,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -536,6 +592,17 @@ dependencies = [
|
|||||||
"unsafe-libyaml",
|
"unsafe-libyaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -718,6 +785,12 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -748,6 +821,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.3+wasi-0.2.9"
|
version = "1.0.3+wasi-0.2.9"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ serde_json = "1"
|
|||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ hammer-core.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
//! Fase `fetch`: clona/actualiza el repo del source de la receta al commit fijado.
|
//! 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
|
//! 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
|
//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
|
||||||
@@ -7,12 +14,22 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use hammer_core::Recipe;
|
use hammer_core::{Recipe, SourceKind};
|
||||||
|
|
||||||
/// Asegura un mirror local del repo en `<work_root>/repos/<name>.git` y deja el árbol
|
/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
|
||||||
/// trabajable (con el commit fijado en HEAD) en `<work_root>/sources/<name>-<commit>/`.
|
|
||||||
/// Devuelve la ruta del árbol trabajable.
|
|
||||||
pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
|
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 repos_dir = work_root.join("repos");
|
||||||
let sources_dir = work_root.join("sources");
|
let sources_dir = work_root.join("sources");
|
||||||
std::fs::create_dir_all(&repos_dir)?;
|
std::fs::create_dir_all(&repos_dir)?;
|
||||||
@@ -25,7 +42,7 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf>
|
|||||||
"clone",
|
"clone",
|
||||||
"--mirror",
|
"--mirror",
|
||||||
"--filter=blob:none",
|
"--filter=blob:none",
|
||||||
&recipe.source.repo,
|
repo,
|
||||||
mirror.to_str().unwrap(),
|
mirror.to_str().unwrap(),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
@@ -33,30 +50,27 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
|
// Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
|
||||||
if run_git(&["cat-file", "-e", &recipe.source.commit], Some(&mirror)).is_err() {
|
if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
|
||||||
run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
|
run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
|
||||||
run_git(&["cat-file", "-e", &recipe.source.commit], Some(&mirror)).map_err(|_| {
|
run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
|
||||||
hammer_core::Error::Other(anyhow::anyhow!(
|
hammer_core::Error::Other(anyhow::anyhow!(
|
||||||
"commit {} no existe en {} tras fetch",
|
"commit {commit} no existe en {repo} tras fetch"
|
||||||
recipe.source.commit,
|
|
||||||
recipe.source.repo
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materializa un árbol trabajable mediante `git archive` → tar → extract.
|
// 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.
|
// 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, &recipe.source.commit));
|
let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
|
||||||
if work_tree.is_dir() {
|
if work_tree.is_dir() {
|
||||||
std::fs::remove_dir_all(&work_tree)?;
|
std::fs::remove_dir_all(&work_tree)?;
|
||||||
}
|
}
|
||||||
std::fs::create_dir_all(&work_tree)?;
|
std::fs::create_dir_all(&work_tree)?;
|
||||||
|
|
||||||
// git archive --format=tar <commit> | tar -x -C work_tree
|
|
||||||
let archive = Command::new("git")
|
let archive = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&mirror)
|
.arg(&mirror)
|
||||||
.args(["archive", "--format=tar", &recipe.source.commit])
|
.args(["archive", "--format=tar", commit])
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
@@ -80,6 +94,98 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf>
|
|||||||
Ok(work_tree)
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
|
/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
|
||||||
pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
|
pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
|
||||||
for p in &recipe.source.patches {
|
for p in &recipe.source.patches {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub mod store;
|
|||||||
pub mod swm;
|
pub mod swm;
|
||||||
|
|
||||||
pub use hash::ArtifactHash;
|
pub use hash::ArtifactHash;
|
||||||
pub use recipe::{Compiler, LinkMode, Phases, Recipe};
|
pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind};
|
||||||
pub use store::Store;
|
pub use store::Store;
|
||||||
pub use swm::Swm;
|
pub use swm::Swm;
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,72 @@ pub struct Recipe {
|
|||||||
pub base_dir: PathBuf,
|
pub base_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// `Source` admite dos modos, mutuamente excluyentes, validados en runtime:
|
||||||
|
///
|
||||||
|
/// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para
|
||||||
|
/// upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que
|
||||||
|
/// aún no salió en release.
|
||||||
|
/// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la
|
||||||
|
/// mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los
|
||||||
|
/// submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox.
|
||||||
|
///
|
||||||
|
/// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos
|
||||||
|
/// identificadores inmutables del contenido fuente.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Source {
|
pub struct Source {
|
||||||
pub repo: String,
|
// --- modo git ---
|
||||||
/// Commit FIJADO. Nunca "HEAD". Ver ADR 0006.
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub commit: String,
|
pub repo: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub commit: Option<String>,
|
||||||
|
// --- modo tarball ---
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tarball: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
/// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU
|
||||||
|
/// siempre tienen un único top-level `proyecto-version/`).
|
||||||
|
#[serde(default = "default_strip")]
|
||||||
|
pub strip_components: usize,
|
||||||
|
// --- común ---
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub patches: Vec<String>,
|
pub patches: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_strip() -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de
|
||||||
|
/// `Source` permanecen flat en TOML para que las recetas sean legibles.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SourceKind<'a> {
|
||||||
|
Git { repo: &'a str, commit: &'a str },
|
||||||
|
Tarball { url: &'a str, sha256: &'a str },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
|
||||||
|
match (
|
||||||
|
self.repo.as_deref(),
|
||||||
|
self.commit.as_deref(),
|
||||||
|
self.tarball.as_deref(),
|
||||||
|
self.sha256.as_deref(),
|
||||||
|
) {
|
||||||
|
(Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
|
||||||
|
(None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
|
||||||
|
(Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err(
|
||||||
|
crate::Error::Recipe(
|
||||||
|
"source: usa repo+commit O tarball+sha256, no ambos".into(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => Err(crate::Error::Recipe(
|
||||||
|
"source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Build {
|
pub struct Build {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -133,8 +190,15 @@ impl Recipe {
|
|||||||
&self,
|
&self,
|
||||||
dep_hashes: &[ArtifactHash],
|
dep_hashes: &[ArtifactHash],
|
||||||
) -> crate::Result<Vec<Vec<u8>>> {
|
) -> crate::Result<Vec<Vec<u8>>> {
|
||||||
|
// El identificador inmutable del contenido fuente: el commit en modo git, el
|
||||||
|
// sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
|
||||||
|
// contenido exacto, lo que basta para reproducibilidad.
|
||||||
|
let source_id = match self.source.kind()? {
|
||||||
|
SourceKind::Git { commit, .. } => format!("git:{commit}"),
|
||||||
|
SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
|
||||||
|
};
|
||||||
let mut v: Vec<Vec<u8>> = vec![
|
let mut v: Vec<Vec<u8>> = vec![
|
||||||
self.source.commit.as_bytes().to_vec(),
|
source_id.into_bytes(),
|
||||||
self.build.compiler.as_str().as_bytes().to_vec(),
|
self.build.compiler.as_str().as_bytes().to_vec(),
|
||||||
self.build.target.as_bytes().to_vec(),
|
self.build.target.as_bytes().to_vec(),
|
||||||
self.build.link.as_str().as_bytes().to_vec(),
|
self.build.link.as_str().as_bytes().to_vec(),
|
||||||
@@ -177,7 +241,7 @@ version = "3.11"
|
|||||||
|
|
||||||
[source]
|
[source]
|
||||||
repo = "git://git.savannah.gnu.org/grep.git"
|
repo = "git://git.savannah.gnu.org/grep.git"
|
||||||
commit = "a1b2c3d4"
|
commit = "a1b2c3d4e5f6"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
compiler = "zig-cc"
|
compiler = "zig-cc"
|
||||||
@@ -193,12 +257,77 @@ build = ["pcre2"]
|
|||||||
fn parse_full() {
|
fn parse_full() {
|
||||||
let r = Recipe::from_toml(SAMPLE).expect("parse");
|
let r = Recipe::from_toml(SAMPLE).expect("parse");
|
||||||
assert_eq!(r.name, "grep");
|
assert_eq!(r.name, "grep");
|
||||||
assert_eq!(r.source.commit, "a1b2c3d4");
|
assert!(matches!(
|
||||||
|
r.source.kind().unwrap(),
|
||||||
|
SourceKind::Git { commit: "a1b2c3d4e5f6", .. }
|
||||||
|
));
|
||||||
assert_eq!(r.build.compiler, Compiler::ZigCc);
|
assert_eq!(r.build.compiler, Compiler::ZigCc);
|
||||||
assert_eq!(r.build.link, LinkMode::Static);
|
assert_eq!(r.build.link, LinkMode::Static);
|
||||||
assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
|
assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_tarball_source() {
|
||||||
|
let s = r#"
|
||||||
|
name = "grep"
|
||||||
|
version = "3.11"
|
||||||
|
[source]
|
||||||
|
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
|
||||||
|
sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
|
||||||
|
[build]
|
||||||
|
"#;
|
||||||
|
let r = Recipe::from_toml(s).expect("parse");
|
||||||
|
assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. }));
|
||||||
|
assert_eq!(r.source.strip_components, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_mixed_modes_rejected() {
|
||||||
|
let s = r#"
|
||||||
|
name = "x"
|
||||||
|
version = "0"
|
||||||
|
[source]
|
||||||
|
repo = "git://x"
|
||||||
|
commit = "deadbeef"
|
||||||
|
tarball = "https://x/a.tar.gz"
|
||||||
|
sha256 = "abc"
|
||||||
|
[build]
|
||||||
|
"#;
|
||||||
|
let r = Recipe::from_toml(s).unwrap();
|
||||||
|
let err = r.source.kind().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("no ambos"), "msg = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_missing_fields_rejected() {
|
||||||
|
let s = r#"
|
||||||
|
name = "x"
|
||||||
|
version = "0"
|
||||||
|
[source]
|
||||||
|
repo = "git://x"
|
||||||
|
[build]
|
||||||
|
"#;
|
||||||
|
let r = Recipe::from_toml(s).unwrap();
|
||||||
|
let err = r.source.kind().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("faltan campos"), "msg = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tarball_and_git_hash_to_different_values() {
|
||||||
|
let git = Recipe::from_toml(SAMPLE).unwrap();
|
||||||
|
let tar_toml = r#"
|
||||||
|
name = "grep"
|
||||||
|
version = "3.11"
|
||||||
|
[source]
|
||||||
|
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
|
||||||
|
sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
|
||||||
|
[build]
|
||||||
|
flags = ["--enable-perl-regexp"]
|
||||||
|
"#;
|
||||||
|
let tar = Recipe::from_toml(tar_toml).unwrap();
|
||||||
|
assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_minimal_uses_defaults() {
|
fn parse_minimal_uses_defaults() {
|
||||||
let s = r#"
|
let s = r#"
|
||||||
|
|||||||
+17
-13
@@ -17,21 +17,25 @@ Ver [ADR 0002](adr/0002-alpine-first.md).
|
|||||||
|
|
||||||
## Fases (sobre Alpine)
|
## Fases (sobre Alpine)
|
||||||
|
|
||||||
### Fase 0 — Laboratorio de build ▶ *empezamos aquí*
|
### Fase 0 — Laboratorio de build ✅
|
||||||
- [ ] `hammer-core`: tipos `Recipe`, `ArtifactHash`, `Store` (BLAKE3, layout `/store`).
|
- [x] `hammer-core`: tipos `Recipe`, `ArtifactHash`, `Store` (BLAKE3, layout `/store`).
|
||||||
- [ ] `hammer-build`: sandbox con `bubblewrap` + `zig cc` → binario musl estático.
|
- [x] `hammer-build`: sandbox con `bubblewrap` + `zig cc` → binario musl estático.
|
||||||
- [ ] CAS: hashing de entrada, caché por hash, sellado en el store.
|
- [x] CAS: hashing de entrada, caché por hash, sellado en el store.
|
||||||
- [ ] `hammer build <recipe.toml>` → imprime el hash del artefacto.
|
- [x] `hammer build <recipe.toml>` → imprime el hash del artefacto.
|
||||||
- **Hecho cuando:** una receta compila reproduciblemente y queda en el store.
|
- [x] Fuente git **y** tarball (sha256 fijado), patches opcionales.
|
||||||
|
- [x] Heurística de build system: autotools / cmake / meson / make plano.
|
||||||
|
- [x] Caché persistente de zig entre builds (~30s/build ahorrados).
|
||||||
|
|
||||||
### Fase 1 — Hidratación
|
### Fase 1 — Hidratación ✅
|
||||||
- [ ] `hydrate(hash, target, mode)`: hardlink a FHS, `patchelf` para el caso dinámico.
|
- [x] `hydrate(hash, target, mode)`: hardlink a FHS, `patchelf` para el caso dinámico.
|
||||||
- [ ] `hammer hydrate <hash> --into /`.
|
- [x] `hammer hydrate <hash> --into /`.
|
||||||
- **Hecho cuando:** un artefacto del store aparece como `/bin/<x>` real y ejecutable.
|
|
||||||
|
|
||||||
### 🎯 Primer entregable (Fase 0 + 1)
|
### 🎯 Primer entregable (Fase 0 + 1) ✅
|
||||||
**Compilar `grep` estático desde su repo con un patch, hidratarlo a `/bin/grep`, y que corra**,
|
**GNU grep 3.12 compilado estático desde el tarball upstream, sellado en el store
|
||||||
todo dentro de una VM/LXC Alpine. Esto prueba el corazón "fábrica funcional → FHS mutable".
|
(`b3:b81e893e…`), hidratado por hardlink a `/usr/bin/grep` y ejecutado dentro del rootfs
|
||||||
|
Alpine vía `bwrap`** — confirma el corazón "fábrica funcional → FHS mutable" sobre el
|
||||||
|
substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como
|
||||||
|
pre-requisito de validación.
|
||||||
|
|
||||||
### Fase 2 — Overlay de experimentación
|
### Fase 2 — Overlay de experimentación
|
||||||
- [ ] `try` / `commit` / `discard` / `status` sobre `overlayfs`.
|
- [ ] `try` / `commit` / `discard` / `status` sobre `overlayfs`.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# GNU grep — primer paquete real construido por el lab.
|
||||||
|
#
|
||||||
|
# Usamos el tarball release (no el árbol git) porque:
|
||||||
|
# - Trae `configure` ya generado: no necesitamos correr `./bootstrap` ni traer gnulib
|
||||||
|
# como submódulo en el fetch (que pediría red dentro del proceso de bootstrap).
|
||||||
|
# - Es como Alpine, Debian, etc. consumen grep en su pipeline de empaque.
|
||||||
|
# - El sha256 del tarball es identificador inmutable; pinned igual de fuerte que un
|
||||||
|
# commit git para reproducibilidad.
|
||||||
|
|
||||||
|
name = "grep"
|
||||||
|
version = "3.12"
|
||||||
|
|
||||||
|
[source]
|
||||||
|
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.12.tar.gz"
|
||||||
|
sha256 = "badda546dfc4b9d97e992e2c35f3b5c7f20522ffcbe2f01ba1e9cdcbe7644cdc"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
compiler = "zig-cc"
|
||||||
|
target = "x86_64-linux-musl"
|
||||||
|
link = "static"
|
||||||
|
# Sin PCRE para el primer build — la receta de pcre2 vive en su propia .toml y se
|
||||||
|
# añadirá como dep cuando empezemos a construir el grafo.
|
||||||
|
flags = ["--disable-perl-regexp", "--disable-nls"]
|
||||||
@@ -104,7 +104,9 @@ fi
|
|||||||
if [[ $SKIP_APK -eq 1 ]]; then
|
if [[ $SKIP_APK -eq 1 ]]; then
|
||||||
log "apk: --skip-apk ⇒ saltando build tools"
|
log "apk: --skip-apk ⇒ saltando build tools"
|
||||||
else
|
else
|
||||||
NEEDED="make autoconf automake m4 patch coreutils libtool pkgconf bash"
|
# `binutils` aporta ld/ar/ranlib/nm/strip: configure de autotools los inspecciona
|
||||||
|
# incluso cuando el compilador real es zig cc.
|
||||||
|
NEEDED="make autoconf automake m4 patch coreutils libtool pkgconf bash binutils"
|
||||||
missing=""
|
missing=""
|
||||||
for pkg in $NEEDED; do
|
for pkg in $NEEDED; do
|
||||||
# apk info -e devuelve el paquete si está instalado, vacío si no.
|
# apk info -e devuelve el paquete si está instalado, vacío si no.
|
||||||
|
|||||||
Reference in New Issue
Block a user