tawasuyu

tawasuyu/brahman

Rust 202TOML 61JSON 7HTML 12471 Definiciones

feat(arje): arje-absorb — absorbe otros inits a una Semilla brahman

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 <noreply@anthropic.com>

762bf95dfd770cc8bf51f21605bc2cc6cc88ea76 · por sergio · (hace 5 meses) · padre 3339fb009 · ver el árbol en este commit

Delta semántico · 55

crates/init/arje-absorb/src/dinit.rs

crates/init/arje-absorb/src/main.rs

crates/init/arje-absorb/src/model.rs

crates/init/arje-absorb/src/openrc.rs

crates/init/arje-absorb/src/runit.rs

crates/init/arje-absorb/src/sysvinit.rs

12 archivos · +1063 −0

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/<svc> 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 <init>` (def. `auto`), `--root <dir>` (def. `/`),
+`--output <f>` (def. `-` = stdout), `--label <s>`, `--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/<svc>
+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<Card> = 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 `<root>/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<Vec<ForeignService>> {
+    let dir = root.join("etc/dinit.d");
+    anyhow::ensure!(
+        dir.is_dir(),
+        "no encontré /etc/dinit.d en {}",
+        root.display()
+    );
+    let mut names: Vec<String> = 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<ForeignService> {
+    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 <init>] [--root <dir>] [--output <archivo>] [--label <s>]
+
+OPCIONES:
+    --from <init>    sysvinit | runit | dinit | openrc | auto   (def: auto)
+    --root <dir>     raíz del sistema a leer                    (def: /)
+    --output <f>     archivo de salida, o '-' para stdout       (def: -)
+    --label <s>      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 <init>",
+                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<String> {
+    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<String>,
+    /// 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<String>)> {
+    let mut tokens: Vec<String> = 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 `<root>`.
+///
+/// OpenRC habilita un servicio con un symlink en `/etc/runlevels/<rl>/`.
+/// Los scripts de `/etc/init.d/` son shell completo —no se parsean—; se
+/// absorben como tarea one-shot `/etc/init.d/<svc> 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<Vec<ForeignService>> {
+    let runlevels = ["sysinit", "boot", "default"];
+    let mut found_any = false;
+    let mut seen: BTreeSet<String> = 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<String> = 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 `<root>`.
+///
+/// runit supervisa un directorio de servicios activos (el `runsvdir`
+/// del runlevel). Cada entrada apunta a `/etc/sv/<nombre>`, 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<Vec<ForeignService>> {
+    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<String> = 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/<name>; 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 `<root>/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<Vec<ForeignService>> {
+    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 <init>    sysvinit|runit|dinit|openrc|auto   (def: auto)
+#     --with-carmen    agrega el escritorio carmen como gestor de login
+#     --seed-out <f>   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 <init>    sysvinit|runit|dinit|openrc|auto   (def: auto)
+  --with-carmen    agrega el escritorio carmen como gestor de login
+  --seed-out <f>   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 <<EOF
+
+[migrate] --dry-run: Semilla absorbida y validada en
+  $SEED_OUT
+Revisala y, cuando quieras instalar:
+  sudo scripts/install-arje-as-init.sh "$SEED_OUT"
+EOF
+  exit 0
+fi
+
+if [ "$WITH_CARMEN" -eq 1 ]; then
+  echo "[migrate] paso 3/4: compilar e instalar carmen (compositor + greeter)"
+  # carmen enlaza mesa/libdrm/libinput/libseat — se compila con el
+  # target del host (glibc dinámico), NO musl: esta máquina ya tiene
+  # esas librerías. Sólo el PID 1 y los shims van musl-static al initrd.
+  cargo build --quiet --release -p mirada-compositor -p mirada-greeter
+  install -m 0755 "$REPO_DIR/target/release/mirada-compositor" /usr/bin/mirada-compositor
+  install -m 0755 "$REPO_DIR/target/release/mirada-greeter"    /usr/bin/mirada-greeter
+  echo "  mirada-compositor y mirada-greeter instalados en /usr/bin/"
+else
+  echo "[migrate] paso 3/4: carmen omitido (sin --with-carmen)"
+fi
+
+echo "[migrate] paso 4/4: instalar arje como init"
+"$SCRIPT_DIR/install-arje-as-init.sh" "$SEED_OUT"
+
+cat <<EOF
+
+[migrate] HECHO. arje quedó instalado en paralelo a tu init actual.
+  - Semilla migrada:  $SEED_OUT  →  /ente/seed.card.json
+  - tu init actual sigue siendo el default; arje se elige en GRUB.
+
+Para arrancar arje una vez en el próximo boot:
+  sudo grub-reboot "arje (init=/sbin/arje-zero) — kernel \$(uname -r)"
+  sudo reboot
+
+Revertir todo:
+  sudo scripts/uninstall-arje.sh
+EOF