Etapa F paquetería #5: DB de instalados + hammer uninstall/installed
Funcionalidad de gestor de paquetes: rastrear qué hay instalado y poder quitarlo.
- hammer-core/installed.rs: `InstalledDb` (name→{version,hash,files}) load/save JSON; record
(upsert), remove, `owned_by_others` (refcount por ruta). Rutas absolutas ⇒ uninstall no
necesita el root. 4 tests.
- hammer-cli: run_apply ahora DEVUELVE los ficheros que CREA (hidratados + file_drop + init_rule;
config_edit modifica, no crea ⇒ no se registra ni se deshace). install los registra en la DB
(--db, default /var/lib/hammer/installed.json). `uninstall <nombre>` borra esos ficheros salvo
los que otro paquete instalado aporta (refcount) y quita la entrada. `installed` lista.
- Validado E2E REAL: install bwrap (con dep libcap) → registra 2 ficheros → `installed` los
lista → `uninstall bwrap` los borra (prefix vacío, DB vacía). 31 suites verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -227,6 +227,23 @@ enum Cmd {
|
||||
/// Raíz de estado para el overlay (modo sin `--prefix`).
|
||||
#[arg(long)]
|
||||
state_root: Option<PathBuf>,
|
||||
/// Base de datos de instalados donde registrar el paquete (para `uninstall`).
|
||||
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
|
||||
db: PathBuf,
|
||||
},
|
||||
/// [Etapa F] Desinstala un paquete: borra los ficheros que registró (refcount: respeta los
|
||||
/// que otro paquete instalado también aporta) y lo quita de la DB de instalados.
|
||||
Uninstall {
|
||||
/// Nombre del paquete a desinstalar.
|
||||
name: String,
|
||||
/// Base de datos de instalados.
|
||||
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
|
||||
db: PathBuf,
|
||||
},
|
||||
/// [Etapa F] Lista los paquetes instalados (de la DB).
|
||||
Installed {
|
||||
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
|
||||
db: PathBuf,
|
||||
},
|
||||
/// [Etapa F] Inspecciona un repositorio de paquetes.
|
||||
Repo {
|
||||
@@ -773,6 +790,7 @@ fn main() -> anyhow::Result<()> {
|
||||
base_ref,
|
||||
trust,
|
||||
state_root,
|
||||
db,
|
||||
} => {
|
||||
run_install(
|
||||
&cli.store,
|
||||
@@ -783,8 +801,11 @@ fn main() -> anyhow::Result<()> {
|
||||
base_ref.as_deref(),
|
||||
trust.as_deref(),
|
||||
state_root.as_deref(),
|
||||
&db,
|
||||
)?;
|
||||
}
|
||||
Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
|
||||
Cmd::Installed { db } => run_installed_list(&db)?,
|
||||
Cmd::Repo { sub } => match sub {
|
||||
RepoCmd::List { repo } => run_repo_list(&repo)?,
|
||||
RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?,
|
||||
@@ -1339,6 +1360,7 @@ fn run_install(
|
||||
base_ref: Option<&std::path::Path>,
|
||||
trust: Option<&std::path::Path>,
|
||||
state_root: Option<&std::path::Path>,
|
||||
db_path: &std::path::Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let index = hammer_core::RepoIndex::load(repo_dir)?;
|
||||
|
||||
@@ -1432,16 +1454,88 @@ fn run_install(
|
||||
}
|
||||
}
|
||||
|
||||
// Identidad del paquete para la DB (antes de mover nada; `entry` vive mientras `index`).
|
||||
let pkg_name = entry.name.clone();
|
||||
let pkg_version = entry.version.clone();
|
||||
let pkg_hash = entry.expected_hash.clone();
|
||||
|
||||
// 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(
|
||||
let created = run_apply(
|
||||
store_path,
|
||||
swm_path_str,
|
||||
prefix,
|
||||
skip_source_patch,
|
||||
base_ref,
|
||||
state_root,
|
||||
)
|
||||
)?;
|
||||
|
||||
// Registrar en la DB de instalados (salvo dry-run de schema). Permite `hammer uninstall`.
|
||||
if !skip_source_patch {
|
||||
let mut idb = hammer_core::InstalledDb::load(db_path)?;
|
||||
let files: Vec<String> = created.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
let nfiles = files.len();
|
||||
idb.record(hammer_core::InstalledPackage {
|
||||
name: pkg_name.clone(),
|
||||
version: pkg_version,
|
||||
hash: pkg_hash,
|
||||
files,
|
||||
});
|
||||
idb.save(db_path)?;
|
||||
eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lista los paquetes instalados de la DB.
|
||||
fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
|
||||
let idb = hammer_core::InstalledDb::load(db_path)?;
|
||||
if idb.packages.is_empty() {
|
||||
println!("nada instalado (DB {})", db_path.display());
|
||||
return Ok(());
|
||||
}
|
||||
println!("{} paquete(s) instalado(s) (DB {}):", idb.packages.len(), db_path.display());
|
||||
for p in &idb.packages {
|
||||
println!(" {:<16} {:<10} {} fichero(s)", p.name, p.version, p.files.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado
|
||||
/// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican
|
||||
/// ficheros ajenos, no creados por el paquete).
|
||||
fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
|
||||
let mut idb = hammer_core::InstalledDb::load(db_path)?;
|
||||
let Some(pkg) = idb.find(name).cloned() else {
|
||||
let avail: Vec<&str> = idb.packages.iter().map(|p| p.name.as_str()).collect();
|
||||
anyhow::bail!(
|
||||
"'{name}' no está instalado (DB {}). Instalados: {}",
|
||||
db_path.display(),
|
||||
if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
|
||||
);
|
||||
};
|
||||
|
||||
let (mut removed, mut kept) = (0usize, 0usize);
|
||||
for f in &pkg.files {
|
||||
// Refcount: no borramos un fichero que otro paquete instalado también aporta.
|
||||
if idb.owned_by_others(name, f) {
|
||||
kept += 1;
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_file(f) {
|
||||
Ok(()) => removed += 1,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba
|
||||
Err(e) => eprintln!(" aviso: no pude borrar {f}: {e}"),
|
||||
}
|
||||
}
|
||||
idb.remove(name);
|
||||
idb.save(db_path)?;
|
||||
eprintln!(
|
||||
"desinstalado {name} {} — {removed} fichero(s) borrado(s){}",
|
||||
pkg.version,
|
||||
if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
|
||||
@@ -1576,9 +1670,12 @@ fn run_apply(
|
||||
skip_source_patch: bool,
|
||||
base_ref: Option<&std::path::Path>,
|
||||
state_root: Option<&std::path::Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
) -> anyhow::Result<Vec<PathBuf>> {
|
||||
let swm = load_swm(file)?;
|
||||
swm.verify_schema()?;
|
||||
// Ficheros que este apply CREA (hidratados + file_drop + init_rule). Sirven para registrar el
|
||||
// paquete en la DB de instalados (config_edit MODIFICA, no crea ⇒ no se registra).
|
||||
let mut created: Vec<PathBuf> = Vec::new();
|
||||
|
||||
if let Some(local) = load_local_base(base_ref)? {
|
||||
match swm.verify_base(&local) {
|
||||
@@ -1636,6 +1733,7 @@ fn run_apply(
|
||||
abs.display()
|
||||
);
|
||||
}
|
||||
created.extend(report.files.iter().map(|f| f.dst.clone()));
|
||||
}
|
||||
hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
|
||||
counts.1 += 1;
|
||||
@@ -1649,6 +1747,7 @@ fn run_apply(
|
||||
hammer_core::apply::rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix);
|
||||
let p = hammer_core::apply::apply_init_rule(&rules_dir, action, service, command)?;
|
||||
eprintln!(" init_rule #{}: {action} {service} → {}", i + 1, p.display());
|
||||
created.push(p);
|
||||
}
|
||||
hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
|
||||
counts.3 += 1;
|
||||
@@ -1657,6 +1756,7 @@ fn run_apply(
|
||||
(Some(b64), None) => {
|
||||
hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
|
||||
eprintln!(" file_drop #{} → {}", i + 1, target.display());
|
||||
created.push(target.clone());
|
||||
}
|
||||
(None, Some(url)) => {
|
||||
// Descarga remota (fuera de cualquier sandbox) + verificación BLAKE3
|
||||
@@ -1669,6 +1769,7 @@ fn run_apply(
|
||||
bytes.len(),
|
||||
target.display()
|
||||
);
|
||||
created.push(target.clone());
|
||||
}
|
||||
_ => unreachable!("verify_schema lo descarta"),
|
||||
}
|
||||
@@ -1683,7 +1784,7 @@ fn run_apply(
|
||||
println!("{id}");
|
||||
eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
|
||||
}
|
||||
Ok(())
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// Exporta el diario actual como `.swm`. Provenance-aware:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y
|
||||
//! qué ficheros aportó cada uno. Es lo que hace posible `hammer uninstall`: sin un registro de
|
||||
//! "este paquete creó estos ficheros", quitar un paquete sería adivinar.
|
||||
//!
|
||||
//! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del
|
||||
//! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo
|
||||
//! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo
|
||||
//! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero.
|
||||
//!
|
||||
//! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall`
|
||||
//! no necesita saber el root: borra exactamente lo que registró el `install`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Nombre por defecto de la base de datos de instalados.
|
||||
pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InstalledDb {
|
||||
#[serde(default)]
|
||||
pub packages: Vec<InstalledPackage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstalledPackage {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
/// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hash: Option<String>,
|
||||
/// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra.
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
impl InstalledDb {
|
||||
/// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía).
|
||||
pub fn load(path: impl AsRef<Path>) -> crate::Result<InstalledDb> {
|
||||
match std::fs::read_to_string(path.as_ref()) {
|
||||
Ok(text) => serde_json::from_str(&text)
|
||||
.map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()),
|
||||
Err(e) => Err(crate::Error::Io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable.
|
||||
pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| crate::Error::Serde(e.to_string()))?;
|
||||
std::fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
|
||||
self.packages.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de
|
||||
/// ficheros (una versión nueva puede aportar otros).
|
||||
pub fn record(&mut self, pkg: InstalledPackage) {
|
||||
if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
|
||||
*slot = pkg;
|
||||
} else {
|
||||
self.packages.push(pkg);
|
||||
self.packages.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros).
|
||||
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
|
||||
let i = self.packages.iter().position(|p| p.name == name)?;
|
||||
Some(self.packages.remove(i))
|
||||
}
|
||||
|
||||
/// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
|
||||
/// borrarlo (lo seguiría necesitando ese tercero).
|
||||
pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
|
||||
self.packages
|
||||
.iter()
|
||||
.any(|p| p.name != name && p.files.iter().any(|f| f == file))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pkg(name: &str, files: &[&str]) -> InstalledPackage {
|
||||
InstalledPackage {
|
||||
name: name.into(),
|
||||
version: "1".into(),
|
||||
hash: None,
|
||||
files: files.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_is_empty() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let db = InstalledDb::load(d.path().join("installed.json")).unwrap();
|
||||
assert!(db.packages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_save_load_find() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("installed.json");
|
||||
let mut db = InstalledDb::default();
|
||||
db.record(pkg("ripgrep", &["/usr/bin/rg"]));
|
||||
db.save(&p).unwrap();
|
||||
let back = InstalledDb::load(&p).unwrap();
|
||||
assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]);
|
||||
assert!(back.find("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_returns_entry() {
|
||||
let mut db = InstalledDb::default();
|
||||
db.record(pkg("rg", &["/usr/bin/rg"]));
|
||||
let removed = db.remove("rg").unwrap();
|
||||
assert_eq!(removed.files, vec!["/usr/bin/rg"]);
|
||||
assert!(db.find("rg").is_none());
|
||||
assert!(db.remove("rg").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_by_others_refcount() {
|
||||
let mut db = InstalledDb::default();
|
||||
db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"]));
|
||||
db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"]));
|
||||
// El .so lo aportan ambos: al desinstalar 'a' no debe borrarse.
|
||||
assert!(db.owned_by_others("a", "/usr/lib/libshared.so"));
|
||||
// El binario propio de 'a' no lo aporta nadie más.
|
||||
assert!(!db.owned_by_others("a", "/usr/bin/a"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod apply;
|
||||
pub mod caps;
|
||||
pub mod hash;
|
||||
pub mod installed;
|
||||
pub mod proto;
|
||||
pub mod query;
|
||||
pub mod recipe;
|
||||
@@ -17,6 +18,7 @@ pub mod swm;
|
||||
|
||||
pub use caps::{AgentCapsConfig, CapRule};
|
||||
pub use hash::ArtifactHash;
|
||||
pub use installed::{InstalledDb, InstalledPackage};
|
||||
pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind};
|
||||
pub use repo::{PackageEntry, RepoIndex};
|
||||
pub use sign::{KeyPair, SigStatus, TrustStore};
|
||||
|
||||
@@ -147,6 +147,10 @@ CLI:
|
||||
- `hammer repo sign --repo DIR --key KEY` — firma el ÍNDICE entero (release). Re-firmá tras
|
||||
publicar (cada `pack --repo` invalida la firma del release).
|
||||
- `hammer repo verify --repo DIR [--trust DIR]` — verifica la firma del release.
|
||||
- `hammer uninstall <nombre> [--db FILE]` — borra los ficheros que el paquete registró (refcount:
|
||||
respeta los que otro paquete instalado también aporta) y lo quita de la DB de instalados.
|
||||
- `hammer installed [--db FILE]` — lista los paquetes instalados. `install` registra cada paquete
|
||||
(nombre, versión, hash, ficheros creados) en la DB (`/var/lib/hammer/installed.json` por defecto).
|
||||
|
||||
## 7. Repositorio de paquetes (Etapa F)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user