From 50d1dbcc314c529ff4a6dd9b22c35b1e05476166 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 3 Sep 2026 21:00:10 +0000 Subject: [PATCH] =?UTF-8?q?qorpa=20D3:=20`packages`=20se=20instala=20solo?= =?UTF-8?q?=20=E2=80=94=20el=20manifiesto=20deja=20de=20ser=20un=20adorno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasta acá el ADR afirmaba «el manifiesto es la verdad, el upper es caché» mientras `recreate` confesaba en su propia salida que «instalarlos todavía es a mano». Con eso el `upper` SÍ era el activo: un blob irreemplazable, que es justo lo que hammer existe para no tener. `hammer qorpa provision ` instala lo declarado, y **`recreate` lo llama solo** (`--no-provision` para saltarlo). Cuatro decisiones, cada una con su porqué: - **El gestor se DETECTA** en la vista merged (apt, pacman, dnf, apk), no se configura: cada imagen trae el suyo. Y no se multiplexa detrás de un comando único —el `pmm` de Bedrock que el ADR rechaza—: se elige cuál correr. - **`provision` ensancha la política y lo dice en la cara.** Instalar pide las tres cosas que una instancia bien declarada no tiene: red, root y la imagen sin sellar. Se ensancha SÓLO durante esa operación, el manifiesto no se toca y el siguiente `run` vuelve a lo escrito. En silencio sería lo que D7 prohíbe. - **El registro vive FUERA del `upper`** (`provisioned.toml`): dentro se iría con la capa. Por eso `recreate` lo borra — un registro que afirma paquetes sobre una capa recién vaciada es la forma más pura del error de la regla 3. - **Los nombres se validan y se comillan**: salen de un fichero que escribe una persona, así que `strace; rm -rf /` no llega al guión. **Las dos manías que sólo salen provisionando de verdad:** el bootstrap de Arch trae la mirrorlist ENTERA comentada (pacman muere con «no servers configured») y el llavero sin inicializar (toda firma inválida). El guión pone el mirror geo oficial avisando cuál, y hace `pacman-key --init && --populate` sólo si falta. `apt` no necesita ni un workaround: es el dividendo del rango de subuid. Probado de punta a punta en los dos gestores —apt sobre Ubuntu base, pacman sobre el bootstrap de Arch—: instalan, el binario corre después con la red apagada, y un `recreate` tira la capa y la deja igual. 27 tests verdes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q --- crates/hammer-cli/src/qorpa.rs | 305 +++++++++++++++++++++++++++++-- docs/adr/0015-imagenes-ajenas.md | 23 +++ 2 files changed, 314 insertions(+), 14 deletions(-) diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index 3836d783..b4fd6d71 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -91,6 +91,9 @@ pub enum QorpaCmd { /// irreemplazable es justo lo que hammer existe para no tener. Recreate { id: String, + /// No reinstalar `packages` después de tirar la capa. Deja la instancia como recién creada. + #[arg(long)] + no_provision: bool, #[arg(long)] root: Option, }, @@ -114,6 +117,23 @@ pub enum QorpaCmd { #[arg(last = true)] cmd: Vec, }, + /// [ADR 0015 D3] Instala en la instancia lo declarado en `packages`. + /// + /// **Es lo que vuelve cierta la frase «el manifiesto es la verdad».** Sin esto `packages` era + /// una lista que nadie leía: `recreate` tiraba la capa y lo instalado a mano no volvía, así que + /// el `upper` era el activo de verdad — exactamente el blob irreemplazable que hammer existe + /// para no tener. + Provision { + id: String, + /// Reinstala aunque el registro diga que esos mismos paquetes ya se instalaron. + #[arg(long)] + force: bool, + /// Imprime el guión que correría dentro de la instancia y sale. + #[arg(long)] + dry_run: bool, + #[arg(long)] + root: Option, + }, /// [ADR 0015 §Orden 5] Genera los shims y `.desktop` de lo declarado en `[export]`. /// /// **Se GENERAN, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno: copiarlo @@ -157,9 +177,14 @@ pub fn run(cmd: QorpaCmd) -> Result<()> { QorpaCmd::Create { id, base, distro, root } => { create(&resolve_root(root), &id, &base, distro.as_deref()) } - QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id), + QorpaCmd::Recreate { id, no_provision, root } => { + recreate(&resolve_root(root), &id, no_provision) + } QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => { - run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id) + run_instance(&resolve_root(root), &id, &cmd, Ajustes { dry_run, no_jail, single_id, ..Default::default() }) + } + QorpaCmd::Provision { id, force, dry_run, root } => { + provision(&resolve_root(root), &id, force, dry_run) } QorpaCmd::Export { id, into, remove, root } => { export(&resolve_root(root), &id, into.as_deref(), remove) @@ -577,8 +602,9 @@ struct Instance { base: String, #[serde(skip_serializing_if = "Option::is_none")] distro: Option, - /// Lo que debe estar instalado adentro. Declarativo: hoy se registra, instalarlo es el - /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada. + /// Lo que debe estar instalado adentro. **Se instala solo**: `hammer qorpa provision` lo pone + /// con el gestor de la imagen, y `recreate` lo llama después de tirar la capa. Sin esto el + /// `upper` sería el activo y el manifiesto un adorno. #[serde(default, skip_serializing_if = "Vec::is_empty")] packages: Vec, /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada. @@ -853,7 +879,7 @@ fn write_instance(dir: &Path, inst: &Instance) -> Result<()> { Ok(()) } -fn recreate(root: &Path, id: &str) -> Result<()> { +fn recreate(root: &Path, id: &str, no_provision: bool) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let (sha, _) = resolve_image(root, &inst.base)?; for capa in ["upper", "work"] { @@ -861,14 +887,162 @@ fn recreate(root: &Path, id: &str) -> Result<()> { .with_context(|| format!("no pude tirar {capa}/ de {id}"))?; std::fs::create_dir_all(dir.join(capa))?; } + // El registro vive FUERA del `upper` (si viviera dentro se iría con él y nunca sabríamos qué + // había), así que hay que borrarlo a mano: la capa se fue, así que no hay nada instalado. + // Dejarlo sería la forma más pura del error de la regla 3 — un registro que afirma presencia + // sobre un árbol vacío. + let _ = std::fs::remove_file(dir.join(PROVISION_LEDGER)); println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]); - if !inst.packages.is_empty() { - println!( - " declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`", - inst.packages.len(), - inst.packages.join(", ") - ); + if inst.packages.is_empty() { + return Ok(()); } + if no_provision { + println!( + " declara {} paquete(s) y NO se reinstalaron (--no-provision): `hammer qorpa \ + provision {id}`", + inst.packages.len() + ); + return Ok(()); + } + // Acá es donde D3 deja de ser una aspiración: si `recreate` no reinstala, el `upper` es el + // activo y el manifiesto un adorno. + println!(" reinstalando lo declarado ({} paquete(s))…", inst.packages.len()); + provision(root, id, false, false) +} + +// ── provision: `packages` deja de ser una lista que nadie lee (D3) ────────────────────────────── + +const PROVISION_LEDGER: &str = "provisioned.toml"; + +/// Lo que quedó instalado, para no reinstalar por gusto y para que `recreate` sepa qué rehacer. +/// Vive junto al manifiesto y NO dentro del `upper`: si viviera dentro se iría con la capa. +#[derive(Serialize, Deserialize, Default)] +struct Provisionado { + manager: String, + packages: Vec, + /// Segundos desde epoch. Sin dep de fechas: se lee con `date -u -d @`. + at_unix: u64, +} + +/// El guión que instala, y de qué gestor sale. +/// +/// **Un gestor por imagen, entrando explícitamente** — el ADR rechaza multiplexar `dnf`/`pacman` +/// detrás de un comando único (el `pmm` de Bedrock): eso es menos mágico y no miente. Acá no se +/// multiplexa nada: se DETECTA cuál trae la imagen y se corre ése, con sus manías. +#[derive(Debug)] +struct Gestor { + nombre: &'static str, + guion: String, +} + +/// Comillas simples de shell a prueba de todo: el contenido sale del manifiesto, que lo escribe una +/// persona, y un nombre de paquete no tiene por qué ser un token seguro. +fn comillar(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +fn gestor_de(dir: &Path, tree: &Path, paquetes: &[String]) -> Result { + // Nombres de paquete: se validan ANTES de armar el guión. Quotear alcanza para que no se + // ejecute nada, pero un nombre raro casi siempre es un error de tipeo, y fallar temprano dice + // dónde está mejor que un gestor quejándose de un paquete inexistente. + for p in paquetes { + if p.is_empty() + || !p.chars().all(|c| c.is_ascii_alphanumeric() || "._+-:@/".contains(c)) + { + bail!("nombre de paquete raro en `packages`: {p:?} — se aborta antes de armar el guión"); + } + } + let lista = paquetes.iter().map(|p| comillar(p)).collect::>().join(" "); + let hay = |rel: &str| resolver_en_instancia(dir, tree, rel).is_some(); + + if hay("usr/bin/apt-get") { + return Ok(Gestor { nombre: "apt", guion: format!( + "set -e\n\ + export DEBIAN_FRONTEND=noninteractive\n\ + apt-get update\n\ + apt-get install -y --no-install-recommends {lista}\n")}); + } + if hay("usr/bin/pacman") { + // Dos manías del bootstrap de Arch, y ninguna se ve hasta que pacman falla: + // · la mirrorlist viene ENTERA comentada ⇒ «no servers configured for repository»; + // · el llavero no está inicializado ⇒ toda firma es inválida. + // El mirror se elige acá y se dice cuál: es el geo oficial de Arch. Que lo elija la + // herramienta es peor que que lo elija el usuario, pero **no elegir** deja una instancia + // que no instala nada, y el fichero queda a la vista en el `upper` para cambiarlo. + return Ok(Gestor { nombre: "pacman", guion: format!( + "set -e\n\ + grep -q '^Server' /etc/pacman.d/mirrorlist 2>/dev/null || {{\n\ + \x20 echo 'aviso: mirrorlist vacía — pongo el mirror geo oficial de Arch' >&2\n\ + \x20 echo 'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' >> /etc/pacman.d/mirrorlist\n\ + }}\n\ + [ -d /etc/pacman.d/gnupg ] || {{ pacman-key --init; pacman-key --populate archlinux; }}\n\ + pacman -Sy --noconfirm --needed {lista}\n")}); + } + if hay("usr/bin/dnf") { + return Ok(Gestor { nombre: "dnf", guion: format!( + "set -e\ndnf install -y --setopt=install_weak_deps=False {lista}\n")}); + } + if hay("sbin/apk") || hay("usr/bin/apk") { + return Ok(Gestor { nombre: "apk", guion: format!( + "set -e\napk add --no-cache {lista}\n")}); + } + bail!( + "no encuentro gestor de paquetes en la instancia (busqué apt-get, pacman, dnf y apk).\n \ + Si la imagen usa otro, instalá a mano con `run` — pero entonces `recreate` no lo va a \n \ + poder rehacer, y eso es justo lo que D3 dice que no puede pasar." + ); +} + +fn provision(root: &Path, id: &str, force: bool, dry_run: bool) -> Result<()> { + let (dir, inst) = read_instance(root, id)?; + let (_, tree) = resolve_image(root, &inst.base)?; + if inst.packages.is_empty() { + println!( + "la instancia {id} no declara `packages`. Se instala lo DECLARADO: es lo que hace que \ + la capa mutable sea caché y no el activo." + ); + return Ok(()); + } + + let previo: Option = std::fs::read_to_string(dir.join(PROVISION_LEDGER)) + .ok() + .and_then(|s| toml::from_str(&s).ok()); + if let Some(p) = &previo { + if !force && p.packages == inst.packages { + println!( + "ya provisionada con {} ({} paquete(s)) — `--force` para reinstalar", + p.manager, p.packages.len() + ); + return Ok(()); + } + } + + let g = gestor_de(&dir, &tree, &inst.packages)?; + if dry_run { + println!("# gestor detectado: {}", g.nombre); + print!("{}", g.guion); + return Ok(()); + } + println!("provisionando {id} con {} — {} paquete(s)", g.nombre, inst.packages.len()); + run_instance(root, id, &["/bin/sh".into(), "-c".into(), g.guion.clone()], + Ajustes { provisionando: true, ..Default::default() })?; + + let reg = Provisionado { + manager: g.nombre.into(), + packages: inst.packages.clone(), + at_unix: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0), + }; + std::fs::write( + dir.join(PROVISION_LEDGER), + format!( + "# Lo escribe `hammer qorpa provision`. Es un REGISTRO, no la verdad: la verdad es\n\ + # `packages` en instance.toml. Si los dos difieren, gana el manifiesto y se reinstala.\n\ + # `at_unix` se lee con `date -u -d @`.\n{}", + toml::to_string_pretty(®).context("serializando el registro")? + ), + )?; + println!("listo: {} paquete(s) instalados con {}", reg.packages.len(), reg.manager); Ok(()) } @@ -1076,12 +1250,47 @@ fn userns_con_rango(uid_dentro: u32) -> Result { Ok(guard) } +/// Lo que NO viene del manifiesto: banderas de la invocación. Van juntas para que agregar una no +/// mueva la firma de media docena de llamadas. +#[derive(Default, Clone, Copy)] +struct Ajustes { + dry_run: bool, + no_jail: bool, + single_id: bool, + /// Modo `provision`: la política declarada se ENSANCHA para poder instalar, y sólo mientras + /// dura la instalación. Ver `provision()` — lo que se ensancha y por qué se dice en voz alta. + provisionando: bool, +} + fn run_instance( - root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool, single_id: bool, + root: &Path, id: &str, cmd: &[String], aj: Ajustes, ) -> Result<()> { - let (dir, inst) = read_instance(root, id)?; + let Ajustes { dry_run, no_jail, single_id, provisionando } = aj; + let (dir, mut inst) = read_instance(root, id)?; let (sha, tree) = resolve_image(root, &inst.base)?; + // ── LA POLÍTICA SE ENSANCHA PARA PROVISIONAR, Y SE DICE ──────────────────────────────────── + // Instalar paquetes pide exactamente las tres cosas que una instancia bien declarada NO tiene: + // red (los paquetes se bajan), root (el gestor chownea y escribe en /usr) y la imagen sin + // sellar (lo que instala va a /usr por definición). Ensancharlas en silencio sería la falla + // que el ADR llama por su nombre en D7: la política se AUTORA. Así que se ensancha sólo aquí, + // sólo mientras dura el `provision`, y se imprime en la cara — el manifiesto NO se toca, y el + // siguiente `run` vuelve a la política escrita. + if provisionando { + let mut abierto: Vec<&str> = Vec::new(); + if !inst.grants.network { abierto.push("red"); inst.grants.network = true; } + if !inst.grants.root { abierto.push("root"); inst.grants.root = true; } + if inst.grants.seal_image { abierto.push("imagen sin sellar"); inst.grants.seal_image = false; } + if inst.run_as.is_some() { abierto.push("como root, no `run_as`"); inst.run_as = None; } + if !abierto.is_empty() { + eprintln!( + "⚠ provision ENSANCHA la política sólo para esta operación: {}. El manifiesto no \n\ + \x20 cambia y el próximo `run` vuelve a lo declarado.", + abierto.join(", ") + ); + } + } + // Quién sos adentro. Con el mapeo por rango esto NO cuesta el rango: lo decide el propio mapa. // ⚠ Cambiarlo en una instancia YA USADA la corrompe: los ficheros del `upper` quedaron con los // uids del mapa anterior. Para correr como usuario normal está `run_as`, que no toca el mapa. @@ -2060,11 +2269,79 @@ mod tests { create(root, "i", &sha, None).unwrap(); let upper = instance_dir(root, "i").join("upper"); std::fs::write(upper.join("basura"), "x").unwrap(); - recreate(root, "i").unwrap(); + recreate(root, "i", true).unwrap(); assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive"); assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad"); } + /// El registro de lo provisionado vive FUERA del `upper`, así que `recreate` tiene que borrarlo + /// a mano. Si sobreviviera, afirmaría que hay paquetes instalados sobre una capa recién + /// vaciada: la forma más pura del error de la regla 3 —presencia declarada sin contenido—, y + /// además haría que el siguiente `provision` no instalara nada. + #[test] + fn recreate_no_deja_vivo_el_registro_de_lo_provisionado() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + let sha = "e".repeat(64); + imagen_falsa(root, &sha); + create(root, "i", &sha, None).unwrap(); + let dir = instance_dir(root, "i"); + let (_, mut inst) = read_instance(root, "i").unwrap(); + inst.packages = vec!["strace".into()]; + write_instance(&dir, &inst).unwrap(); + std::fs::write( + dir.join(PROVISION_LEDGER), + toml::to_string(&Provisionado { + manager: "apt".into(), packages: vec!["strace".into()], at_unix: 1, + }).unwrap(), + ).unwrap(); + + recreate(root, "i", true).unwrap(); + assert!(!dir.join(PROVISION_LEDGER).exists(), "el registro sobrevivió a la capa que describía"); + } + + /// El gestor se DETECTA de la vista merged, no se configura: una imagen trae el suyo. Y el + /// `upper` gana, porque el gestor puede haberse instalado después. + #[test] + fn el_gestor_sale_de_la_imagen_y_el_upper_manda() { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join("inst"); + let tree = d.path().join("tree"); + std::fs::create_dir_all(dir.join("upper/usr/bin")).unwrap(); + std::fs::create_dir_all(tree.join("usr/bin")).unwrap(); + + std::fs::write(tree.join("usr/bin/apt-get"), "").unwrap(); + let g = gestor_de(&dir, &tree, &["strace".into()]).unwrap(); + assert_eq!(g.nombre, "apt"); + assert!(g.guion.contains("--no-install-recommends")); + + std::fs::write(tree.join("usr/bin/dnf"), "").unwrap(); + assert_eq!(gestor_de(&dir, &tree, &["strace".into()]).unwrap().nombre, "apt", + "con dos, manda el orden declarado, no el azar del directorio"); + + // Sin ninguno: falla diciendo qué buscó, en vez de correr un comando que no existe. + let vacio = d.path().join("pelada"); + std::fs::create_dir_all(&vacio).unwrap(); + let err = gestor_de(&vacio, &vacio, &["x".into()]).unwrap_err().to_string(); + assert!(err.contains("pacman") && err.contains("apk"), "mensaje inesperado: {err}"); + } + + /// El nombre de paquete sale del manifiesto, que lo escribe una persona. Se comilla SIEMPRE, y + /// además se rechaza lo que no parece un nombre: quotear evita la ejecución, pero un `;` en un + /// nombre es un error de tipeo que conviene ver acá y no en la salida del gestor. + #[test] + fn un_nombre_de_paquete_raro_no_llega_al_guion() { + let d = tempfile::tempdir().unwrap(); + let tree = d.path().join("tree"); + std::fs::create_dir_all(tree.join("usr/bin")).unwrap(); + std::fs::write(tree.join("usr/bin/apt-get"), "").unwrap(); + + for malo in ["strace; rm -rf /", "$(whoami)", "a b", "'", ""] { + assert!(gestor_de(d.path(), &tree, &[malo.into()]).is_err(), "pasó {malo:?}"); + } + assert_eq!(comillar("a'b"), r"'a'\''b'"); + } + #[test] fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() { let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None); diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index aa19e3a6..f1afc387 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -156,6 +156,29 @@ Consecuencias que se caen solas: `/var/lib/hammer/qorpa/instances/*/upper` y eso es correcto, no un olvido. - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.** +**✅ COMPLETADO 2026-09-03 — hasta acá `packages` era una lista que nadie leía.** Durante unas +horas el ADR afirmaba las cuatro consecuencias de arriba mientras `recreate` confesaba en su propia +salida que «instalarlos todavía es a mano»: o sea que el `upper` **sí** era el activo y el +manifiesto un adorno. Ahora `hammer qorpa provision ` instala lo declarado, y **`recreate` lo +llama solo** (`--no-provision` para no hacerlo). + +| decisión | por qué | +|---|---| +| el gestor se **detecta** (`apt`, `pacman`, `dnf`, `apk`), no se configura | cada imagen trae el suyo. Y **no se multiplexa**: el ADR rechaza un comando único al estilo `pmm` de Bedrock; acá se elige cuál correr, no se inventa una interfaz que los tape | +| `provision` **ensancha la política y lo dice en la cara** | instalar pide las tres cosas que una instancia bien declarada no tiene —red, root y la imagen sin sellar—. Se ensancha sólo durante esa operación, el manifiesto **no se toca**, y el siguiente `run` vuelve a lo escrito. Ensancharlo en silencio sería exactamente lo que D7 prohíbe | +| el registro (`provisioned.toml`) vive **fuera** del `upper` | si viviera dentro se iría con la capa y nadie sabría qué había. Por eso `recreate` lo borra a mano: un registro que afirma paquetes sobre una capa recién vaciada es la forma más pura del error de la regla 3 | +| los nombres de paquete se **validan y se comillan** | salen del manifiesto, que lo escribe una persona; `strace; rm -rf /` no llega al guión | + +**Las dos manías que sólo aparecen provisionando de verdad** (medidas, no leídas): el bootstrap de +Arch trae la **mirrorlist entera comentada** —`pacman` muere con «no servers configured»— y el +**llavero sin inicializar**, con lo que toda firma es inválida; el guión pone el mirror geo oficial +**avisando cuál**, y corre `pacman-key --init && --populate` sólo si falta. `apt`, en cambio, no +necesita ni un workaround: es el dividendo del rango de subuid. + +**Probado de punta a punta en los dos gestores**: `apt` sobre Ubuntu base y `pacman` sobre el +bootstrap de Arch instalan, el binario corre después con la red apagada otra vez, y un `recreate` +tira la capa y la deja igual — que es la prueba de esta sección entera. + ### D4 — Granularidad: cuatro tipos, no uno | tipo | qué es | mutable | ¿sellable? | para qué |