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 · by sergio · 2026-05-22T00:40:34Z (5 months ago) · parent 3339fb009 · browse the tree at this commit
Semantic delta · 55
crates/init/arje-absorb/src/card.rs
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
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