> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
qorpa D3: `packages` se instala solo — el manifiesto deja de ser un adorno
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 <id>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
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<PathBuf>,
},
@@ -114,6 +117,23 @@ pub enum QorpaCmd {
#[arg(last = true)]
cmd: Vec<String>,
},
+ /// [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<PathBuf>,
+ },
/// [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<String>,
- /// 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<String>,
/// 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() {
+ 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<String>,
+ /// Segundos desde epoch. Sin dep de fechas: se lee con `date -u -d @<n>`.
+ 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<Gestor> {
+ // 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::<Vec<_>>().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!(
- " declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`",
- inst.packages.len(),
- inst.packages.join(", ")
+ "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<Provisionado> = 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>`.\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<UsernsMapeado> {
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 <id>` 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é |