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>
This commit is contained in:
sergio
2026-05-22 00:40:34 +00:00
parent 3339fb009c
commit 762bf95dfd
12 changed files with 1063 additions and 0 deletions
Generated
+9
View File
@@ -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"
+1
View File
@@ -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)
+18
View File
@@ -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 }
+51
View File
@@ -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`.
+193
View File
@@ -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);
}
}
+105
View File
@@ -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());
}
}
+174
View File
@@ -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());
}
}
+103
View File
@@ -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());
}
}
+86
View File
@@ -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());
}
}
+111
View File
@@ -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());
}
}
+96
View File
@@ -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");
}
}
+116
View File
@@ -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