diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index c5c420fc..65d7a326 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -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 { + 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![ + 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 { + // 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). diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 0341972f..8a5aea3d 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -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 ` 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 = diff --git a/scripts/product-boot-test.sh b/scripts/product-boot-test.sh new file mode 100755 index 00000000..e91d75c4 --- /dev/null +++ b/scripts/product-boot-test.sh @@ -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= ./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