Etapa F paquetería #3: deps entre paquetes — install resuelve el cierre y reconstruye el catálogo
Cierra el hueco que pack/install advertían: un paquete con build-deps (bwrap→libcap,
openssh→zlib,openssl) ahora se instala por nombre reproduciéndose desde fuente CON sus deps.
Modelo: las build-deps viajan por NOMBRE en el source_patch y en la PackageEntry; install
resuelve el cierre transitivo desde el índice y reconstruye un catálogo de recetas que el lab
consulta al materializar deps en el sandbox.
- hammer-core: `Mutation::SourcePatch.deps` (Deps, serde-skip si vacío) + `from_recipe` lo
carga. `Deps::is_empty`. `RepoIndex`/`PackageEntry.deps` + `resolve_closure(name)` (DFS
topológico, deps antes que dependientes, detecta dep faltante y ciclo). 8 tests nuevos.
- hammer-build/swm_bridge: refactor — `recipe_from_source_patch` (síntesis pública, setea deps
+ base_dir=catálogo) + `catalog_dir_for` (dir determinista compartido). build_source_patch
lo reusa. synthesize_recipe ahora setea recipe.deps + base_dir al catálogo (no "/").
- hammer-cli: pack puebla PackageEntry.deps; install resuelve el cierre y escribe un {dep}.toml
por dep en el catalog_dir ANTES de aplicar el target (mismo dir determinista que usa
build_source_patch ⇒ el lab resuelve {dep}.toml por nombre). Warning de pack actualizado.
- VALIDADO E2E REAL contra ./store: `install bwrap` resuelve libcap del catálogo y reproduce
el artefacto CACHEADO EXACTO (b3:f89e716…) → hidrata bwrap (1.8MB ELF). El paquete con dep
hashea bit-idéntico al original. Resolución/diamante/faltante/ciclo unit-tested. 31 suites verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
|
||||
build,
|
||||
target_bin,
|
||||
expected_hash,
|
||||
deps: _,
|
||||
} => {
|
||||
let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
|
||||
skipped_source_patches += 1;
|
||||
@@ -596,6 +597,7 @@ mod tests {
|
||||
},
|
||||
target_bin: "/bin/x".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
}],
|
||||
signature: None,
|
||||
};
|
||||
|
||||
@@ -27,77 +27,15 @@ pub fn build_source_patch(
|
||||
store: &Store,
|
||||
scratch_root: Option<&Path>,
|
||||
) -> hammer_core::Result<ArtifactHash> {
|
||||
let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, expected) =
|
||||
match mutation {
|
||||
Mutation::SourcePatch {
|
||||
repo,
|
||||
commit,
|
||||
tarball,
|
||||
sha256,
|
||||
strip_components,
|
||||
patch,
|
||||
patch_url,
|
||||
build,
|
||||
target_bin,
|
||||
expected_hash,
|
||||
} => (
|
||||
repo, commit, tarball, sha256, strip_components, patch, patch_url, build, target_bin,
|
||||
expected_hash,
|
||||
),
|
||||
_ => {
|
||||
return Err(hammer_core::Error::Recipe(
|
||||
"build_source_patch: la mutación no es 'source_patch'".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let catalog_dir = catalog_dir_for(cfg, scratch_root);
|
||||
let recipe = recipe_from_source_patch(mutation, None, &catalog_dir)?;
|
||||
|
||||
// git xor tarball — misma regla que el schema (`swm_source_kind`).
|
||||
let source_kind = hammer_core::swm::swm_source_kind(
|
||||
repo.as_deref(),
|
||||
commit.as_deref(),
|
||||
tarball.as_deref(),
|
||||
sha256.as_deref(),
|
||||
)
|
||||
.map_err(hammer_core::Error::Recipe)?;
|
||||
|
||||
let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
|
||||
let recipe_dir = scratch.join("swm-recipes");
|
||||
std::fs::create_dir_all(&recipe_dir)?;
|
||||
|
||||
// Clave estable del origen para nombrar artefactos a disco: el commit (git) o el
|
||||
// sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
|
||||
let source_key: &str = match &source_kind {
|
||||
hammer_core::SourceKind::Git { commit, .. } => commit,
|
||||
hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
|
||||
let expected = match mutation {
|
||||
Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable:
|
||||
// si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
|
||||
// aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que
|
||||
// no vengan ambos.
|
||||
let mut patches: Vec<String> = Vec::new();
|
||||
let patch_path = recipe_dir.join(format!("{source_key}.patch"));
|
||||
if let Some(text) = patch {
|
||||
std::fs::write(&patch_path, text.as_bytes())?;
|
||||
patches.push(patch_path.to_string_lossy().into_owned());
|
||||
} else if let Some(url) = patch_url {
|
||||
tracing::info!(%url, "fetch: descargando patch remoto");
|
||||
crate::download::fetch_url_to_file(url, &patch_path)?;
|
||||
patches.push(patch_path.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
let name = derive_name(target_bin);
|
||||
let recipe = synthesize_recipe(
|
||||
&name,
|
||||
&source_kind,
|
||||
build_cfg,
|
||||
*strip_components,
|
||||
patches,
|
||||
&recipe_dir,
|
||||
)?;
|
||||
|
||||
let hash = build(&recipe, cfg, store)?;
|
||||
if let Some(want) = expected {
|
||||
if let Some(want) = &expected {
|
||||
let want_hex = want.strip_prefix("b3:").unwrap_or(want);
|
||||
let got_hex = hash
|
||||
.as_str()
|
||||
@@ -112,6 +50,85 @@ pub fn build_source_patch(
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a
|
||||
/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los
|
||||
/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`.
|
||||
pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf {
|
||||
scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes")
|
||||
}
|
||||
|
||||
/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen,
|
||||
/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus
|
||||
/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí).
|
||||
///
|
||||
/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un
|
||||
/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva.
|
||||
pub fn recipe_from_source_patch(
|
||||
mutation: &Mutation,
|
||||
name: Option<&str>,
|
||||
catalog_dir: &Path,
|
||||
) -> hammer_core::Result<Recipe> {
|
||||
let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps) =
|
||||
match mutation {
|
||||
Mutation::SourcePatch {
|
||||
repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
|
||||
target_bin, deps, ..
|
||||
} => (
|
||||
repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
|
||||
target_bin, deps,
|
||||
),
|
||||
_ => {
|
||||
return Err(hammer_core::Error::Recipe(
|
||||
"recipe_from_source_patch: la mutación no es 'source_patch'".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// git xor tarball — misma regla que el schema (`swm_source_kind`).
|
||||
let source_kind = hammer_core::swm::swm_source_kind(
|
||||
repo.as_deref(),
|
||||
commit.as_deref(),
|
||||
tarball.as_deref(),
|
||||
sha256.as_deref(),
|
||||
)
|
||||
.map_err(hammer_core::Error::Recipe)?;
|
||||
|
||||
std::fs::create_dir_all(catalog_dir)?;
|
||||
|
||||
// Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256
|
||||
// (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
|
||||
let source_key: &str = match &source_kind {
|
||||
hammer_core::SourceKind::Git { commit, .. } => commit,
|
||||
hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
|
||||
};
|
||||
|
||||
let mut patches: Vec<String> = Vec::new();
|
||||
if patch.is_some() || patch_url.is_some() {
|
||||
// Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
|
||||
// entre `{dep}.patch` distintos en el mismo catálogo.
|
||||
let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
|
||||
let patch_path = catalog_dir.join(format!("{label}-{source_key}.patch"));
|
||||
if let Some(text) = patch {
|
||||
std::fs::write(&patch_path, text.as_bytes())?;
|
||||
} else if let Some(url) = patch_url {
|
||||
tracing::info!(%url, "fetch: descargando patch remoto");
|
||||
crate::download::fetch_url_to_file(url, &patch_path)?;
|
||||
}
|
||||
patches.push(patch_path.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
|
||||
synthesize_recipe(
|
||||
&name,
|
||||
&source_kind,
|
||||
build_cfg,
|
||||
*strip_components,
|
||||
deps,
|
||||
patches,
|
||||
catalog_dir,
|
||||
)
|
||||
}
|
||||
|
||||
/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
|
||||
/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
|
||||
/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
|
||||
@@ -123,11 +140,13 @@ fn derive_name(target_bin: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn synthesize_recipe(
|
||||
name: &str,
|
||||
source: &hammer_core::SourceKind<'_>,
|
||||
build_cfg: &SwmBuild,
|
||||
strip_components: Option<usize>,
|
||||
deps: &hammer_core::Deps,
|
||||
patches: Vec<String>,
|
||||
base_dir: &Path,
|
||||
) -> hammer_core::Result<Recipe> {
|
||||
@@ -188,7 +207,12 @@ flags = []
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
|
||||
// Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el
|
||||
// `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo
|
||||
// necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
|
||||
// `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
|
||||
recipe.deps = deps.clone();
|
||||
recipe.base_dir = base_dir.to_path_buf();
|
||||
Ok(recipe)
|
||||
}
|
||||
|
||||
@@ -246,6 +270,7 @@ mod tests {
|
||||
},
|
||||
&fake_swm_build(),
|
||||
None,
|
||||
&hammer_core::Deps::default(),
|
||||
vec![],
|
||||
d.path(),
|
||||
)
|
||||
@@ -273,6 +298,7 @@ mod tests {
|
||||
},
|
||||
&fake_swm_build(),
|
||||
None,
|
||||
&hammer_core::Deps::default(),
|
||||
vec![],
|
||||
d.path(),
|
||||
)
|
||||
@@ -329,13 +355,16 @@ mod tests {
|
||||
build: fake_swm_build(),
|
||||
target_bin: "/bin/x".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
};
|
||||
let store = Store::open(d.path().join("store")).unwrap();
|
||||
let cfg = BuildConfig::defaults_for_store(store.root());
|
||||
let scratch = d.path().join("scratch");
|
||||
// Falla (no hay rootfs), pero el patch ya debe estar descargado.
|
||||
let _ = build_source_patch(&m, &cfg, &store, Some(&scratch));
|
||||
let landed = scratch.join("swm-recipes").join(format!("{commit}.patch"));
|
||||
// El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del
|
||||
// target_bin) para no colisionar entre deps en el catálogo.
|
||||
let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch"));
|
||||
assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
|
||||
assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
|
||||
}
|
||||
|
||||
@@ -1177,14 +1177,14 @@ fn run_pack(
|
||||
let recipe = hammer_core::Recipe::load_from_path(recipe_path)
|
||||
.map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?;
|
||||
|
||||
// El `source_patch` aún NO modela `deps` (resolución de dependencias entre paquetes = pieza
|
||||
// posterior de la paquetería). Una receta con build-deps se empaqueta igual, pero el receptor
|
||||
// necesitará esas deps ya presentes en su store para reproducir. Avisamos, no perdemos en silencio.
|
||||
if !recipe.deps.build.is_empty() || !recipe.deps.runtime.is_empty() {
|
||||
// Las deps por NOMBRE viajan en el `source_patch` y en el índice; `install` resuelve el cierre
|
||||
// desde el repo. Avisamos sólo para recordar que esas deps deben ESTAR publicadas en el mismo
|
||||
// repo (si no, `install` fallará al resolver el catálogo).
|
||||
if !recipe.deps.build.is_empty() {
|
||||
eprintln!(
|
||||
"⚠ la receta declara deps (build={:?} runtime={:?}) que el source_patch no transporta; \
|
||||
el receptor debe tenerlas en su store para reproducir (deps entre paquetes: pieza futura)",
|
||||
recipe.deps.build, recipe.deps.runtime
|
||||
"nota: este paquete depende de {:?} — publicá esas deps en el mismo repo para que \
|
||||
`install` pueda reproducirlo",
|
||||
recipe.deps.build
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1281,6 +1281,7 @@ fn run_pack(
|
||||
distro_version: distro_version.to_string(),
|
||||
expected_hash: expected_hash.clone(),
|
||||
signed_by: swm.signature.as_ref().map(|s| s.by.clone()),
|
||||
deps: recipe.deps.build.clone(),
|
||||
});
|
||||
if let Some(old) = orphan {
|
||||
// Una versión nueva deja huérfano el `.swm` viejo: lo retiramos (best-effort).
|
||||
@@ -1346,7 +1347,52 @@ fn run_install(
|
||||
eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
|
||||
}
|
||||
|
||||
// El resto (schema, base, reproduce source_patch, hidrata) es exactamente `apply`.
|
||||
// Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes
|
||||
// que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir
|
||||
// el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría
|
||||
// reconstruirse: el sandbox no tendría headers/libs de las deps.
|
||||
if !entry.deps.is_empty() && !skip_source_patch {
|
||||
let store = hammer_core::Store::open(store_path)?;
|
||||
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
||||
let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None);
|
||||
std::fs::create_dir_all(&catalog_dir)?;
|
||||
|
||||
// El cierre incluye al propio target al final; el catálogo sólo necesita las DEPS (las
|
||||
// que el target y sus deps resolverán por nombre). Escribimos un `{dep}.toml` por cada una.
|
||||
let closure = index.resolve_closure(name)?;
|
||||
let dep_pkgs: Vec<&hammer_core::PackageEntry> =
|
||||
closure.into_iter().filter(|p| p.name != name).collect();
|
||||
if !dep_pkgs.is_empty() {
|
||||
eprintln!(
|
||||
"deps: resolviendo catálogo para {} dep(s): {}",
|
||||
dep_pkgs.len(),
|
||||
dep_pkgs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
}
|
||||
for dep in dep_pkgs {
|
||||
let dep_swm_path = hammer_core::RepoIndex::swm_path(repo_dir, dep);
|
||||
let dep_swm = load_swm(
|
||||
dep_swm_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("ruta no UTF-8: {}", dep_swm_path.display()))?,
|
||||
)?;
|
||||
let mutation = source_patch_of(&dep_swm).ok_or_else(|| {
|
||||
anyhow::anyhow!("la dep '{}' no es un paquete source_patch", dep.name)
|
||||
})?;
|
||||
// El nombre de la receta DEBE ser el nombre del paquete: así el dependiente la
|
||||
// resuelve como `{dep}.toml`.
|
||||
let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch(
|
||||
mutation,
|
||||
Some(&dep.name),
|
||||
&catalog_dir,
|
||||
)?;
|
||||
let toml = dep_recipe.to_toml()?;
|
||||
std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), toml)?;
|
||||
}
|
||||
}
|
||||
|
||||
// El resto (schema, base, reproduce source_patch CON sus deps del catálogo, hidrata) es
|
||||
// exactamente `apply`: build_source_patch usa el MISMO catalog_dir determinista.
|
||||
run_apply(
|
||||
store_path,
|
||||
swm_path_str,
|
||||
@@ -1357,6 +1403,13 @@ fn run_install(
|
||||
)
|
||||
}
|
||||
|
||||
/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
|
||||
fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
|
||||
swm.mutations
|
||||
.iter()
|
||||
.find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
|
||||
}
|
||||
|
||||
/// Lista los paquetes publicados en un repositorio.
|
||||
fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> {
|
||||
let index = hammer_core::RepoIndex::load(repo_dir)?;
|
||||
@@ -1765,6 +1818,7 @@ fn build_export_mutations(
|
||||
},
|
||||
target_bin,
|
||||
expected_hash: Some(art_hash),
|
||||
deps: recipe.deps.clone(),
|
||||
});
|
||||
stats.source_patches += 1;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod swm;
|
||||
|
||||
pub use caps::{AgentCapsConfig, CapRule};
|
||||
pub use hash::ArtifactHash;
|
||||
pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind};
|
||||
pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind};
|
||||
pub use repo::{PackageEntry, RepoIndex};
|
||||
pub use sign::{KeyPair, SigStatus, TrustStore};
|
||||
pub use store::Store;
|
||||
|
||||
@@ -129,7 +129,7 @@ impl Phases {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Deps {
|
||||
#[serde(default)]
|
||||
pub build: Vec<String>,
|
||||
@@ -137,6 +137,14 @@ pub struct Deps {
|
||||
pub runtime: Vec<String>,
|
||||
}
|
||||
|
||||
impl Deps {
|
||||
/// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el
|
||||
/// bloque `deps` vacío en un `.swm`/receta.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.build.is_empty() && self.runtime.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
|
||||
/// para paquetes con gcc-ismos. Ver ADR 0003.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
|
||||
@@ -36,6 +36,11 @@ pub struct PackageEntry {
|
||||
pub file: String,
|
||||
/// `base.distro_version` del `.swm` — permite filtrar incompatibles sin abrir el manifiesto.
|
||||
pub distro_version: String,
|
||||
/// Build-deps del paquete por NOMBRE (de la receta original) — las que el receptor debe tener
|
||||
/// como paquetes en el repo para reproducir. `install` resuelve este grafo desde el índice sin
|
||||
/// abrir cada `.swm`. Vacío ⇒ paquete autocontenido.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deps: Vec<String>,
|
||||
/// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_hash: Option<String>,
|
||||
@@ -94,6 +99,52 @@ impl RepoIndex {
|
||||
pub fn swm_path(repo_dir: impl AsRef<Path>, entry: &PackageEntry) -> PathBuf {
|
||||
repo_dir.as_ref().join(&entry.file)
|
||||
}
|
||||
|
||||
/// Resuelve el cierre transitivo de build-deps de `name` en orden TOPOLÓGICO (cada dep antes
|
||||
/// que quien la necesita; `name` queda al final). Es el orden en que `install` debe procesar:
|
||||
/// poblar el catálogo con las deps antes de construir el dependiente. Error si una dep falta
|
||||
/// del índice (no se puede reproducir sin ella) o si hay un ciclo.
|
||||
pub fn resolve_closure<'a>(&'a self, name: &str) -> crate::Result<Vec<&'a PackageEntry>> {
|
||||
let mut order: Vec<&'a PackageEntry> = Vec::new();
|
||||
let mut done: Vec<&'a str> = Vec::new(); // ya emitidos
|
||||
let mut stack: Vec<&'a str> = Vec::new(); // en la rama DFS actual (detección de ciclo)
|
||||
self.visit(name, &mut order, &mut done, &mut stack)?;
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
fn visit<'a>(
|
||||
&'a self,
|
||||
name: &str,
|
||||
order: &mut Vec<&'a PackageEntry>,
|
||||
done: &mut Vec<&'a str>,
|
||||
stack: &mut Vec<&'a str>,
|
||||
) -> crate::Result<()> {
|
||||
if done.iter().any(|d| *d == name) {
|
||||
return Ok(());
|
||||
}
|
||||
if stack.iter().any(|s| *s == name) {
|
||||
return Err(crate::Error::Recipe(format!(
|
||||
"ciclo de dependencias en el repo: {} → {name}",
|
||||
stack.join(" → ")
|
||||
)));
|
||||
}
|
||||
let entry = self.find(name).ok_or_else(|| {
|
||||
crate::Error::Recipe(format!(
|
||||
"dependencia '{name}' no está en el repo (necesaria por {})",
|
||||
stack.last().copied().unwrap_or("(raíz)")
|
||||
))
|
||||
})?;
|
||||
// Guardamos refs con la vida de `self` (no del parámetro `name`): el `.swm` y su nombre
|
||||
// viven en el índice. `name` sólo sirve para buscar y comparar.
|
||||
stack.push(entry.name.as_str());
|
||||
for dep in &entry.deps {
|
||||
self.visit(dep, order, done, stack)?;
|
||||
}
|
||||
stack.pop();
|
||||
done.push(entry.name.as_str());
|
||||
order.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -101,6 +152,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(name: &str, ver: &str) -> PackageEntry {
|
||||
entry_deps(name, ver, &[])
|
||||
}
|
||||
|
||||
fn entry_deps(name: &str, ver: &str, deps: &[&str]) -> PackageEntry {
|
||||
PackageEntry {
|
||||
name: name.into(),
|
||||
version: ver.into(),
|
||||
@@ -108,6 +163,7 @@ mod tests {
|
||||
distro_version: "dev".into(),
|
||||
expected_hash: None,
|
||||
signed_by: None,
|
||||
deps: deps.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,4 +208,53 @@ mod tests {
|
||||
let orphan = idx.upsert(entry("rg", "1.0.0"));
|
||||
assert!(orphan.is_none());
|
||||
}
|
||||
|
||||
fn names<'a>(v: &[&'a PackageEntry]) -> Vec<&'a str> {
|
||||
v.iter().map(|e| e.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_topological_deps_before_dependents() {
|
||||
// openssh → zlib, openssl ; openssl no tiene deps. Orden: deps antes, target al final.
|
||||
let mut idx = RepoIndex::default();
|
||||
idx.upsert(entry("zlib", "1.3"));
|
||||
idx.upsert(entry("openssl", "3.5"));
|
||||
idx.upsert(entry_deps("openssh", "10.3", &["zlib", "openssl"]));
|
||||
let order = names(&idx.resolve_closure("openssh").unwrap());
|
||||
// zlib y openssl preceden a openssh; openssh es el último.
|
||||
assert_eq!(*order.last().unwrap(), "openssh");
|
||||
let p = |n| order.iter().position(|x| *x == n).unwrap();
|
||||
assert!(p("zlib") < p("openssh") && p("openssl") < p("openssh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_dedups_diamond() {
|
||||
// a→b,c ; b→d ; c→d. d aparece una sola vez, antes que b y c.
|
||||
let mut idx = RepoIndex::default();
|
||||
idx.upsert(entry("d", "1"));
|
||||
idx.upsert(entry_deps("b", "1", &["d"]));
|
||||
idx.upsert(entry_deps("c", "1", &["d"]));
|
||||
idx.upsert(entry_deps("a", "1", &["b", "c"]));
|
||||
let order = names(&idx.resolve_closure("a").unwrap());
|
||||
assert_eq!(order.iter().filter(|x| **x == "d").count(), 1);
|
||||
let p = |n| order.iter().position(|x| *x == n).unwrap();
|
||||
assert!(p("d") < p("b") && p("d") < p("c") && p("b") < p("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_missing_dep_errors() {
|
||||
let mut idx = RepoIndex::default();
|
||||
idx.upsert(entry_deps("openssh", "10.3", &["openssl"])); // openssl ausente
|
||||
let err = idx.resolve_closure("openssh").unwrap_err().to_string();
|
||||
assert!(err.contains("openssl") && err.contains("no está en el repo"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_cycle_errors() {
|
||||
let mut idx = RepoIndex::default();
|
||||
idx.upsert(entry_deps("a", "1", &["b"]));
|
||||
idx.upsert(entry_deps("b", "1", &["a"]));
|
||||
let err = idx.resolve_closure("a").unwrap_err().to_string();
|
||||
assert!(err.contains("ciclo"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ pub enum Mutation {
|
||||
target_bin: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expected_hash: Option<String>,
|
||||
/// Dependencias del paquete por NOMBRE (las de la receta original). Las build-deps deben
|
||||
/// estar presentes como paquetes en el repo del receptor: `install` reconstruye con ellas
|
||||
/// un catálogo de recetas para que el lab las materialice en el sandbox al reproducir.
|
||||
/// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps).
|
||||
#[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")]
|
||||
deps: crate::recipe::Deps,
|
||||
},
|
||||
ConfigEdit {
|
||||
file: String,
|
||||
@@ -283,6 +289,7 @@ impl Swm {
|
||||
build,
|
||||
target_bin: target_bin.into(),
|
||||
expected_hash,
|
||||
deps: recipe.deps.clone(),
|
||||
}],
|
||||
signature: None,
|
||||
};
|
||||
@@ -570,6 +577,7 @@ mutations:
|
||||
},
|
||||
target_bin: "/bin/x".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
}],
|
||||
signature: None,
|
||||
};
|
||||
@@ -599,6 +607,7 @@ mutations:
|
||||
},
|
||||
target_bin: "/bin/grep".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
}],
|
||||
signature: None,
|
||||
};
|
||||
@@ -625,6 +634,7 @@ mutations:
|
||||
},
|
||||
target_bin: "/bin/x".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
};
|
||||
let err = m.verify_schema().unwrap_err();
|
||||
assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
|
||||
@@ -724,6 +734,7 @@ zig_version = "0.13.0"
|
||||
},
|
||||
target_bin: "/bin/x".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
};
|
||||
let err = m.verify_schema().unwrap_err();
|
||||
assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
|
||||
|
||||
@@ -378,6 +378,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
|
||||
},
|
||||
target_bin: format!("/usr/bin/{name}"),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
};
|
||||
let store = match hammer_core::Store::open(&store_root) {
|
||||
Ok(s) => s,
|
||||
|
||||
+18
-7
@@ -133,15 +133,16 @@ CLI:
|
||||
construir un paquete distribuible y reproducible-desde-fuente; es la inversa de `apply`.
|
||||
`--target-bin` fija el ancla de sanity (default `/usr/bin/<name>`); `--expected b3:…` o
|
||||
`--build` sellan el `expected_hash` (ancla "verificar, no confiar"); `--sign KEY` lo firma.
|
||||
El `source_patch` traslada con fidelidad `build.{phases,zig_version,flags}` y, en tarball,
|
||||
`strip_components`; los patches de la receta viajan inline (concatenados). **Aún no modela
|
||||
`deps`** (resolución entre paquetes = pieza posterior): si la receta declara build/runtime
|
||||
deps, `pack` lo advierte y el receptor debe tenerlas ya en su store para reproducir.
|
||||
El `source_patch` traslada con fidelidad `build.{phases,zig_version,flags}`, en tarball
|
||||
`strip_components`, y las `deps` por nombre; los patches de la receta viajan inline
|
||||
(concatenados). Si la receta declara build-deps, `pack` recuerda publicarlas en el mismo repo.
|
||||
Con `--repo DIR` **publica** en un repositorio (ver abajo) en vez de (o además de) `--out`.
|
||||
- `hammer install <nombre> [--repo DIR]` — **consume** del repositorio: resuelve `nombre` en el
|
||||
índice, verifica la firma (con `--trust DIR`) y la base (con `--base-ref`), y delega en el
|
||||
camino de `apply` (reproduce el `source_patch` desde fuente + hidrata). Nunca corre un binario
|
||||
ajeno. `--prefix`/`--skip-source-patch` para staging y dry-run de schema.
|
||||
índice, verifica la firma (con `--trust DIR`) y la base (con `--base-ref`), **resuelve el cierre
|
||||
transitivo de build-deps** desde el índice (orden topológico) poblando un catálogo de recetas, y
|
||||
delega en el camino de `apply` (reproduce el `source_patch` desde fuente, con sus deps
|
||||
materializadas en el sandbox, + hidrata). Nunca corre un binario ajeno. `--prefix`/
|
||||
`--skip-source-patch` para staging y dry-run de schema.
|
||||
- `hammer repo list [--repo DIR]` — lista el catálogo (`<repo>/index.json`).
|
||||
|
||||
## 7. Repositorio de paquetes (Etapa F)
|
||||
@@ -154,3 +155,13 @@ publica con el nombre/versión de la receta (upsert idempotente por nombre; una
|
||||
retira el `.swm` huérfano). El transporte del repo (filesystem / sshfs / mirror) es ortogonal:
|
||||
ver `hammer-mirror` para el CAS del store. Tipos en `hammer-core::repo` (`RepoIndex`,
|
||||
`PackageEntry`).
|
||||
|
||||
**Dependencias.** Un `source_patch` lleva sus build-deps por NOMBRE (las de la receta original);
|
||||
la `PackageEntry` las espeja para resolver el grafo sin abrir cada `.swm`. `install <nombre>`
|
||||
calcula el cierre transitivo (`RepoIndex::resolve_closure`, topológico, error si falta una dep o
|
||||
hay ciclo) y, para reproducir, **reconstruye un catálogo de recetas** efímero: escribe un
|
||||
`{dep}.toml` por dep (sintetizado de su `.swm` vía `swm_bridge::recipe_from_source_patch`) en el
|
||||
`catalog_dir` determinista que el lab consulta al materializar build-deps en el sandbox. Así un
|
||||
paquete con deps (p. ej. `bwrap`→`libcap`, `openssh`→`zlib,openssl`) se reproduce desde fuente
|
||||
bit-idéntico — verificado: `install bwrap` reproduce el artefacto cacheado exacto resolviendo
|
||||
`libcap` del catálogo.
|
||||
|
||||
Reference in New Issue
Block a user