diff --git a/Cargo.lock b/Cargo.lock index 821713e..0d2edba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,19 @@ dependencies = [ "zbus", ] +[[package]] +name = "ente-machined-compat" +version = "0.0.1" +dependencies = [ + "anyhow", + "ente-bus", + "ente-card", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", +] + [[package]] name = "ente-polkit-compat" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 8683fd4..99bef85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/ente-journald-compat", "crates/ente-resolved-compat", "crates/ente-polkit-compat", + "crates/ente-machined-compat", ] [workspace.package] diff --git a/crates/ente-bus/src/lib.rs b/crates/ente-bus/src/lib.rs index 2ce90a4..586df9c 100644 --- a/crates/ente-bus/src/lib.rs +++ b/crates/ente-bus/src/lib.rs @@ -18,6 +18,20 @@ pub const ENV_BUS_SOCK: &str = "ENTE_BUS_SOCK"; pub const ENV_ENTE_ID: &str = "ENTE_ID"; pub const MAX_FRAME: usize = 1 << 20; // 1 MiB — protección contra OOM +/// Interface UUID para decisiones de policy. Un Ente independiente +/// (separado de polkit-compat) se anuncia como proveedor de +/// `Capability::Endpoint { interface: POLKIT_DECISION_IFACE, version: 1 }` +/// para arbitrar autorizaciones. Recibe blob: +/// `pid_be | uid_be | action_id_utf8` → responde 1 byte: 1=allow, 0=deny. +pub const POLKIT_DECISION_IFACE: ente_card::InterfaceId = + ente_card::InterfaceId([0xb0; 16]); + +/// Interface UUID auto-anunciado por compat-polkit. Diferente al de +/// decisión para evitar recursión (polkit-compat invoca DECISION pero +/// no es proveedor de DECISION; se anuncia como SERVICE). +pub const POLKIT_SERVICE_IFACE: ente_card::InterfaceId = + ente_card::InterfaceId([0xa4; 16]); + /// Credenciales del peer extraídas vía SO_PEERCRED al accept del bus. /// Imposibles de falsear desde el cliente — el kernel las inyecta. /// Definidas aquí (no en ente-zero) porque conceptualmente son atributo diff --git a/crates/ente-journald-compat/Cargo.toml b/crates/ente-journald-compat/Cargo.toml index 9c8fc1f..c248dfe 100644 --- a/crates/ente-journald-compat/Cargo.toml +++ b/crates/ente-journald-compat/Cargo.toml @@ -9,6 +9,10 @@ publish.workspace = true name = "ente-journald-compat" path = "src/main.rs" +[[bin]] +name = "ente-journalctl" +path = "src/journalctl.rs" + [dependencies] ente-card = { path = "../ente-card" } ente-bus = { path = "../ente-bus" } diff --git a/crates/ente-journald-compat/src/journalctl.rs b/crates/ente-journald-compat/src/journalctl.rs new file mode 100644 index 0000000..c282228 --- /dev/null +++ b/crates/ente-journald-compat/src/journalctl.rs @@ -0,0 +1,197 @@ +//! ente-journalctl: query CLI sobre el journal persistido en CAS. +//! +//! Lee el index `~/.local/share/ente/journal/index.log` (líneas +//! `timestamp_ms:source:unit:sha_hex`), filtra, y para cada match +//! restituye el blob desde CAS y lo imprime. +//! +//! Uso: +//! ente-journalctl # todo el journal +//! ente-journalctl --unit foo.service # filtra por unit +//! ente-journalctl --since 60 # últimos 60 segundos +//! ente-journalctl --grep "panic" # contiene "panic" +//! ente-journalctl --tail 20 # últimas 20 entries +//! ente-journalctl --json # output JSON-lines + +use std::path::PathBuf; + +struct Args { + unit: Option, + since_secs: Option, + grep: Option, + tail: Option, + source: Option, + json: bool, +} + +fn parse_args() -> Args { + let mut args = std::env::args().skip(1); + let mut a = Args { + unit: None, since_secs: None, grep: None, tail: None, source: None, json: false, + }; + while let Some(arg) = args.next() { + match arg.as_str() { + "--unit" | "-u" => a.unit = args.next(), + "--since" | "-S" => a.since_secs = args.next().and_then(|s| s.parse().ok()), + "--grep" | "-g" => a.grep = args.next(), + "--tail" | "-n" => a.tail = args.next().and_then(|s| s.parse().ok()), + "--source" => a.source = args.next(), + "--json" => a.json = true, + "-h" | "--help" => { print_help(); std::process::exit(0); } + other => { + eprintln!("argumento desconocido: {other}"); + print_help(); + std::process::exit(2); + } + } + } + a +} + +fn print_help() { + eprintln!("ente-journalctl — query CLI del journal persistido en CAS"); + eprintln!(); + eprintln!("Filtros:"); + eprintln!(" --unit, -u Filtra por unidad (e.g. foo.service)"); + eprintln!(" --source journal | syslog"); + eprintln!(" --since, -S Sólo últimos N segundos"); + eprintln!(" --grep, -g Contiene en el body decoded"); + eprintln!(" --tail, -n Últimas N entries"); + eprintln!("Output:"); + eprintln!(" --json JSON-lines en lugar de pretty"); +} + +fn index_path() -> PathBuf { + let base = if let Ok(d) = std::env::var("XDG_DATA_HOME") { d } + else if let Ok(h) = std::env::var("HOME") { format!("{h}/.local/share") } + else { "/var/lib".into() }; + PathBuf::from(base).join("ente").join("journal").join("index.log") +} + +fn now_ms() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +#[derive(Debug)] +struct IndexEntry { + timestamp_ms: u128, + source: String, + unit: String, + sha_hex: String, +} + +fn parse_line(line: &str) -> Option { + let mut parts = line.splitn(4, ':'); + let ts: u128 = parts.next()?.parse().ok()?; + let source = parts.next()?.to_string(); + let unit = parts.next()?.to_string(); + let sha = parts.next()?.to_string(); + if sha.len() != 64 { return None; } + Some(IndexEntry { timestamp_ms: ts, source, unit, sha_hex: sha }) +} + +fn parse_sha(hex: &str) -> Option<[u8; 32]> { + if hex.len() != 64 { return None; } + let mut sha = [0u8; 32]; + for i in 0..32 { + sha[i] = u8::from_str_radix(&hex[i*2..i*2+2], 16).ok()?; + } + Some(sha) +} + +fn main() -> anyhow::Result<()> { + let args = parse_args(); + let path = index_path(); + if !path.exists() { + eprintln!("index no existe: {} — ¿journald-compat ha corrido?", path.display()); + std::process::exit(1); + } + let raw = std::fs::read_to_string(&path)?; + let mut entries: Vec = raw.lines() + .filter_map(parse_line) + .collect(); + + // Filtros + let now = now_ms(); + if let Some(secs) = args.since_secs { + let cutoff = now.saturating_sub(secs as u128 * 1000); + entries.retain(|e| e.timestamp_ms >= cutoff); + } + if let Some(unit) = &args.unit { + entries.retain(|e| &e.unit == unit); + } + if let Some(src) = &args.source { + entries.retain(|e| &e.source == src); + } + // tail después de filtros temporales/identidad pero antes de grep — + // grep es post porque requiere cargar bytes del CAS. + + let mut out: Vec<(IndexEntry, String)> = entries.into_iter() + .filter_map(|e| { + let sha = parse_sha(&e.sha_hex)?; + let bytes = ente_cas::resolve(&sha).ok()?; + let body = String::from_utf8_lossy(&bytes).into_owned(); + Some((e, body)) + }) + .collect(); + + if let Some(g) = &args.grep { + out.retain(|(_, body)| body.contains(g.as_str())); + } + if let Some(n) = args.tail { + let len = out.len(); + if len > n { out.drain(..len - n); } + } + + for (e, body) in out { + if args.json { + print_json(&e, &body); + } else { + print_pretty(&e, &body); + } + } + Ok(()) +} + +fn print_pretty(e: &IndexEntry, body: &str) { + let secs = e.timestamp_ms / 1000; + let ms = e.timestamp_ms % 1000; + let header = if e.unit == "-" { + format!("{}.{:03} [{}]", secs, ms, e.source) + } else { + format!("{}.{:03} [{}] {{{}}}", secs, ms, e.source, e.unit) + }; + println!("{header}"); + // Si es journald native (KEY=value lines), extraer MESSAGE. + if body.contains('=') && body.lines().any(|l| l.contains('=')) { + for line in body.lines() { + if let Some((k, v)) = line.split_once('=') { + if k.trim() == "MESSAGE" { + println!(" {v}"); + return; + } + } + } + } + for line in body.trim_end().lines() { + println!(" {line}"); + } +} + +fn print_json(e: &IndexEntry, body: &str) { + // JSON-lines básico, sin dependencia de serde — formato simple y estable. + let escaped_body = body + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + let unit_field = if e.unit == "-" { "null".to_string() } + else { format!("\"{}\"", e.unit) }; + println!( + r#"{{"timestamp_ms":{},"source":"{}","unit":{},"sha":"{}","body":"{}"}}"#, + e.timestamp_ms, e.source, unit_field, e.sha_hex, escaped_body + ); +} diff --git a/crates/ente-machined-compat/Cargo.toml b/crates/ente-machined-compat/Cargo.toml new file mode 100644 index 0000000..eb88b4a --- /dev/null +++ b/crates/ente-machined-compat/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ente-machined-compat" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "ente-machined-compat" +path = "src/main.rs" + +[dependencies] +ente-card = { path = "../ente-card" } +ente-bus = { path = "../ente-bus" } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +zbus = { version = "4", default-features = false, features = ["tokio"] } diff --git a/crates/ente-machined-compat/src/main.rs b/crates/ente-machined-compat/src/main.rs new file mode 100644 index 0000000..4ad7e00 --- /dev/null +++ b/crates/ente-machined-compat/src/main.rs @@ -0,0 +1,186 @@ +//! ente-machined-compat: shim de `org.freedesktop.machine1`. +//! +//! systemd-machined trackea VMs y containers (typically managed por systemd-nspawn). +//! En el fractal cada Ente con namespaces es candidato a "machine", pero la +//! correspondencia no es 1:1 — un Ente puede tener menos aislamiento que una +//! container completa. +//! +//! Este shim devuelve listas vacías para no romper clientes (gnome-boxes, +//! virt-manager, etc) que llaman a `ListMachines` durante boot. Métodos de +//! mutación (RegisterMachine, KillMachine) se aceptan como no-op con audit +//! log via tracing. +//! +//! Producción real: integrar con el graph del fractal — ListMachines query +//! BusRequest::ListEntes filtrado por `card.soma.namespaces.pid`. + +use ente_bus::{BusClient, BusRequest, BusResponse}; +use ente_card::Capability; +use std::collections::HashMap; +use tokio::signal::unix::{signal, SignalKind}; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; +use zbus::{fdo, interface, zvariant::OwnedValue}; + +const BUS_NAME: &str = "org.freedesktop.machine1"; +const OBJ_PATH: &str = "/org/freedesktop/machine1"; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + init_tracing(); + info!("ente-machined-compat: arrancando"); + announce_to_fractal().await; + + let manager = MachineManager; + let conn_result = zbus::connection::Builder::system() + .and_then(|b| b.name(BUS_NAME)) + .and_then(|b| b.serve_at(OBJ_PATH, manager)); + match conn_result { + Ok(builder) => match builder.build().await { + Ok(_conn) => { + info!(name = BUS_NAME, "name acquired, sirviendo"); + wait_for_term().await + } + Err(e) => { warn!(?e, "build conn falló — modo idle"); wait_for_term().await } + }, + Err(e) => { warn!(?e, "builder D-Bus falló — modo idle"); wait_for_term().await } + } +} + +struct MachineManager; + +/// Tipo del wire format de ListMachines: `(s, s, s, u, ay, ay, t, ay)` — +/// name, class, service, leader_pid, root_directory_path, id_unix, time_obtained, +/// machine_id_bytes. systemd usa este struct simplificado. +type Machine = (String, String, String, u32, String); + +#[interface(name = "org.freedesktop.machine1.Manager")] +impl MachineManager { + /// Lista vacía — no trackeamos containers todavía. + async fn list_machines(&self) -> fdo::Result> { + Ok(vec![]) + } + + /// Devuelve siempre NotFound — sin machines registradas. + async fn get_machine(&self, name: String) -> fdo::Result { + Err(fdo::Error::Failed(format!("machine '{name}' no encontrada"))) + } + + async fn get_machine_by_pid(&self, pid: u32) -> fdo::Result { + Err(fdo::Error::Failed(format!("PID {pid} no asociado a ninguna machine"))) + } + + async fn register_machine( + &self, + name: String, + _id: Vec, + _service: String, + class: String, + _leader_pid: u32, + _root_directory: String, + ) -> fdo::Result { + info!(%name, %class, "RegisterMachine (no-op)"); + Err(fdo::Error::NotSupported( + "RegisterMachine no implementado — usar Cards del fractal".into() + )) + } + + async fn register_machine_with_network( + &self, + name: String, + id: Vec, + service: String, + class: String, + leader_pid: u32, + root_directory: String, + _network_interfaces: Vec, + ) -> fdo::Result { + self.register_machine(name, id, service, class, leader_pid, root_directory).await + } + + async fn create_machine( + &self, + name: String, + _id: Vec, + _service: String, + class: String, + _leader_pid: u32, + _root_directory: String, + _scope_properties: Vec<(String, OwnedValue)>, + ) -> fdo::Result { + info!(%name, %class, "CreateMachine (no-op)"); + Err(fdo::Error::NotSupported( + "CreateMachine no implementado".into() + )) + } + + async fn terminate_machine(&self, name: String) -> fdo::Result<()> { + info!(%name, "TerminateMachine (no-op)"); + Ok(()) + } + + async fn kill_machine(&self, name: String, _who: String, _signal: i32) -> fdo::Result<()> { + info!(%name, "KillMachine (no-op)"); + Ok(()) + } + + async fn get_machine_address(&self, name: String) -> fdo::Result)>> { + warn!(%name, "GetMachineAddress (sin tracking, devuelvo vacío)"); + Ok(vec![]) + } + + async fn get_machine_osrelease(&self, name: String) -> fdo::Result> { + warn!(%name, "GetMachineOSRelease (sin tracking)"); + Ok(HashMap::new()) + } + + /// Operaciones sobre la "host machine" (PID 1 namespace) — siempre + /// disponibles. Usamos el path canónico `/org/freedesktop/machine1/machine/_host`. + async fn open_machine_login(&self, _name: String) -> fdo::Result<(zbus::zvariant::OwnedObjectPath, zbus::zvariant::OwnedFd)> { + Err(fdo::Error::NotSupported( + "OpenMachineLogin no implementado".into() + )) + } + + async fn open_machine_shell( + &self, + _name: String, + _user: String, + _path: String, + _args: Vec, + _environment: Vec, + ) -> fdo::Result<(zbus::zvariant::OwnedObjectPath, zbus::zvariant::OwnedFd)> { + Err(fdo::Error::NotSupported("OpenMachineShell no implementado".into())) + } +} + +async fn announce_to_fractal() { + if let Ok(mut client) = BusClient::from_env().await { + let req = BusRequest::Announce { + capabilities: vec![Capability::Endpoint { + interface: ente_card::InterfaceId([0xa5; 16]), + version: 1, + }], + }; + match client.call(req).await { + Ok(BusResponse::Ok) => info!("Announce → bus interno OK"), + Ok(other) => warn!(?other, "Announce respuesta inesperada"), + Err(e) => warn!(?e, "Announce falló"), + } + } +} + +async fn wait_for_term() -> anyhow::Result<()> { + let mut term = signal(SignalKind::terminate())?; + let mut int_ = signal(SignalKind::interrupt())?; + tokio::select! { + _ = term.recv() => info!("SIGTERM"), + _ = int_.recv() => info!("SIGINT"), + } + Ok(()) +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ente_machined_compat=info")); + tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init(); +} diff --git a/crates/ente-polkit-compat/src/main.rs b/crates/ente-polkit-compat/src/main.rs index 96fd8c1..53b411c 100644 --- a/crates/ente-polkit-compat/src/main.rs +++ b/crates/ente-polkit-compat/src/main.rs @@ -14,11 +14,11 @@ //! CheckAuthorization solicita un token al graph y devuelve true/false //! según el resultado. -use ente_bus::{BusClient, BusRequest, BusResponse}; +use ente_bus::{BusClient, BusRequest, BusResponse, POLKIT_DECISION_IFACE, POLKIT_SERVICE_IFACE}; use ente_card::Capability; use std::collections::HashMap; use tokio::signal::unix::{signal, SignalKind}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use tracing_subscriber::EnvFilter; use zbus::{fdo, interface, zvariant::OwnedValue}; @@ -73,9 +73,12 @@ impl PolkitAuthority { .and_then(|v| u32::try_from(v).ok()); let uid = subj_details.get("uid") .and_then(|v| u32::try_from(v).ok()); - info!(%action_id, %subj_kind, ?pid, ?uid, "CheckAuthorization → ALLOW"); - // Always-yes: fractal confía en todos sus Entes (auth real está en el bus). - Ok((true, false, HashMap::new())) + + // Pregunta al bus interno del fractal si hay un policy provider. + // Si lo hay, su decisión gobierna. Si no (NoProvider), default = allow. + let decision = query_policy(&action_id, pid, uid).await; + info!(%action_id, %subj_kind, ?pid, ?uid, ?decision, "CheckAuthorization"); + Ok((decision.allow, false, HashMap::new())) } async fn check_authorization_by_async( @@ -175,11 +178,62 @@ type EnumeratedAction = ( /// Aquí `(string)` * 5 + 2 timestamps. Simplificamos al subset relevante. type TemporaryAuth = (String, String, (String, HashMap), u64, u64); +/// Resultado de una consulta de policy al fractal. +#[derive(Debug)] +struct PolicyDecision { + allow: bool, + /// Origen: "fractal" si vino del bus, "default-allow" si no había proveedor. + source: &'static str, +} + +/// Pregunta al bus interno: ¿hay alguien que decida sobre `action_id`? +/// Wire format del blob: `pid_u32_be | uid_u32_be | action_id_utf8`. +/// El proveedor responde con `Invoked { result: [0|1] }` — 1 = allow. +async fn query_policy(action_id: &str, pid: Option, uid: Option) -> PolicyDecision { + let mut blob = Vec::with_capacity(8 + action_id.len()); + blob.extend_from_slice(&pid.unwrap_or(0).to_be_bytes()); + blob.extend_from_slice(&uid.unwrap_or(0).to_be_bytes()); + blob.extend_from_slice(action_id.as_bytes()); + + let mut client = match BusClient::from_env().await { + Ok(c) => c, + Err(e) => { + debug!(?e, "no bus client — default allow"); + return PolicyDecision { allow: true, source: "no-bus" }; + } + }; + let req = BusRequest::Invoke { + cap: Capability::Endpoint { + interface: POLKIT_DECISION_IFACE, + version: 1, + }, + blob, + }; + match client.call(req).await { + Ok(BusResponse::Invoked { result }) => { + let allow = result.first().copied().unwrap_or(1) != 0; + PolicyDecision { allow, source: "fractal" } + } + Ok(BusResponse::Error(msg)) if msg.contains("sin proveedor") => { + // No hay policy provider — default allow. + PolicyDecision { allow: true, source: "default-allow" } + } + Ok(other) => { + warn!(?other, "policy: respuesta inesperada — default allow"); + PolicyDecision { allow: true, source: "default-allow" } + } + Err(e) => { + warn!(?e, "policy: bus call falló — default allow"); + PolicyDecision { allow: true, source: "default-allow" } + } + } +} + async fn announce_to_fractal() { if let Ok(mut client) = BusClient::from_env().await { let req = BusRequest::Announce { capabilities: vec![Capability::Endpoint { - interface: ente_card::InterfaceId([0xa4; 16]), + interface: POLKIT_SERVICE_IFACE, version: 1, }], }; diff --git a/crates/ente-zero/src/seed.rs b/crates/ente-zero/src/seed.rs index 06365ab..f424982 100644 --- a/crates/ente-zero/src/seed.rs +++ b/crates/ente-zero/src/seed.rs @@ -155,6 +155,7 @@ fn synthesize_dev_seed() -> EntityCard { ("compat-journald", "target/debug/ente-journald-compat"), ("compat-resolved", "target/debug/ente-resolved-compat"), ("compat-polkit", "target/debug/ente-polkit-compat"), + ("compat-machined", "target/debug/ente-machined-compat"), ] { if let Some(card) = optional_native_card( label, bin, diff --git a/docs/gnome-boot-test.md b/docs/gnome-boot-test.md new file mode 100644 index 0000000..bb04f06 --- /dev/null +++ b/docs/gnome-boot-test.md @@ -0,0 +1,164 @@ +# Boot test: GNOME bajo Ente #0 (sin systemd) + +Procedimiento para verificar que GNOME 45+ arranca con `ente-zero` como +PID 1 y los 8 D-Bus shims (logind, hostnamed, timedated, localed, +journald, resolved, polkit, machined) cubren las llamadas que el +escritorio realiza durante la sesión. + +## Qué se prueba + +1. **PID 1 boot**: el kernel arranca, mounta initramfs, transfiere control + a `/init` que es `ente-zero` con la Card Semilla. +2. **Genesis spawning**: ente-zero encarna el bus D-Bus (dbus-daemon), + los 8 compat-shims, NetworkManager, y gdm/sddm (display manager). +3. **D-Bus interception**: los compat-shims toman los nombres + `org.freedesktop.{logind,hostname1,timedate1,locale1,resolve1, + PolicyKit1,machine1}` antes que cualquier sondeo de systemd. +4. **GNOME session**: gdm autenticación → GNOME shell → settings panels + funcionan (Region & Language, Date & Time, About, Network). + +## Prerequisitos + +- QEMU (`qemu-system-x86_64`) con KVM. +- Imagen base con kernel + GNOME instalados — sugerencia: una Arch Linux + o Fedora minimal modificada (más abajo). NixOS también es razonable. +- ~10 GiB libre. + +## Esqueleto del rootfs + +`scripts/build-rootfs.sh` genera un overlay sobre una imagen base que: + +1. Compila el workspace en modo release (`cargo build --workspace --release`) +2. Copia los binarios a `/usr/local/bin/`: + - `ente-zero` + - `ente-{logind,hostnamed,timedated,localed,journald,resolved,polkit,machined}-compat` + - `ente-echo`, `brainctl`, `busctl`, `ente-journalctl` +3. Renombra el `/init` original a `/sbin/init.systemd` (backup) +4. Symlink `/init` → `/usr/local/bin/ente-zero` +5. Coloca la Card Semilla en `/ente/seed.card.k` +6. Desinstala los services systemd que ahora son shims (logind, etc) o + los enmascara con `systemctl mask` (en la imagen base, antes de + reescribir `/init`) + +## Card Semilla para el boot test + +`/ente/seed.card.k` debe declarar como genesis los Entes esenciales: +- D-Bus daemon (`/usr/bin/dbus-daemon --system`) +- Los 8 compat-shims +- NetworkManager +- udev daemon (kernel events ya manejados por nuestro Netlink stream; + udev añade reglas de userspace — opcional) +- gdm o sddm + +Ejemplo mínimo: + +```kcl +import .card + +seed = EntityCard { + schema_version = 1 + id = "01KQ_BOOT_SEED_GNOME_TEST_0" + label = "boot-gnome-test" + provides = [ + Capability {kind = "Spawn"} + Capability {kind = "Journal"} + ] + soma = SomaSpec {} + payload = Payload {kind = "Virtual"} + supervision = Supervision {kind = "OneShot"} + genesis = [ + # dbus-daemon — todo lo demás depende de él. + EntityCard { + schema_version = 1 + id = "01KQ_BOOT_DBUS_DAEMON__________" + label = "dbus-daemon" + soma = SomaSpec {} + payload = Payload { + kind = "Native" + exec = "/usr/bin/dbus-daemon" + argv = ["--system", "--nofork"] + } + supervision = Supervision { + kind = "Restart" + initial_ms = 100 + max_ms = 30000 + } + } + # Aquí los 8 compat-shims (mismo patrón) ... + # Aquí gdm o sddm ... + ] +} +``` + +## Boot + +```bash +scripts/run-vm.sh /path/to/base.qcow2 /path/to/built-rootfs.iso +``` + +QEMU arranca con: +- `-kernel` el kernel de la imagen base +- `-initrd` (opcional) si la imagen no es bootable directa +- `-append "init=/usr/local/bin/ente-zero rd.break=initqueue"` para + redirigir PID 1 desde el initramfs. +- `-display gtk,gl=on` para ver la sesión gráfica. + +## Verificaciones + +### Console +- ente-zero arranca como PID 1 (`/proc/1/comm` == `ente-zero`). +- Genesis instancia los 8 shims: logs `Ente encarnado label=compat-X`. +- D-Bus daemon responde: `busctl list` muestra los nombres tomados por + los shims (deberían aparecer como `org.freedesktop.X1`). + +### gdm +- Llega al greeter — significa que polkit, logind, hostnamed responden. +- Login funciona — verifica que `Inhibit`, `CreateSession`, `ListSessions` + no bloquean la pila de PAM. + +### GNOME shell +- Settings → About: hostname y OS info pobladas (hostnamed properties). +- Settings → Date & Time: timezone correcta (timedated). +- Settings → Region & Language: locale actual visible (localed). +- Settings → Network: NetworkManager opera, los DNS lookups funcionan + (resolved). +- "Restart" en menú: invoca `org.freedesktop.login1.Reboot` → llega a + nuestro stub → log en journal. + +### Journal +- `ente-journalctl --tail 100` muestra mensajes capturados durante el boot. +- Filtros `--unit gdm.service` y `--source syslog` funcionan. + +## Limitaciones conocidas + +- **systemd-units**: GNOME no usa systemd unit files directamente, pero + algunas apps los crean dinámicamente (e.g., gnome-terminal con + `systemd-run --user`). Esas calls fallarán con NotSupported y la app + caerá a un fallback no-systemd. +- **sd_notify**: services que usan `Type=notify` para anunciar readiness + fallan silenciosamente (NOTIFY_SOCKET no se setea). Reemplazables con + `Type=simple` y nuestro Supervision::Restart. +- **systemd-tmpfiles**: tmpfiles.d no se procesa. Aplicar manualmente + o portar el comportamiento a un Ente. +- **systemd journal binary export**: nuestro journal usa CAS por hash, + no el formato `.journal` binario. Apps que llamen + `journalctl --output=export` no son compatibles. `ente-journalctl` + cubre los casos típicos. + +## Plan de regresión + +Hacer boot test en CI tras cada cambio en los compat-shims: + +1. `cargo build --workspace --release` +2. `scripts/build-rootfs.sh` (overlay sobre imagen base inmutable) +3. `scripts/run-vm.sh --headless --timeout 120` con script de comprobación + automatizado (xdotool o similar, o screen-shotting). +4. Capturar `dmesg` + `ente-journalctl` outputs como artefactos. + +Output esperado en stdout del test: +``` +PASS: ente-zero como PID 1 +PASS: 8 compat-shims encarnados +PASS: gdm greeter mostrado +PASS: settings panels operativos +``` diff --git a/docs/seed-gnome-test.k b/docs/seed-gnome-test.k new file mode 100644 index 0000000..b68d1ab --- /dev/null +++ b/docs/seed-gnome-test.k @@ -0,0 +1,155 @@ +# Card Semilla para el boot test de GNOME bajo Ente #0. +# +# Este archivo se valida con `kcl run` contra el schema en +# crates/ente-card/schema/card.k antes de que ente-zero lo cargue. +# +# Genesis declara la constelación mínima para que GNOME arranque sin +# systemd: D-Bus daemon, los 8 compat-shims, NetworkManager, gdm. + +import .ente_card.schema.card + +# Card "supervisor genérico" reutilizable — dispara un binario con Restart. +schema NativeRestart(EnteBase): + soma = SomaSpec { + rlimits = ResourceLimits {nofile = 16384} + } + supervision = Supervision { + kind = "Restart" + initial_ms = 100 + max_ms = 30000 + } + + +# ----- La Semilla ----- + +seed = EntityCard { + schema_version = 1 + id = "01KQABOOTTESTSEEDFRACTAL00" + label = "boot-gnome-test" + provides = [ + Capability {kind = "Spawn"} + Capability {kind = "Journal"} + ] + soma = SomaSpec {} + payload = Payload {kind = "Virtual"} + supervision = Supervision {kind = "OneShot"} + + genesis = [ + # 1. dbus-daemon — pivote del system bus, todos los demás dependen de él. + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTDBUSDAEMON___" + label = "dbus-daemon" + soma = SomaSpec {} + payload = Payload { + kind = "Native" + exec = "/usr/bin/dbus-daemon" + argv = ["--system", "--nofork", "--nopidfile"] + } + supervision = Supervision { + kind = "Restart" + initial_ms = 100 + max_ms = 30000 + } + } + + # 2-9. Los 8 compat-shims D-Bus. + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTLOGIND_______" + label = "compat-logind" + provides = [Capability {kind = "LegacyLogind"}] + soma = SomaSpec {} + payload = Payload { + kind = "Native" + exec = "/usr/local/bin/ente-logind-compat" + } + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTHOSTNAMED____" + label = "compat-hostnamed" + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-hostnamed-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTTIMEDATED____" + label = "compat-timedated" + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-timedated-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTLOCALED______" + label = "compat-localed" + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-localed-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTJOURNALD_____" + label = "compat-journald" + provides = [Capability {kind = "Journal"}] + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-journald-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTRESOLVED_____" + label = "compat-resolved" + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-resolved-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTPOLKIT_______" + label = "compat-polkit" + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-polkit-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTMACHINED_____" + label = "compat-machined" + soma = SomaSpec {} + payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-machined-compat"} + supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} + } + + # 10. NetworkManager — la mayoría de distros lo prefieren sobre networkd. + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTNETWORKMGR___" + label = "NetworkManager" + soma = SomaSpec {} + payload = Payload { + kind = "Native" + exec = "/usr/sbin/NetworkManager" + argv = ["--no-daemon"] + } + supervision = Supervision {kind = "Restart", initial_ms = 200, max_ms = 30000} + } + + # 11. gdm — display manager. GNOME settings panels via gnome-shell. + EntityCard { + schema_version = 1 + id = "01KQABOOTTESTGDMDAEMON____" + label = "gdm" + soma = SomaSpec {} + payload = Payload { + kind = "Native" + exec = "/usr/bin/gdm" + argv = ["--no-daemon"] + } + supervision = Supervision {kind = "Restart", initial_ms = 500, max_ms = 60000} + } + ] +} diff --git a/scripts/build-rootfs.sh b/scripts/build-rootfs.sh new file mode 100755 index 0000000..a99bc96 --- /dev/null +++ b/scripts/build-rootfs.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# build-rootfs.sh — overlay del workspace sobre una imagen base. +# +# Inputs: +# $1 — path al rootfs (directorio montado o chroot pre-existente) +# $2 — path opcional a Card Semilla custom (.k o .json) +# +# Output: el rootfs queda con /init → ente-zero, binarios en +# /usr/local/bin, y la Semilla en /ente/seed.card.k. + +set -euo pipefail + +ROOTFS="${1:-}" +SEED_CARD="${2:-}" + +if [[ -z "$ROOTFS" || ! -d "$ROOTFS" ]]; then + echo "Uso: $0 [seed-card-path]" >&2 + exit 1 +fi + +WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)" +echo "Workspace: $WORKSPACE" +echo "Rootfs: $ROOTFS" + +# 1. Build release +echo "==> cargo build --workspace --release" +cd "$WORKSPACE" +cargo build --workspace --release + +# 2. Copia binarios +echo "==> copiando binarios a $ROOTFS/usr/local/bin/" +mkdir -p "$ROOTFS/usr/local/bin" +BINS=( + ente-zero + ente-logind-compat + ente-hostnamed-compat + ente-timedated-compat + ente-localed-compat + ente-journald-compat + ente-resolved-compat + ente-polkit-compat + ente-machined-compat + ente-echo + ente-journalctl +) +for bin in "${BINS[@]}"; do + src="target/release/$bin" + if [[ ! -x "$src" ]]; then + echo "WARN: $src no existe, skip" >&2 + continue + fi + cp "$src" "$ROOTFS/usr/local/bin/" + echo " + $bin" +done + +# brainctl viene del example +if [[ -x "target/release/examples/brainctl" ]]; then + cp "target/release/examples/brainctl" "$ROOTFS/usr/local/bin/" + echo " + brainctl (example)" +fi +if [[ -x "target/release/examples/busctl" ]]; then + cp "target/release/examples/busctl" "$ROOTFS/usr/local/bin/ente-busctl" + echo " + ente-busctl (example, renombrado para no chocar con systemd-busctl)" +fi + +# 3. Backup del init original +if [[ -e "$ROOTFS/sbin/init" && ! -L "$ROOTFS/sbin/init" ]]; then + if [[ ! -e "$ROOTFS/sbin/init.systemd" ]]; then + echo "==> backup /sbin/init → /sbin/init.systemd" + mv "$ROOTFS/sbin/init" "$ROOTFS/sbin/init.systemd" + fi +fi + +# 4. /init y /sbin/init apuntan a ente-zero +echo "==> /init → ente-zero" +ln -sf /usr/local/bin/ente-zero "$ROOTFS/init" +ln -sf /usr/local/bin/ente-zero "$ROOTFS/sbin/init" + +# 5. Card Semilla +mkdir -p "$ROOTFS/ente" +if [[ -n "$SEED_CARD" && -f "$SEED_CARD" ]]; then + cp "$SEED_CARD" "$ROOTFS/ente/seed.card.k" + echo "==> Semilla custom: $SEED_CARD" +else + cp "$WORKSPACE/docs/seed-gnome-test.k" "$ROOTFS/ente/seed.card.k" 2>/dev/null \ + || echo "WARN: docs/seed-gnome-test.k no existe; ente-zero sintetizará dev seed" +fi + +# 6. Mascara servicios systemd que vamos a sustituir +echo "==> systemctl mask de servicios sustituidos (si systemd presente)" +chroot "$ROOTFS" systemctl mask \ + systemd-logind.service \ + systemd-hostnamed.service \ + systemd-timedated.service \ + systemd-localed.service \ + systemd-journald.service \ + systemd-resolved.service \ + systemd-machined.service \ + polkit.service \ + 2>/dev/null || echo " (chroot mask falló — OK si la imagen no tiene systemd)" + +echo "==> rootfs listo en $ROOTFS" +echo +echo "Próximo paso:" +echo " scripts/run-vm.sh " diff --git a/scripts/run-vm.sh b/scripts/run-vm.sh new file mode 100755 index 0000000..c6058a2 --- /dev/null +++ b/scripts/run-vm.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# run-vm.sh — boot de la imagen rootfs en QEMU con KVM. +# +# Inputs: +# $1 — imagen disco (qcow2 o raw) con el rootfs ya parchado +# $@ — args adicionales a qemu +# +# Default: 4 GiB RAM, 2 vCPU, virtio-net, OpenGL para GNOME shell. + +set -euo pipefail + +DISK="${1:-}" +shift || true + +if [[ -z "$DISK" || ! -f "$DISK" ]]; then + echo "Uso: $0 [args extra de qemu]" >&2 + echo "Genera la imagen con scripts/build-rootfs.sh primero." >&2 + exit 1 +fi + +# Boot config: redirige init a ente-zero. Usa los flags que el initramfs +# de la imagen base entienda — `init=` es universal para GRUB-equivalentes. +KERNEL_CMDLINE="root=/dev/vda1 rw console=ttyS0,115200 init=/usr/local/bin/ente-zero RUST_LOG=info" + +# Config QEMU. -display gtk con OpenGL para GNOME shell. -display none +# para CI/headless (el test verifica solamente boot via console). +DISPLAY_FLAGS="${ENTE_VM_DISPLAY:--display gtk,gl=on}" + +exec qemu-system-x86_64 \ + -enable-kvm \ + -cpu host \ + -smp 2 \ + -m 4G \ + -drive "file=$DISK,if=virtio" \ + -netdev user,id=net0 \ + -device virtio-net-pci,netdev=net0 \ + -serial mon:stdio \ + $DISPLAY_FLAGS \ + -append "$KERNEL_CMDLINE" \ + "$@"