hammer-bootstrap: capa de servicios (sshd como producto) — Etapa C Paso 2
Consolidación arquitectónica de sshd-como-servicio (Separación Mecanismo/ Política, decisión del usuario). El núcleo NO se toca: STAGE1_COMPONENTS + STAGE1_SEED_CARD siguen siendo el mecanismo base atómico que el selfhost-verify reconstruye bit a bit (of_tree 9adefb82/7fa6cb4e blindado). Nuevo en hammer-bootstrap: - SERVICE_COMPONENTS = [netup, openssh] (política de producto). - SSHD_SERVICE_CARD: card genesis Native/Restart (netup + ssh-keygen -A + exec sshd -D), validado E2E en QEMU. - product_seed_card(): compone la seed de producto = seed base + cards de servicio apendados al genesis vía serde_json (hammer sigue autocontenido, sin dep de card-core). El núcleo (hammerd+getty) se preserva. - assemble_product_rootfs() + product(): HIDRATACIÓN TARDÍA — hidrata el stage1-rootfs ya sellado (4/4 verificado) + inyecta openssh/netup encima + escribe configs (passwd con sshd, sshd_config con PidFile /run, /var/empty 0711, /root/.ssh) y sella un `product-rootfs` APARTE. of_tree del núcleo intacto. Idempotente, reproducible. GOTCHA: los ficheros hidratados son hardlinks read-only al store ⇒ romper el hardlink (remove+write) en vez de chmod (mutaría el inodo del store). - CLI: `hammer bootstrap product --rootfs <base> --recipes recipes`. - 4 tests nuevos (seed compone, hash determinista, assemble inyecta, recetas de servicio parsean). 36/36 verde. scripts/product-boot-test.sh: valida que el product-rootfs de la RUTA REAL bootea en QEMU y sirve SSH (sólo provisiona authorized_keys de prueba, no ensambla nada). VERDE: arje levanta hammerd+getty+sshd, handshake real "Accepted publickey for root", guest responde (seed=hammer-product con 3 cards, Linux 6.16.12). Cierra el agujero de verificación con la arquitectura final, no con el spike sucio. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -386,6 +386,201 @@ fn assemble_rootfs(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Capa de servicios: el PRODUCTO (Separación Mecanismo/Política) ──────────────────────────────
|
||||
//
|
||||
// `STAGE1_COMPONENTS` + `STAGE1_SEED_CARD` son el **mecanismo base**: el núcleo atómico que el
|
||||
// selfhost-verify reconstruye bit a bit (`of_tree` 9adefb82/7fa6cb4e). Inmutable y blindado. Los
|
||||
// **servicios** (sshd, …) son **política de producto**: NO entran al núcleo (no contaminan el
|
||||
// `of_tree` ni obligan a un rebuild pesado de openssl/zlib dentro de la VM). Se inyectan por
|
||||
// **hidratación tardía** sobre el rootfs base ya sellado, produciendo un `product-rootfs` APARTE.
|
||||
// Así, meter más servicios mañana no toca jamás el núcleo verificado.
|
||||
|
||||
/// Componentes de servicio inyectados sobre el 4/4 verificado: `netup` (red) + `openssh` (sshd/ssh).
|
||||
/// Crecer esta lista NO toca `STAGE1_COMPONENTS` ni el selfhost-verify.
|
||||
const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"];
|
||||
|
||||
/// Card de servicio `sshd` (genesis `Native`/`Restart`): el comando levanta la red (`netup`, DHCP),
|
||||
/// genera host keys al primer boot (`ssh-keygen -A`) y `exec`uta `sshd -D`. Todo dentro del card para
|
||||
/// no asumir orden de genesis. Mismo esquema validado de [`STAGE1_SEED_CARD`]; ULID propio. Se inyecta
|
||||
/// en el `genesis` de la seed de producto. Verificado E2E en QEMU (`scripts/ssh-e2e-test.sh`).
|
||||
const SSHD_SERVICE_CARD: &str = r#"{
|
||||
"schema_version": 1,
|
||||
"id": "01HQAR53D4M2NBV8KZTYXFQA12",
|
||||
"lineage": null,
|
||||
"label": "sshd",
|
||||
"provides": [],
|
||||
"requires": [],
|
||||
"permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
|
||||
"soma": {
|
||||
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
|
||||
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
|
||||
"cgroup": { "path": "arje.slice/sshd", "cpu_weight": null, "io_weight": null },
|
||||
"cpu_affinity": null
|
||||
},
|
||||
"payload": { "Native": {
|
||||
"exec": "/bin/busybox",
|
||||
"argv": ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"],
|
||||
"envp": [["HOME", "/root"]]
|
||||
}},
|
||||
"supervision": { "Restart": { "initial": 500, "max": 20000 } },
|
||||
"lifecycle": "daemon",
|
||||
"priority": "normal",
|
||||
"flow": { "input": [], "output": [] },
|
||||
"genesis": []
|
||||
}"#;
|
||||
|
||||
/// `/etc/passwd` del producto: `root` (login por llave) + `sshd` (usuario de privsep, shell inerte).
|
||||
const PRODUCT_PASSWD: &str = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
|
||||
/// `/etc/group` del producto.
|
||||
const PRODUCT_GROUP: &str = "root:x:0:\nsshd:x:74:\n";
|
||||
/// `/etc/ssh/sshd_config` del producto: sólo por llave (sin password), pidfile en `/run` (tmpfs que
|
||||
/// arje monta), authorized_keys provisionadas en deploy.
|
||||
const SSHD_CONFIG: &str = "Port 22\nListenAddress 0.0.0.0\nPermitRootLogin prohibit-password\nPubkeyAuthentication yes\nPasswordAuthentication no\nAuthorizedKeysFile /root/.ssh/authorized_keys\nPidFile /run/sshd.pid\nSubsystem sftp /usr/libexec/sftp-server\n";
|
||||
|
||||
/// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía
|
||||
/// `serde_json` (hammer sigue autocontenido: no depende de `card-core`). Determinista ⇒ el
|
||||
/// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual.
|
||||
fn product_seed_card() -> Result<String> {
|
||||
let mut seed: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD)
|
||||
.map_err(|e| Error::Other(format!("seed base inválida: {e}")))?;
|
||||
seed["label"] = serde_json::Value::String("hammer-product".into());
|
||||
let card: serde_json::Value = serde_json::from_str(SSHD_SERVICE_CARD)
|
||||
.map_err(|e| Error::Other(format!("card sshd inválida: {e}")))?;
|
||||
seed.get_mut("genesis")
|
||||
.and_then(|g| g.as_array_mut())
|
||||
.ok_or_else(|| Error::Other("seed base sin array `genesis`".into()))?
|
||||
.push(card);
|
||||
serde_json::to_string_pretty(&seed).map_err(|e| Error::Other(format!("serializar seed: {e}")))
|
||||
}
|
||||
|
||||
/// Identidad del `product-rootfs`: tag + el `RootfsHash` del 4/4 base + `(nombre,hash)` de cada
|
||||
/// servicio + la seed de producto + las configs. Cambiar cualquiera re-hashea (reproducible).
|
||||
fn product_rootfs_hash(
|
||||
base: &RootfsHash,
|
||||
services: &[(String, ArtifactHash)],
|
||||
seed: &str,
|
||||
) -> RootfsHash {
|
||||
let mut inputs: Vec<Vec<u8>> = vec![
|
||||
b"hammer-product-rootfs-v1".to_vec(),
|
||||
base.as_str().as_bytes().to_vec(),
|
||||
];
|
||||
for (name, h) in services {
|
||||
inputs.push(name.as_bytes().to_vec());
|
||||
inputs.push(h.as_str().as_bytes().to_vec());
|
||||
}
|
||||
inputs.push(seed.as_bytes().to_vec());
|
||||
inputs.push(PRODUCT_PASSWD.as_bytes().to_vec());
|
||||
inputs.push(PRODUCT_GROUP.as_bytes().to_vec());
|
||||
inputs.push(SSHD_CONFIG.as_bytes().to_vec());
|
||||
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
|
||||
ArtifactHash::of_inputs(&refs)
|
||||
}
|
||||
|
||||
/// Ensambla el árbol de producto en `staging`: hidrata el rootfs base sellado (4/4) y luego los
|
||||
/// componentes de servicio ENCIMA (hidratación tardía); sobrescribe la seed con la de producto y
|
||||
/// escribe las configs de servicio (passwd/group/sshd_config + `/var/empty` 0711, `/etc/ssh`,
|
||||
/// `/root/.ssh` 0700). Pieza testeable sin build real.
|
||||
fn assemble_product_rootfs(
|
||||
store: &Store,
|
||||
base: &RootfsHash,
|
||||
services: &[(String, ArtifactHash)],
|
||||
seed: &str,
|
||||
staging: &Path,
|
||||
) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// 1) base verificado (4/4): hidratación por hardlinks del árbol ya sellado (incl. mountpoints
|
||||
// vacíos, /sbin/init y la seed base, que sobrescribimos en el paso 3).
|
||||
let base_dir = store.path_of(base, "stage1-rootfs");
|
||||
if !base_dir.is_dir() {
|
||||
return Err(Error::Other(format!(
|
||||
"rootfs base no sellado en {}",
|
||||
base_dir.display()
|
||||
)));
|
||||
}
|
||||
hammer_build::run_hydrate(&base_dir, staging, hammer_core::LinkMode::Static, None)?;
|
||||
|
||||
// 2) servicios encima (openssh, netup): mismo proyectado por hardlink.
|
||||
for (name, h) in services {
|
||||
let dir = store.path_of(h, name);
|
||||
hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static, None)?;
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(staging.join(".hammer"));
|
||||
|
||||
// 3) seed de producto (sobrescribe la base) + configs + dirs de runtime de sshd.
|
||||
// Los ficheros hidratados son HARDLINKS al store sellado (read-only); escribir encima daría
|
||||
// EACCES y `chmod` mutaría el inodo del store. Hay que ROMPER el hardlink: remove + write nuevo.
|
||||
let write_fresh = |rel: &str, data: &str| -> Result<()> {
|
||||
let p = staging.join(rel);
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(&p, data)?;
|
||||
Ok(())
|
||||
};
|
||||
write_fresh("ente/seed.card.json", seed)?;
|
||||
write_fresh("etc/passwd", PRODUCT_PASSWD)?;
|
||||
write_fresh("etc/group", PRODUCT_GROUP)?;
|
||||
write_fresh("etc/ssh/sshd_config", SSHD_CONFIG)?;
|
||||
let empty = staging.join("var/empty"); // jaula de privsep: root, no group/world-writable.
|
||||
std::fs::create_dir_all(&empty)?;
|
||||
std::fs::set_permissions(&empty, std::fs::Permissions::from_mode(0o711))?;
|
||||
let dotssh = staging.join("root/.ssh"); // authorized_keys provisionadas en deploy.
|
||||
std::fs::create_dir_all(&dotssh)?;
|
||||
std::fs::set_permissions(&dotssh, std::fs::Permissions::from_mode(0o700))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// **Producto** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4) y le inyecta la
|
||||
/// capa de servicios por hidratación tardía, sellando un `product-rootfs`. El mecanismo base queda
|
||||
/// intacto: este árbol NO es el que reconstruye el selfhost-verify (su `of_tree` sigue blindado).
|
||||
/// Idempotente. `base_cfg` apunta el zig del lab (no la semilla): los servicios usan su `zig_version`
|
||||
/// propia (openssh ⇒ 0.13.0), y si ya están sellados, `build` los devuelve cacheados sin recompilar.
|
||||
pub fn product(
|
||||
base: &RootfsHash,
|
||||
base_cfg: &hammer_build::BuildConfig,
|
||||
recipes_dir: &Path,
|
||||
store: &Store,
|
||||
) -> Result<RootfsHash> {
|
||||
// 1) construir/cachear los componentes de servicio (idempotente vía el lab).
|
||||
let mut services: Vec<(String, ArtifactHash)> = Vec::with_capacity(SERVICE_COMPONENTS.len());
|
||||
for name in SERVICE_COMPONENTS {
|
||||
let recipe_path = recipes_dir.join(format!("{name}.toml"));
|
||||
let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| {
|
||||
Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display()))
|
||||
})?;
|
||||
let h = hammer_build::build(&recipe, base_cfg, store)?;
|
||||
services.push((name.to_string(), h));
|
||||
}
|
||||
|
||||
// 2) seed de producto + hash de identidad.
|
||||
let seed = product_seed_card()?;
|
||||
let phash = product_rootfs_hash(base, &services, &seed);
|
||||
let store_name = "product-rootfs";
|
||||
|
||||
// 3) ensamblar + sellar (si no estaba ya).
|
||||
if !store.has(&phash, store_name) {
|
||||
let staging = store
|
||||
.root()
|
||||
.join(".bootstrap-tmp")
|
||||
.join(phash.store_dir_name(store_name));
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
let result = (|| -> Result<()> {
|
||||
assemble_product_rootfs(store, base, &services, &seed, &staging)?;
|
||||
store.seal(&staging, &phash, store_name)?;
|
||||
Ok(())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
result?;
|
||||
tracing::info!(hash = %phash, "product: rootfs de servicios sellado");
|
||||
} else {
|
||||
tracing::info!(hash = %phash, "product: rootfs ya sellado (idempotente)");
|
||||
}
|
||||
Ok(phash)
|
||||
}
|
||||
|
||||
// ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ──────────────────────
|
||||
//
|
||||
// El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando
|
||||
@@ -1309,6 +1504,106 @@ mod tests {
|
||||
assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox");
|
||||
}
|
||||
|
||||
// --- Capa de servicios (producto): composición de seed + hidratación tardía ---
|
||||
|
||||
#[test]
|
||||
fn product_seed_appends_sshd_to_base_genesis() {
|
||||
// La seed de producto = base + sshd; el núcleo (hammerd, getty) se preserva intacto y el
|
||||
// resultado es JSON válido (lo que arje-zero validará al boot).
|
||||
let seed = product_seed_card().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
|
||||
assert_eq!(v["label"], "hammer-product");
|
||||
assert_eq!(v["payload"], "Virtual");
|
||||
let labels: Vec<&str> = v["genesis"].as_array().unwrap()
|
||||
.iter().map(|g| g["label"].as_str().unwrap()).collect();
|
||||
assert_eq!(labels, vec!["hammerd", "console-getty", "sshd"], "núcleo intacto + sshd añadido");
|
||||
let sshd = &v["genesis"][2];
|
||||
assert_eq!(sshd["payload"]["Native"]["exec"], "/bin/busybox");
|
||||
assert!(sshd["payload"]["Native"]["argv"][2].as_str().unwrap().contains("/usr/sbin/sshd"));
|
||||
assert!(sshd["supervision"]["Restart"]["initial"].is_number(), "sshd es Restart (lifecycle)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_rootfs_hash_is_deterministic_and_sensitive() {
|
||||
let base = ArtifactHash::from_hex("ab");
|
||||
let svcs = [("openssh".to_string(), ArtifactHash::from_hex("cd"))];
|
||||
let seed = product_seed_card().unwrap();
|
||||
let a = product_rootfs_hash(&base, &svcs, &seed);
|
||||
assert_eq!(a, product_rootfs_hash(&base, &svcs, &seed), "determinista");
|
||||
let other_base = ArtifactHash::from_hex("ef");
|
||||
assert_ne!(a, product_rootfs_hash(&other_base, &svcs, &seed), "cambiar la base re-hashea");
|
||||
let other_svc = [("openssh".to_string(), ArtifactHash::from_hex("99"))];
|
||||
assert_ne!(a, product_rootfs_hash(&base, &other_svc, &seed), "cambiar un servicio re-hashea");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_product_injects_services_over_base() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(tmp.path().join("store")).unwrap();
|
||||
|
||||
// Un rootfs base sintético sellado como `stage1-rootfs` (el 4/4 ya verificado): trae la seed
|
||||
// base, /sbin/init, un mountpoint vacío y un binario del núcleo.
|
||||
let base = seal_component(&store, "stage1-rootfs", "ba5e", |w| {
|
||||
std::fs::create_dir_all(w.join("usr/bin")).unwrap();
|
||||
std::fs::create_dir_all(w.join("ente")).unwrap();
|
||||
std::fs::create_dir_all(w.join("proc")).unwrap();
|
||||
std::fs::create_dir_all(w.join("var")).unwrap();
|
||||
std::fs::create_dir_all(w.join("root")).unwrap();
|
||||
std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELFfake").unwrap();
|
||||
std::fs::write(w.join("ente/seed.card.json"), STAGE1_SEED_CARD).unwrap();
|
||||
std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin_init_tmp")).unwrap();
|
||||
});
|
||||
// Componentes de servicio sintéticos.
|
||||
let hossh = seal_component(&store, "openssh", "0551", |w| {
|
||||
std::fs::create_dir_all(w.join("usr/sbin")).unwrap();
|
||||
std::fs::write(w.join("usr/sbin/sshd"), b"\x7fELFfake").unwrap();
|
||||
});
|
||||
let hnet = seal_component(&store, "netup", "9e72", |w| {
|
||||
std::fs::create_dir_all(w.join("usr/bin")).unwrap();
|
||||
std::fs::write(w.join("usr/bin/netup"), b"\x7fELFfake").unwrap();
|
||||
});
|
||||
|
||||
let seed = product_seed_card().unwrap();
|
||||
let services = [("openssh".to_string(), hossh), ("netup".to_string(), hnet)];
|
||||
let staging = store.root().join("prod-staging");
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
assemble_product_rootfs(&store, &base, &services, &seed, &staging).unwrap();
|
||||
|
||||
// base hidratada + servicios inyectados.
|
||||
assert!(staging.join("usr/bin/hammerd").is_file(), "núcleo presente");
|
||||
assert!(staging.join("proc").is_dir(), "mountpoint vacío recreado");
|
||||
assert!(staging.join("usr/sbin/sshd").is_file(), "openssh inyectado");
|
||||
assert!(staging.join("usr/bin/netup").is_file(), "netup inyectado");
|
||||
// la seed fue sobrescrita con la de producto (3 cards).
|
||||
let got = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&got).unwrap();
|
||||
assert_eq!(v["label"], "hammer-product");
|
||||
assert_eq!(v["genesis"].as_array().unwrap().len(), 3);
|
||||
// configs de servicio.
|
||||
assert!(std::fs::read_to_string(staging.join("etc/passwd")).unwrap().contains("sshd:"));
|
||||
assert!(std::fs::read_to_string(staging.join("etc/ssh/sshd_config")).unwrap().contains("Port 22"));
|
||||
// /var/empty con permisos 0711 (privsep no acepta group/world-writable).
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(staging.join("var/empty")).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o711, "/var/empty debe ser 0711");
|
||||
assert!(staging.join("root/.ssh").is_dir(), "/root/.ssh para authorized_keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_service_recipes_parse_and_hash() {
|
||||
// Las recetas de servicio reales (netup, openssh) cargan y hashean — no contaminan el núcleo.
|
||||
let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(tmp.path().join("store")).unwrap();
|
||||
for name in SERVICE_COMPONENTS {
|
||||
let path = recipes_dir.join(format!("{name}.toml"));
|
||||
let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
|
||||
assert_eq!(&r.name, name);
|
||||
let h = hammer_build::artifact_hash(&r, &store).expect("hash");
|
||||
assert!(h.as_str().starts_with("b3:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_stage1_recipes_parse_and_hash() {
|
||||
// Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK).
|
||||
|
||||
@@ -263,6 +263,18 @@ enum BootstrapCmd {
|
||||
#[arg(long, default_value = "recipes")]
|
||||
recipes: String,
|
||||
},
|
||||
/// [Producto] Capa de SERVICIOS: toma el rootfs base verificado del 4/4 (`--rootfs`, el que
|
||||
/// imprimió `stage1`) e inyecta por hidratación tardía los componentes de servicio (sshd/openssh
|
||||
/// + netup) y sus configs, sellando un `product-rootfs` APARTE. El mecanismo base no se toca (su
|
||||
/// `of_tree` sigue blindado para el selfhost-verify). Separación Mecanismo/Política.
|
||||
Product {
|
||||
/// Hash del rootfs base de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
||||
#[arg(long)]
|
||||
rootfs: String,
|
||||
/// Directorio de recetas (`openssh.toml`, `netup.toml`, …).
|
||||
#[arg(long, default_value = "recipes")]
|
||||
recipes: String,
|
||||
},
|
||||
/// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
|
||||
/// (anota la línea del manifiesto). Con `--verify <hash>` compara contra un rebuild `stage1'`
|
||||
/// (producido en la VM) y emite el veredicto de auto-alojamiento.
|
||||
@@ -571,6 +583,20 @@ fn main() -> anyhow::Result<()> {
|
||||
let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
|
||||
println!("{hash}");
|
||||
}
|
||||
BootstrapCmd::Product { rootfs, recipes } => {
|
||||
let store = hammer_core::Store::open(&cli.store)?;
|
||||
// El zig del lab (no la semilla): los servicios usan su `zig_version` propia
|
||||
// (openssh ⇒ 0.13.0, hermano del default), y si ya están sellados, salen cacheados.
|
||||
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
||||
let base = hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
|
||||
let hash = hammer_bootstrap::product(
|
||||
&base,
|
||||
&base_cfg,
|
||||
std::path::Path::new(&recipes),
|
||||
&store,
|
||||
)?;
|
||||
println!("{hash}");
|
||||
}
|
||||
BootstrapCmd::Stage2 { rootfs, verify } => {
|
||||
let store = hammer_core::Store::open(&cli.store)?;
|
||||
let rootfs_hash =
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# product-boot-test.sh — valida que el `product-rootfs` ENSAMBLADO POR LA RUTA REAL
|
||||
# (`hammer bootstrap product`) bootea en QEMU y sirve SSH. A diferencia de ssh-e2e-test.sh (spike
|
||||
# que arma un rootfs sucio a mano), acá NO se ensambla nada: se toma el artefacto sellado tal cual
|
||||
# (seed de producto, passwd, sshd_config, /var/empty, sshd como card — todo ya inyectado por la capa
|
||||
# de servicios) y sólo se provisiona una authorized_keys de prueba (lo que en deploy haría el admin).
|
||||
#
|
||||
# Prueba el axioma de la consolidación: el mismo árbol que produce el pipeline arranca el daemon SSH
|
||||
# bajo arje sin tocar el núcleo verificado.
|
||||
#
|
||||
# Uso: PRODUCT=<hash-o-prefijo> ./scripts/product-boot-test.sh
|
||||
# (PRODUCT = el que imprimió `hammer bootstrap product`; default: el más reciente del store)
|
||||
# Vars: KERNEL MEM (def 2048) KVM (def 1) PORT (def 2223) DEADLINE (def 150)
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
|
||||
KERNEL="${KERNEL:-$(ls store/*-linux/boot/bzImage 2>/dev/null | head -1)}"
|
||||
MEM="${MEM:-2048}"; KVM="${KVM:-1}"; PORT="${PORT:-2223}"; DEADLINE="${DEADLINE:-150}"
|
||||
WORK="$ROOT/work/product-boot"; RFS="$WORK/rootfs"; LOG="$WORK/console.log"
|
||||
|
||||
# localizar el product-rootfs sellado (por prefijo, o el más reciente)
|
||||
if [ -n "${PRODUCT:-}" ]; then
|
||||
PDIR=$(ls -d store/"${PRODUCT#b3:}"*-product-rootfs 2>/dev/null | head -1)
|
||||
else
|
||||
PDIR=$(ls -dt store/*-product-rootfs 2>/dev/null | head -1)
|
||||
fi
|
||||
[ -n "$PDIR" ] && [ -d "$PDIR" ] || { echo "no encuentro product-rootfs (corré 'hammer bootstrap product')"; exit 1; }
|
||||
[ -n "$KERNEL" ] && [ -e "$KERNEL" ] || { echo "no encuentro kernel"; exit 1; }
|
||||
echo "==> product-rootfs: $PDIR"
|
||||
echo "==> kernel: $KERNEL"
|
||||
|
||||
# copia escribible del artefacto sellado (read-only); NO modificamos el store
|
||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||
cp -a "$PDIR"/. "$RFS"/
|
||||
chmod -R u+w "$RFS"
|
||||
rm -rf "$RFS/.hammer"
|
||||
|
||||
# provisión de admin: una authorized_keys de prueba (única cosa que el producto NO trae horneada)
|
||||
"$(ls store/*-openssh/usr/bin/ssh-keygen | head -1)" -t ed25519 -N '' -f "$WORK/clientkey" -q
|
||||
install -d -m 0700 "$RFS/root/.ssh"
|
||||
cat "$WORK/clientkey.pub" > "$RFS/root/.ssh/authorized_keys"
|
||||
chmod 0600 "$RFS/root/.ssh/authorized_keys"
|
||||
|
||||
# sanity: el árbol de producto trae lo que esperamos (sin ensamblarlo acá)
|
||||
echo "==> chequeo de contenido del artefacto:"
|
||||
for f in usr/sbin/sshd usr/bin/netup etc/ssh/sshd_config etc/passwd ente/seed.card.json; do
|
||||
[ -e "$RFS/$f" ] && echo " ok $f" || { echo " FALTA $f"; exit 1; }
|
||||
done
|
||||
grep -q '"label": "sshd"' "$RFS/ente/seed.card.json" && echo " ok card sshd en la seed" || { echo " FALTA card sshd"; exit 1; }
|
||||
|
||||
# empaquetar (root-owned) y bootear
|
||||
( cd "$RFS" && find . -print0 | cpio --null -o -H newc -R 0:0 2>/dev/null | gzip -1 ) > "$WORK/initramfs.cpio.gz"
|
||||
accel=(-cpu Broadwell); { [ "$KVM" = 1 ] && [ -w /dev/kvm ]; } && accel=(-enable-kvm -cpu host)
|
||||
echo "==> boot QEMU (mem ${MEM}), hostfwd :$PORT->:22"
|
||||
qemu-system-x86_64 -m "$MEM" -no-reboot -nographic "${accel[@]}" \
|
||||
-kernel "$KERNEL" -initrd "$WORK/initramfs.cpio.gz" -append "console=ttyS0 rdinit=/sbin/init" \
|
||||
-netdev "user,id=n0,hostfwd=tcp::$PORT-:22" -device e1000,netdev=n0 > "$LOG" 2>&1 &
|
||||
QPID=$!; trap 'kill $QPID 2>/dev/null || true' EXIT
|
||||
|
||||
SSHOPTS=(-p "$PORT" -i "$WORK/clientkey" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
|
||||
-o GlobalKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=4 -o LogLevel=ERROR)
|
||||
echo "==> esperando handshake (deadline ${DEADLINE}s)…"
|
||||
start=$(date +%s); ok=0
|
||||
while [ $(( $(date +%s) - start )) -lt "$DEADLINE" ]; do
|
||||
kill -0 $QPID 2>/dev/null || { echo "!! QEMU murió"; break; }
|
||||
if out=$(ssh "${SSHOPTS[@]}" root@localhost \
|
||||
'echo PRODUCT_SSH_OK uid=$(id -u); cat /ente/seed.card.json | grep -o "\"label\": \"[a-z-]*\"" | tr "\n" " "; echo; uname -sr' 2>/dev/null); then
|
||||
echo "============== RESPUESTA DEL GUEST =============="; echo "$out"; echo "================================================"
|
||||
echo "$out" | grep -q PRODUCT_SSH_OK && ok=1; break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "---- consola (cola) ----"; tail -18 "$LOG" 2>/dev/null
|
||||
[ "$ok" = 1 ] && { echo; echo "*** PRODUCT SSH OK — el product-rootfs de la ruta real bootea y sirve SSH ***"; exit 0; }
|
||||
echo; echo "*** PRODUCT SSH FALLÓ — ver $LOG ***"; exit 1
|
||||
Reference in New Issue
Block a user