From 762bf95dfd770cc8bf51f21605bc2cc6cc88ea76 Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 22 May 2026 00:40:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(arje):=20arje-absorb=20=E2=80=94=20absorbe?= =?UTF-8?q?=20otros=20inits=20a=20una=20Semilla=20brahman?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo crate `crates/init/arje-absorb`: lee la configuración de un init clásico y la traduce a una Tarjeta Semilla (Card JSON) con cada servicio como hija genesis de arje-zero. El paso «absorber» de la migración a arje — para no perder los servicios al cambiar de init. - Absorbers: sysvinit (/etc/inittab), runit (runsvdir o /etc/sv), dinit (/etc/dinit.d), openrc (/etc/runlevels). Autodetección. - Modelo intermedio ForeignService → Card vía brahman-card (validado). - `--with-carmen`: agrega carmen-dm (gestor de login gráfico). - CLI: --from/--root/--output/--label/--with-carmen. 24 tests, clippy limpio. `scripts/migrate-to-arje.sh`: orquesta absorber → validar → (carmen: compila+instala mirada dinámico) → install-arje-as-init.sh. El init viejo queda intacto; arje se elige en GRUB. --dry-run no toca nada. systemd no se absorbe (units no son texto trivial) — para systemd sigue la capa de shims + seeds/arje-host.card.json. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 9 ++ Cargo.toml | 1 + crates/init/arje-absorb/Cargo.toml | 18 +++ crates/init/arje-absorb/README.md | 51 +++++++ crates/init/arje-absorb/src/card.rs | 193 ++++++++++++++++++++++++ crates/init/arje-absorb/src/dinit.rs | 105 +++++++++++++ crates/init/arje-absorb/src/main.rs | 174 +++++++++++++++++++++ crates/init/arje-absorb/src/model.rs | 103 +++++++++++++ crates/init/arje-absorb/src/openrc.rs | 86 +++++++++++ crates/init/arje-absorb/src/runit.rs | 111 ++++++++++++++ crates/init/arje-absorb/src/sysvinit.rs | 96 ++++++++++++ scripts/migrate-to-arje.sh | 116 ++++++++++++++ 12 files changed, 1063 insertions(+) create mode 100644 crates/init/arje-absorb/Cargo.toml create mode 100644 crates/init/arje-absorb/README.md create mode 100644 crates/init/arje-absorb/src/card.rs create mode 100644 crates/init/arje-absorb/src/dinit.rs create mode 100644 crates/init/arje-absorb/src/main.rs create mode 100644 crates/init/arje-absorb/src/model.rs create mode 100644 crates/init/arje-absorb/src/openrc.rs create mode 100644 crates/init/arje-absorb/src/runit.rs create mode 100644 crates/init/arje-absorb/src/sysvinit.rs create mode 100755 scripts/migrate-to-arje.sh diff --git a/Cargo.lock b/Cargo.lock index 2c439fa..30aaeef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,6 +342,15 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arje-absorb" +version = "0.0.1" +dependencies = [ + "anyhow", + "brahman-card", + "tempfile", +] + [[package]] name = "arje-binfmt-compat" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index afb1d65..aa06ebd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/init/arje-soma", "crates/init/arje-snapshot", "crates/init/arje-incarnate", + "crates/init/arje-absorb", # ============================================================ # runtime/ — Infraestructura de ejecución (bus + cas + wasm + brain) diff --git a/crates/init/arje-absorb/Cargo.toml b/crates/init/arje-absorb/Cargo.toml new file mode 100644 index 0000000..5212758 --- /dev/null +++ b/crates/init/arje-absorb/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "arje-absorb" +version = "0.0.1" +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "arje-absorb — lee la configuración de otro init (sysvinit/runit/dinit/openrc) y la traduce a una Tarjeta Semilla brahman." + +[[bin]] +name = "arje-absorb" +path = "src/main.rs" + +[dependencies] +brahman-card = { path = "../../protocol/brahman-card" } +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/init/arje-absorb/README.md b/crates/init/arje-absorb/README.md new file mode 100644 index 0000000..d01fdba --- /dev/null +++ b/crates/init/arje-absorb/README.md @@ -0,0 +1,51 @@ +# arje-absorb + +Absorbe la configuración de **otro init** y la traduce a una **Tarjeta +Semilla** brahman: una `Card` JSON con cada servicio como hija `genesis` +de `arje-zero`. + +Es el paso «absorber» de la migración a arje — para no perder los +servicios del sistema al cambiar de init. Lo usa `scripts/migrate-to-arje.sh`. + +## Inits soportados + +| init | qué lee | servicio → Card | +| ---------- | ----------------------------------------- | --------------- | +| `sysvinit` | `/etc/inittab` | `respawn` → daemon supervisado; `wait`/`once`/`sysinit` → one-shot | +| `runit` | el `runsvdir` activo (o `/etc/sv`) | cada script `run` → daemon supervisado (calce 1:1) | +| `dinit` | `/etc/dinit.d/*` | `type=process`/`bgprocess` → daemon; `scripted` → one-shot | +| `openrc` | `/etc/runlevels/{sysinit,boot,default}` | `/etc/init.d/ start` → one-shot | + +`systemd` no se absorbe — sus units no son un formato de texto trivial. +Para systemd ya existe la capa de shims (`crates/compat/`) y la seed +`seeds/arje-host.card.json`. + +## Uso + +```sh +# autodetecta el init y emite la Semilla a stdout +arje-absorb + +# explícito, a un archivo, agregando el gestor de login gráfico +arje-absorb --from openrc --output /ente/seed.card.json --with-carmen + +# absorber otra raíz (un chroot, una imagen montada) +arje-absorb --root /mnt/sistema --from runit +``` + +Opciones: `--from ` (def. `auto`), `--root ` (def. `/`), +`--output ` (def. `-` = stdout), `--label `, `--with-carmen`. + +## Lo que NO hace + +La Semilla absorbida **conserva el comportamiento** del init viejo: los +servicios corren sin aislar (namespaces en `false`, FS read-write, red +`full`). No endurece el sandbox — eso se hace después, Card por Card. + +La absorción de OpenRC es **superficial**: envuelve `/etc/init.d/ +start` como one-shot (los scripts de OpenRC son shell completo, no se +parsean — el daemon lo deja en segundo plano OpenRC mismo, no arje). +runit y dinit, en cambio, dan supervisión real porque su `run`/`command` +es un proceso en primer plano. + +Revisá la Semilla antes de instalarla como `/ente/seed.card.json`. diff --git a/crates/init/arje-absorb/src/card.rs b/crates/init/arje-absorb/src/card.rs new file mode 100644 index 0000000..f644a5a --- /dev/null +++ b/crates/init/arje-absorb/src/card.rs @@ -0,0 +1,193 @@ +//! Traducción del modelo intermedio a Tarjetas Semilla brahman. +//! +//! Cada [`ForeignService`] se vuelve una Card hija; el conjunto cuelga +//! como `genesis` de una Card raíz que `arje-zero` encarna al arrancar. + +use std::time::Duration; + +use brahman_card::{ + Capability, Card, CgroupSpec, FsPolicy, Lifecycle, NetworkingPolicy, Payload, Permissions, + Priority, SomaSpec, Supervision, +}; + +use crate::model::{ForeignService, ServiceKind}; + +/// Convierte un servicio absorbido en una Card hija (genesis child). +fn service_to_card(svc: &ForeignService) -> Card { + let (lifecycle, supervision) = match svc.kind { + ServiceKind::Daemon => ( + Lifecycle::Daemon, + Supervision::Restart { + initial: Duration::from_millis(1_000), + max: Duration::from_millis(60_000), + }, + ), + ServiceKind::OneShot => (Lifecycle::Oneshot, Supervision::OneShot), + }; + Card { + payload: Payload::Native { + exec: svc.exec.clone(), + argv: svc.argv.clone(), + envp: svc.env.clone(), + }, + supervision, + lifecycle, + priority: Priority::Normal, + // Servicio de sistema absorbido: sin aislar (namespaces en + // `false` por defecto), con FS de escritura, red y subprocesos. + // La migración conserva el comportamiento; endurecer el sandbox + // queda como trabajo posterior, Card por Card. + permissions: Permissions { + filesystem: FsPolicy::ReadWrite, + networking: NetworkingPolicy::Full, + processes: true, + ..Permissions::default() + }, + soma: SomaSpec { + cgroup: CgroupSpec { + path: "ente.slice/absorbed".to_string(), + ..CgroupSpec::default() + }, + ..SomaSpec::default() + }, + ..Card::new(svc.name.clone()) + } +} + +/// La Card de `carmen-dm`: el compositor `mirada` en modo greeter, +/// como gestor de login gráfico. Para agregar a una Semilla absorbida +/// al migrar un escritorio (flag `--with-carmen`). Idéntica a la +/// entrada `carmen-dm` de `seeds/arje-host.card.json`. +pub fn carmen_dm_card() -> Card { + Card { + payload: Payload::Native { + exec: "/usr/bin/mirada-compositor".to_string(), + argv: vec!["--greeter".to_string(), "--drm".to_string()], + envp: vec![ + ( + "PATH".to_string(), + "/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin".to_string(), + ), + ("XDG_RUNTIME_DIR".to_string(), "/run".to_string()), + ( + "MIRADA_GREETER_BIN".to_string(), + "/usr/bin/mirada-greeter".to_string(), + ), + ], + }, + supervision: Supervision::Restart { + initial: Duration::from_millis(2_000), + max: Duration::from_millis(60_000), + }, + lifecycle: Lifecycle::Daemon, + priority: Priority::High, + permissions: Permissions { + filesystem: FsPolicy::ReadWrite, + networking: NetworkingPolicy::None, + processes: true, + ..Permissions::default() + }, + soma: SomaSpec { + cgroup: CgroupSpec { + path: "ente.slice/carmen".to_string(), + ..CgroupSpec::default() + }, + ..SomaSpec::default() + }, + ..Card::new("carmen-dm") + } +} + +/// Arma una Tarjeta Semilla raíz que encarna todos los servicios +/// absorbidos como hijas `genesis` de `arje-zero`. +pub fn build_seed(label: &str, services: &[ForeignService]) -> Card { + let genesis: Vec = services.iter().map(service_to_card).collect(); + Card { + provides: [Capability::Spawn, Capability::Journal] + .into_iter() + .collect(), + permissions: Permissions { + filesystem: FsPolicy::ReadWrite, + networking: NetworkingPolicy::Full, + processes: true, + ..Permissions::default() + }, + genesis, + ..Card::new(label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn svc(name: &str, kind: ServiceKind) -> ForeignService { + ForeignService { + name: name.to_string(), + exec: "/usr/bin/foo".to_string(), + argv: vec!["-x".to_string()], + env: Vec::new(), + kind, + } + } + + #[test] + fn seed_validates() { + let seed = build_seed( + "arje.seed.absorbed", + &[svc("a", ServiceKind::Daemon), svc("b", ServiceKind::OneShot)], + ); + seed.validate().expect("la Semilla absorbida debe validar"); + assert_eq!(seed.genesis.len(), 2); + } + + #[test] + fn daemon_maps_to_restart() { + let c = service_to_card(&svc("d", ServiceKind::Daemon)); + assert!(matches!(c.supervision, Supervision::Restart { .. })); + assert_eq!(c.lifecycle, Lifecycle::Daemon); + } + + #[test] + fn oneshot_maps_to_oneshot() { + let c = service_to_card(&svc("o", ServiceKind::OneShot)); + assert!(matches!(c.supervision, Supervision::OneShot)); + assert_eq!(c.lifecycle, Lifecycle::Oneshot); + } + + #[test] + fn children_get_distinct_ids() { + let seed = build_seed( + "x", + &[svc("a", ServiceKind::Daemon), svc("b", ServiceKind::Daemon)], + ); + assert_ne!(seed.genesis[0].id, seed.genesis[1].id); + } + + #[test] + fn empty_service_list_still_validates() { + let seed = build_seed("arje.seed.absorbed", &[]); + seed.validate().expect("una Semilla sin hijas es válida"); + } + + #[test] + fn carmen_card_is_valid_greeter() { + let c = carmen_dm_card(); + c.validate().expect("la Card de carmen-dm debe validar"); + match &c.payload { + brahman_card::Payload::Native { exec, argv, .. } => { + assert_eq!(exec, "/usr/bin/mirada-compositor"); + assert!(argv.contains(&"--greeter".to_string())); + } + _ => panic!("carmen-dm debe ser un payload Native"), + } + } + + #[test] + fn carmen_can_be_appended_to_absorbed_seed() { + let mut seed = build_seed("arje.seed.absorbed", &[svc("a", ServiceKind::Daemon)]); + seed.genesis.push(carmen_dm_card()); + seed.validate().expect("la Semilla con carmen debe validar"); + assert_eq!(seed.genesis.len(), 2); + } +} diff --git a/crates/init/arje-absorb/src/dinit.rs b/crates/init/arje-absorb/src/dinit.rs new file mode 100644 index 0000000..985c9e0 --- /dev/null +++ b/crates/init/arje-absorb/src/dinit.rs @@ -0,0 +1,105 @@ +//! Absorbe **dinit**: parsea los archivos de `/etc/dinit.d`. + +use std::fs; +use std::path::Path; + +use crate::model::{split_command, ForeignService, ServiceKind}; + +/// Descubre los servicios dinit de `/etc/dinit.d`. +/// +/// Cada archivo es un servicio con líneas `clave = valor`. Absorbemos +/// los de `type` = `process`/`bgprocess` (daemon) y `scripted` +/// (one-shot) que declaren un `command`. `internal`/`triggered` no +/// encarnan un proceso y se omiten — entre ellos el servicio `boot`. +pub fn absorb(root: &Path) -> anyhow::Result> { + let dir = root.join("etc/dinit.d"); + anyhow::ensure!( + dir.is_dir(), + "no encontré /etc/dinit.d en {}", + root.display() + ); + let mut names: Vec = fs::read_dir(&dir)? + .flatten() + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.')) + .collect(); + names.sort(); + + let mut out = Vec::new(); + for name in names { + let text = fs::read_to_string(dir.join(&name))?; + if let Some(svc) = parse_service(&name, &text) { + out.push(svc); + } + } + Ok(out) +} + +/// Parsea un archivo de servicio dinit. `None` si no encarna un proceso. +fn parse_service(name: &str, text: &str) -> Option { + let mut ty = String::new(); + let mut command = String::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, val)) = line.split_once('=') else { + continue; + }; + match key.trim() { + "type" => ty = val.trim().to_string(), + "command" => command = val.trim().to_string(), + _ => {} + } + } + let kind = match ty.as_str() { + "process" | "bgprocess" => ServiceKind::Daemon, + "scripted" => ServiceKind::OneShot, + _ => return None, // internal, triggered, o sin `type` + }; + let (exec, argv) = split_command(&command)?; + Some(ForeignService { + name: format!("dinit-{name}"), + exec, + argv, + env: Vec::new(), + kind, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absorbs_process_and_scripted_skips_internal() { + let tmp = tempfile::tempdir().unwrap(); + let d = tmp.path().join("etc/dinit.d"); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("boot"), "type = internal\nwaits-for = sshd\n").unwrap(); + fs::write( + d.join("sshd"), + "# servicio ssh\ntype = process\ncommand = /usr/sbin/sshd -D\nrestart = true\n", + ) + .unwrap(); + fs::write(d.join("fsck"), "type = scripted\ncommand = /sbin/fsck -A\n").unwrap(); + + let svcs = absorb(tmp.path()).unwrap(); + // sshd + fsck — boot (internal) se omite. + assert_eq!(svcs.len(), 2); + let sshd = svcs.iter().find(|s| s.name == "dinit-sshd").unwrap(); + assert_eq!(sshd.kind, ServiceKind::Daemon); + assert_eq!(sshd.exec, "/usr/sbin/sshd"); + assert_eq!(sshd.argv, ["-D"]); + let fsck = svcs.iter().find(|s| s.name == "dinit-fsck").unwrap(); + assert_eq!(fsck.kind, ServiceKind::OneShot); + } + + #[test] + fn errors_without_dinit_d() { + let tmp = tempfile::tempdir().unwrap(); + assert!(absorb(tmp.path()).is_err()); + } +} diff --git a/crates/init/arje-absorb/src/main.rs b/crates/init/arje-absorb/src/main.rs new file mode 100644 index 0000000..65aa026 --- /dev/null +++ b/crates/init/arje-absorb/src/main.rs @@ -0,0 +1,174 @@ +//! `arje-absorb` — traduce la configuración de otro init a una Semilla. +//! +//! Lee la configuración de un init clásico (sysvinit, runit, dinit, +//! OpenRC) y emite una Tarjeta Semilla brahman (`Card` JSON) con cada +//! servicio como hija `genesis` de `arje-zero`. Es el paso «absorber» +//! de la migración: `scripts/migrate-to-arje.sh` lo usa para no perder +//! los servicios del sistema al cambiar de init. +//! +//! La Semilla resultante NO endurece el sandbox — conserva el +//! comportamiento del init viejo. Revisala antes de instalarla. + +use std::path::{Path, PathBuf}; +use std::process::exit; + +mod card; +mod dinit; +mod model; +mod openrc; +mod runit; +mod sysvinit; + +const HELP: &str = "\ +arje-absorb — absorbe la configuración de otro init en una Semilla brahman. + +USO: + arje-absorb [--from ] [--root ] [--output ] [--label ] + +OPCIONES: + --from sysvinit | runit | dinit | openrc | auto (def: auto) + --root raíz del sistema a leer (def: /) + --output archivo de salida, o '-' para stdout (def: -) + --label label de la Semilla raíz (def: arje.seed.absorbed) + --with-carmen agrega carmen-dm (gestor de login gráfico) a la Semilla + -h, --help esta ayuda + +Emite una Tarjeta Semilla con cada servicio del init ajeno como hija +genesis de arje-zero. Revisala antes de instalarla como +/ente/seed.card.json."; + +fn main() { + if let Err(e) = run() { + eprintln!("arje-absorb: error: {e:#}"); + exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let mut from = "auto".to_string(); + let mut root = PathBuf::from("/"); + let mut output = "-".to_string(); + let mut label = "arje.seed.absorbed".to_string(); + let mut with_carmen = false; + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => { + println!("{HELP}"); + return Ok(()); + } + "--from" => { + from = args.next().ok_or_else(|| anyhow::anyhow!("--from necesita un valor"))? + } + "--root" => { + root = PathBuf::from( + args.next().ok_or_else(|| anyhow::anyhow!("--root necesita un valor"))?, + ) + } + "--output" => { + output = + args.next().ok_or_else(|| anyhow::anyhow!("--output necesita un valor"))? + } + "--label" => { + label = + args.next().ok_or_else(|| anyhow::anyhow!("--label necesita un valor"))? + } + "--with-carmen" => with_carmen = true, + other => anyhow::bail!("opción desconocida «{other}» (usá --help)"), + } + } + + let init = if from == "auto" { + detect(&root).ok_or_else(|| { + anyhow::anyhow!( + "no pude autodetectar el init en {} — pasá --from ", + root.display() + ) + })? + } else { + from.clone() + }; + + let services = match init.as_str() { + "sysvinit" => sysvinit::absorb(&root)?, + "runit" => runit::absorb(&root)?, + "dinit" => dinit::absorb(&root)?, + "openrc" => openrc::absorb(&root)?, + other => anyhow::bail!( + "init «{other}» no soportado (sysvinit | runit | dinit | openrc | auto)" + ), + }; + + eprintln!( + "arje-absorb: init «{init}» → {} servicio(s) absorbido(s).", + services.len() + ); + if services.is_empty() { + eprintln!("arje-absorb: aviso: 0 servicios — la Semilla quedará sin hijas."); + } + + let mut seed = card::build_seed(&label, &services); + if with_carmen { + seed.genesis.push(card::carmen_dm_card()); + eprintln!("arje-absorb: agregado carmen-dm (gestor de login gráfico)."); + } + seed.validate() + .map_err(|e| anyhow::anyhow!("la Semilla generada no valida: {e}"))?; + let json = seed.to_json_pretty()?; + + if output == "-" { + println!("{json}"); + } else { + std::fs::write(&output, json) + .map_err(|e| anyhow::anyhow!("escribiendo {output}: {e}"))?; + eprintln!("arje-absorb: Semilla escrita en {output}"); + } + Ok(()) +} + +/// Autodetecta el init presente en `root`. El orden importa: lo más +/// específico primero — un sistema OpenRC suele llevar también un +/// `/etc/inittab` (para las consolas), así que sysvinit va último. +fn detect(root: &Path) -> Option { + if root.join("etc/dinit.d").is_dir() { + return Some("dinit".to_string()); + } + if root.join("etc/runlevels").is_dir() || root.join("sbin/openrc").exists() { + return Some("openrc".to_string()); + } + if root.join("etc/runit").is_dir() || root.join("etc/sv").is_dir() { + return Some("runit".to_string()); + } + if root.join("etc/inittab").exists() { + return Some("sysvinit".to_string()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_prefers_dinit() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("etc/dinit.d")).unwrap(); + std::fs::create_dir_all(tmp.path().join("etc/runlevels")).unwrap(); + assert_eq!(detect(tmp.path()).as_deref(), Some("dinit")); + } + + #[test] + fn detect_openrc_over_sysvinit() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("etc/runlevels/default")).unwrap(); + std::fs::write(tmp.path().join("etc/inittab"), "").unwrap(); + assert_eq!(detect(tmp.path()).as_deref(), Some("openrc")); + } + + #[test] + fn detect_none_on_empty_root() { + let tmp = tempfile::tempdir().unwrap(); + assert!(detect(tmp.path()).is_none()); + } +} diff --git a/crates/init/arje-absorb/src/model.rs b/crates/init/arje-absorb/src/model.rs new file mode 100644 index 0000000..ba7e621 --- /dev/null +++ b/crates/init/arje-absorb/src/model.rs @@ -0,0 +1,103 @@ +//! El modelo intermedio: un servicio descubierto en el init ajeno, +//! independiente de su formato de origen. Cada absorber +//! (`sysvinit`, `runit`, `dinit`, `openrc`) produce [`ForeignService`]s; +//! `card` los traduce a Cards brahman. + +/// Un servicio leído de la configuración de otro init. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignService { + /// Etiqueta del servicio — pasa a ser el `label` de su Card. + pub name: String, + /// Ejecutable (path absoluto, tal como existirá en el sistema). + pub exec: String, + /// Argumentos del ejecutable. + pub argv: Vec, + /// Variables de entorno (clave, valor) a inyectarle. + pub env: Vec<(String, String)>, + /// Daemon supervisado, o tarea de una sola ejecución. + pub kind: ServiceKind, +} + +/// Cómo trata arje a un servicio absorbido. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceKind { + /// Servicio de larga duración: arje lo supervisa y reinicia. + Daemon, + /// Tarea puntual: corre una vez y termina. + OneShot, +} + +/// Parte una línea de comando en `(exec, argv)`, respetando comillas +/// simples y dobles. Best-effort: no expande variables ni globs — +/// suficiente para los campos `process`/`command` de los inits +/// clásicos. `None` si la línea no tiene ningún token. +pub fn split_command(line: &str) -> Option<(String, Vec)> { + let mut tokens: Vec = Vec::new(); + let mut cur = String::new(); + let mut in_token = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' | '\'' => { + in_token = true; + let quote = c; + for q in chars.by_ref() { + if q == quote { + break; + } + cur.push(q); + } + } + c if c.is_whitespace() => { + if in_token { + tokens.push(std::mem::take(&mut cur)); + in_token = false; + } + } + c => { + in_token = true; + cur.push(c); + } + } + } + if in_token { + tokens.push(cur); + } + if tokens.is_empty() { + return None; + } + let exec = tokens.remove(0); + Some((exec, tokens)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_plain_command() { + let (exec, argv) = split_command("/usr/sbin/sshd -D -e").unwrap(); + assert_eq!(exec, "/usr/sbin/sshd"); + assert_eq!(argv, ["-D", "-e"]); + } + + #[test] + fn respects_double_quotes() { + let (exec, argv) = split_command(r#"/bin/foo "arg con espacios" bar"#).unwrap(); + assert_eq!(exec, "/bin/foo"); + assert_eq!(argv, ["arg con espacios", "bar"]); + } + + #[test] + fn respects_single_quotes() { + let (exec, argv) = split_command("/bin/sh -c 'echo hola mundo'").unwrap(); + assert_eq!(exec, "/bin/sh"); + assert_eq!(argv, ["-c", "echo hola mundo"]); + } + + #[test] + fn empty_line_yields_none() { + assert!(split_command(" ").is_none()); + assert!(split_command("").is_none()); + } +} diff --git a/crates/init/arje-absorb/src/openrc.rs b/crates/init/arje-absorb/src/openrc.rs new file mode 100644 index 0000000..8c57bf1 --- /dev/null +++ b/crates/init/arje-absorb/src/openrc.rs @@ -0,0 +1,86 @@ +//! Absorbe **OpenRC**: descubre los servicios habilitados por runlevel. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use crate::model::{ForeignService, ServiceKind}; + +/// Descubre los servicios OpenRC habilitados en ``. +/// +/// OpenRC habilita un servicio con un symlink en `/etc/runlevels//`. +/// Los scripts de `/etc/init.d/` son shell completo —no se parsean—; se +/// absorben como tarea one-shot `/etc/init.d/ start`, que arranca +/// el daemon (OpenRC mismo lo lleva a segundo plano). Recorremos los +/// runlevels `sysinit` → `boot` → `default` y deduplicamos. +pub fn absorb(root: &Path) -> anyhow::Result> { + let runlevels = ["sysinit", "boot", "default"]; + let mut found_any = false; + let mut seen: BTreeSet = BTreeSet::new(); + let mut out = Vec::new(); + for rl in runlevels { + let dir = root.join("etc/runlevels").join(rl); + if !dir.is_dir() { + continue; + } + found_any = true; + let mut names: Vec = fs::read_dir(&dir)? + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.')) + .collect(); + names.sort(); + for svc in names { + if !seen.insert(svc.clone()) { + continue; // ya absorbido en un runlevel anterior + } + out.push(ForeignService { + name: format!("openrc-{svc}"), + exec: format!("/etc/init.d/{svc}"), + argv: vec!["start".to_string()], + env: Vec::new(), + kind: ServiceKind::OneShot, + }); + } + } + anyhow::ensure!( + found_any, + "no encontré /etc/runlevels/* en {}", + root.display() + ); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absorbs_enabled_services_deduped() { + let tmp = tempfile::tempdir().unwrap(); + for (rl, svcs) in [ + ("boot", ["bootmisc", "hostname"].as_slice()), + ("default", ["sshd", "hostname"].as_slice()), + ] { + let d = tmp.path().join("etc/runlevels").join(rl); + fs::create_dir_all(&d).unwrap(); + for s in svcs { + // Un archivo simple basta: sólo leemos los nombres. + fs::write(d.join(s), "").unwrap(); + } + } + let out = absorb(tmp.path()).unwrap(); + // bootmisc, hostname, sshd — `hostname` deduplicado entre runlevels. + assert_eq!(out.len(), 3); + let sshd = out.iter().find(|s| s.name == "openrc-sshd").unwrap(); + assert_eq!(sshd.exec, "/etc/init.d/sshd"); + assert_eq!(sshd.argv, ["start"]); + assert_eq!(sshd.kind, ServiceKind::OneShot); + } + + #[test] + fn errors_without_runlevels() { + let tmp = tempfile::tempdir().unwrap(); + assert!(absorb(tmp.path()).is_err()); + } +} diff --git a/crates/init/arje-absorb/src/runit.rs b/crates/init/arje-absorb/src/runit.rs new file mode 100644 index 0000000..2fea1aa --- /dev/null +++ b/crates/init/arje-absorb/src/runit.rs @@ -0,0 +1,111 @@ +//! Absorbe **runit**: descubre los servicios bajo supervisión. + +use std::fs; +use std::path::Path; + +use crate::model::{ForeignService, ServiceKind}; + +/// Descubre los servicios runit de ``. +/// +/// runit supervisa un directorio de servicios activos (el `runsvdir` +/// del runlevel). Cada entrada apunta a `/etc/sv/`, cuyo script +/// `run` es un daemon en primer plano — el calce con arje es 1:1: arje +/// supervisa ese mismo `run`. Si no hay `runsvdir`, cae a `/etc/sv` +/// (todos los servicios definidos). +pub fn absorb(root: &Path) -> anyhow::Result> { + let runsvdir = [ + "etc/runit/runsvdir/default", + "etc/runit/runsvdir/current", + "service", + "var/service", + "etc/service", + ] + .into_iter() + .map(|c| root.join(c)) + .find(|p| p.is_dir()); + + let scan = match runsvdir { + Some(d) => d, + None => { + let sv = root.join("etc/sv"); + anyhow::ensure!( + sv.is_dir(), + "no encontré servicios runit en {}", + root.display() + ); + sv + } + }; + + let mut names: Vec = fs::read_dir(&scan)? + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.')) + .collect(); + names.sort(); + + let mut out = Vec::new(); + for name in names { + // El servicio vive en /etc/sv/; su run-script es el exec. + let run = root.join("etc/sv").join(&name).join("run"); + if !run.exists() { + continue; // entrada sin run-script real — la saltamos + } + out.push(ForeignService { + name: format!("runit-{name}"), + exec: format!("/etc/sv/{name}/run"), + argv: Vec::new(), + env: Vec::new(), + kind: ServiceKind::Daemon, // runit siempre supervisa + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_sv(root: &Path, name: &str) { + let d = root.join("etc/sv").join(name); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("run"), "#!/bin/sh\nexec daemon\n").unwrap(); + } + + #[test] + fn absorbs_from_etc_sv_fallback() { + let tmp = tempfile::tempdir().unwrap(); + make_sv(tmp.path(), "sshd"); + make_sv(tmp.path(), "dhcpcd"); + let svcs = absorb(tmp.path()).unwrap(); + assert_eq!(svcs.len(), 2); + // Salida ordenada por nombre. + assert_eq!(svcs[0].name, "runit-dhcpcd"); + assert_eq!(svcs[0].exec, "/etc/sv/dhcpcd/run"); + assert_eq!(svcs[0].kind, ServiceKind::Daemon); + } + + #[test] + fn absorbs_only_enabled_from_runsvdir() { + let tmp = tempfile::tempdir().unwrap(); + for s in ["sshd", "dhcpcd", "apagado"] { + make_sv(tmp.path(), s); + } + let rsv = tmp.path().join("etc/runit/runsvdir/default"); + fs::create_dir_all(&rsv).unwrap(); + // Sólo sshd y dhcpcd están habilitados (symlink en el runsvdir). + for s in ["sshd", "dhcpcd"] { + std::os::unix::fs::symlink(tmp.path().join("etc/sv").join(s), rsv.join(s)) + .unwrap(); + } + let svcs = absorb(tmp.path()).unwrap(); + assert_eq!(svcs.len(), 2); + assert!(svcs.iter().all(|s| s.name != "runit-apagado")); + } + + #[test] + fn errors_without_services() { + let tmp = tempfile::tempdir().unwrap(); + assert!(absorb(tmp.path()).is_err()); + } +} diff --git a/crates/init/arje-absorb/src/sysvinit.rs b/crates/init/arje-absorb/src/sysvinit.rs new file mode 100644 index 0000000..155b66a --- /dev/null +++ b/crates/init/arje-absorb/src/sysvinit.rs @@ -0,0 +1,96 @@ +//! Absorbe **sysvinit**: parsea `/etc/inittab`. + +use std::fs; +use std::path::Path; + +use anyhow::Context; + +use crate::model::{split_command, ForeignService, ServiceKind}; + +/// Lee `/etc/inittab` y devuelve sus servicios. +/// +/// Formato de cada línea: `id:runlevels:action:process`. Tomamos las +/// que tienen un `process` real: `respawn` → daemon supervisado; +/// `wait`/`once`/`boot`/`bootwait`/`sysinit` → one-shot. El resto de +/// acciones (`initdefault`, `ctrlaltdel`, `power*`, `off`, …) no lanzan +/// un servicio y se ignoran. +pub fn absorb(root: &Path) -> anyhow::Result> { + let path = root.join("etc/inittab"); + let text = + fs::read_to_string(&path).with_context(|| format!("leyendo {}", path.display()))?; + let mut out = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut f = line.splitn(4, ':'); + let (id, action, process) = match (f.next(), f.next(), f.next(), f.next()) { + (Some(id), Some(_rl), Some(action), Some(process)) => (id, action, process), + _ => continue, // línea malformada + }; + let kind = match action.trim() { + "respawn" => ServiceKind::Daemon, + "wait" | "once" | "boot" | "bootwait" | "sysinit" => ServiceKind::OneShot, + _ => continue, + }; + // El proceso puede empezar con `+` (sysvinit: no escribir utmp). + let process = process.trim().trim_start_matches('+').trim(); + let Some((exec, argv)) = split_command(process) else { + continue; + }; + out.push(ForeignService { + name: format!("sysv-{}", id.trim()), + exec, + argv, + env: Vec::new(), + kind, + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_inittab(content: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("etc")).unwrap(); + fs::write(tmp.path().join("etc/inittab"), content).unwrap(); + tmp + } + + #[test] + fn parses_respawn_and_oneshot() { + let tmp = with_inittab( + "# consolas del sistema\n\ + id:3:initdefault:\n\ + si::sysinit:/etc/rc.d/rc.sysinit\n\ + 1:2345:respawn:/sbin/agetty 38400 tty1 linux\n\ + rc::wait:/etc/rc.d/rc 3\n", + ); + let svcs = absorb(tmp.path()).unwrap(); + // sysinit + respawn + wait — initdefault no cuenta. + assert_eq!(svcs.len(), 3); + let agetty = svcs.iter().find(|s| s.name == "sysv-1").unwrap(); + assert_eq!(agetty.kind, ServiceKind::Daemon); + assert_eq!(agetty.exec, "/sbin/agetty"); + assert_eq!(agetty.argv, ["38400", "tty1", "linux"]); + let si = svcs.iter().find(|s| s.name == "sysv-si").unwrap(); + assert_eq!(si.kind, ServiceKind::OneShot); + } + + #[test] + fn skips_comments_and_blank_lines() { + let tmp = with_inittab("\n \n# sólo comentarios\n"); + assert!(absorb(tmp.path()).unwrap().is_empty()); + } + + #[test] + fn strips_leading_plus() { + let tmp = with_inittab("x:2:respawn:+/sbin/getty tty2\n"); + let svcs = absorb(tmp.path()).unwrap(); + assert_eq!(svcs[0].exec, "/sbin/getty"); + } +} diff --git a/scripts/migrate-to-arje.sh b/scripts/migrate-to-arje.sh new file mode 100755 index 0000000..8bd1795 --- /dev/null +++ b/scripts/migrate-to-arje.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# migrate-to-arje.sh — migra una máquina de su init actual a arje, +# absorbiendo su configuración de servicios. +# +# QUÉ HACE: +# 1. Compila arje-absorb (host). +# 2. Autodetecta el init actual (o --from) y absorbe sus servicios en +# una Tarjeta Semilla (out/arje-absorbed.card.json por default). +# 3. (--with-carmen) agrega carmen-dm —el gestor de login gráfico— a +# la Semilla y compila+instala mirada-compositor y mirada-greeter. +# 4. Pasa la Semilla a install-arje-as-init.sh, que instala arje en +# paralelo a tu init actual, con su propia entrada GRUB. +# +# Tu init actual queda INTACTO y sigue siendo el default — arje se elige +# en GRUB. Revertir: sudo scripts/uninstall-arje.sh +# +# systemd NO se absorbe (sus units no son un formato de texto trivial); +# para systemd usá install-arje-as-init.sh con seeds/arje-host.card.json. +# Este script cubre sysvinit / runit / dinit / OpenRC. +# +# USO: +# sudo scripts/migrate-to-arje.sh [opciones] +# --from sysvinit|runit|dinit|openrc|auto (def: auto) +# --with-carmen agrega el escritorio carmen como gestor de login +# --seed-out dónde escribir la Semilla absorbida +# (def: out/arje-absorbed.card.json) +# --dry-run absorbe y valida, sin instalar nada (no necesita root) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +FROM="auto" +WITH_CARMEN=0 +DRY_RUN=0 +SEED_OUT="$REPO_DIR/out/arje-absorbed.card.json" + +while [ "$#" -gt 0 ]; do + case "$1" in + --from) FROM="${2:?--from necesita un valor}"; shift 2 ;; + --with-carmen) WITH_CARMEN=1; shift ;; + --seed-out) SEED_OUT="${2:?--seed-out necesita un valor}"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) + cat <<'USAGE' +migrate-to-arje.sh — migra una máquina a arje absorbiendo su init actual. + --from sysvinit|runit|dinit|openrc|auto (def: auto) + --with-carmen agrega el escritorio carmen como gestor de login + --seed-out dónde escribir la Semilla absorbida + --dry-run absorbe y valida sin instalar (no necesita root) +USAGE + exit 0 ;; + *) echo "[migrate] opción desconocida: $1 (usá --help)" >&2; exit 2 ;; + esac +done + +cd "$REPO_DIR" + +if [ "$DRY_RUN" -eq 0 ] && [ "$(id -u)" -ne 0 ]; then + echo "[migrate] hace falta root para instalar — o corré con --dry-run" >&2 + exit 1 +fi + +echo "[migrate] paso 1/4: compilar arje-absorb" +cargo build --quiet --release -p arje-absorb +ABSORB="$REPO_DIR/target/release/arje-absorb" + +echo "[migrate] paso 2/4: absorber el init actual (--from $FROM)" +mkdir -p "$(dirname "$SEED_OUT")" +ABSORB_ARGS=(--from "$FROM" --output "$SEED_OUT" --label "arje.seed.migrated") +[ "$WITH_CARMEN" -eq 1 ] && ABSORB_ARGS+=(--with-carmen) +"$ABSORB" "${ABSORB_ARGS[@]}" +echo "[migrate] Semilla escrita en: $SEED_OUT" +bash "$REPO_DIR/seeds/validate.sh" "$SEED_OUT" + +if [ "$DRY_RUN" -eq 1 ]; then + cat <