SDD 30 §4a+§4c: los 9 demonios de GNOME declarados — y aparecieron dos que no estaban en NINGÚN perfil

Lo que el script de sesión lanza con `&` ahora está declarado en las recetas y
habilitado en el perfil. Ninguna receta movió su hash: 9/9 idénticos a los que
los grafos ya registraban.

EL HALLAZGO, y no lo buscaba: la comprobación inversa del resolutor rechazó
`arje-logind-compat` y `arje-polkit-compat` porque están en CERO perfiles — y
sin embargo qemu-desktop-image.sh los copia al rootfs a mano y el de COSMIC hace
`exit 1` si falta logind-compat. Dos binarios imprescindibles, presentes en la
imagen y ausentes del destino declarado: la misma forma del agujero de `foot`,
encontrada por una comprobación en vez de por una imagen inusable. Son raíces de
escritorio-gnome (los dos) y de escritorio-cosmic (sólo logind, verificado que
sus scripts no nombran polkit).

DOS COSAS QUE NO SON TRANSCRIPCIÓN:
- `dbus-daemon --fork` no se traduce tal cual: arje supervisa al HIJO DIRECTO y
  Type=forking no existe, así que un daemon que forkea y sale deja a arje viendo
  morir al padre con éxito y reencarnándolo para siempre. La card usa --nofork.
- `scope = system|session` decide DÓNDE va la card. Las de sesión necesitan
  XDG_RUNTIME_DIR y usuario logueado; en el genesis arrancarían antes de que
  exista ninguno. Y fuera de mirada NADIE entrega cards de sesión todavía, así
  que salen con AVISO: el hueco queda contado, no omitido.

Correcciones propias: la unicidad del label es DENTRO del perfil, no del corpus
(upower vive legítimamente en dos colas); la membresía se lee de los CINCO
grafos, no sólo el del corpus; una RAÍZ manda sobre el grafo, que es derivado y
lo regenera el cron; y la flag nace en inglés (`--services`) como manda la
regla 4, aunque `--lista` sea deuda vieja del mismo fichero.

`--selftest`: 7 casos, el primero es el CONTROL que tiene que pasar en verde.
This commit is contained in:
Sergio
2026-09-12 11:08:58 +00:00
parent 0b5dae7812
commit 1b56b16402
11 changed files with 316 additions and 28 deletions
+52
View File
@@ -67,6 +67,21 @@ pub struct Service {
/// `daemon` (default) u `oneshot` — el `lifecycle` de la Card.
#[serde(default)]
pub kind: Kind,
/// **De sistema o de sesión** — y no es cosmético: decide DÓNDE va la Card.
///
/// `system` (default) = arranca al boot, va en el `genesis` de la seed. `session` = pertenece a
/// una sesión gráfica (pipewire, wireplumber, el bus de sesión): necesita `XDG_RUNTIME_DIR` y un
/// usuario logueado, así que meterla en el genesis la arrancaría antes de que exista ninguna de
/// las dos cosas.
///
/// El campo existe porque el destino de una Card de sesión **hoy no está resuelto fuera de
/// mirada**: ahí lo hace el compositor (`mirada-compositor/src/session.rs` arma
/// `session.{label}` con `requires = [wayland_floor()]` y la entrega a PID 1 por `RunCard`),
/// pero mutter y kwin no tienen esa integración y sus demonios de sesión los sigue lanzando el
/// script de arranque. Declararlo igual hace que el hueco se CUENTE en vez de omitirse —
/// `scripts/targets.py --servicios` avisa por cada uno.
#[serde(default)]
pub scope: Scope,
/// Política de supervisión. **Ausente ⇒ `OneShot`** (no revive). Presente ⇒ `Restart` con
/// backoff exponencial entre `initial_ms` y `max_ms`, y su muerte es el `CRASHED` real.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -97,6 +112,24 @@ impl Kind {
}
}
/// Dónde vive la Card: arranque del sistema o sesión gráfica. Ver [`Service::scope`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
#[default]
System,
Session,
}
impl Scope {
pub fn as_str(self) -> &'static str {
match self {
Scope::System => "system",
Scope::Session => "session",
}
}
}
/// `permissions.networking` de la Card.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -243,6 +276,7 @@ mod tests {
.collect(),
envp: vec![("HOME".into(), "/root".into())],
kind: Kind::Daemon,
scope: Scope::System,
restart: Some(Restart { initial_ms: 500, max_ms: 20000 }),
networking: Networking::Full,
cgroup: Some("arje.slice/sshd".into()),
@@ -311,6 +345,24 @@ restart = { initial_ms = 500, max_ms = 20000 }
assert!(s.validate().unwrap_err().to_string().contains("initial_ms"));
}
#[test]
fn el_scope_es_system_por_defecto_y_no_entra_en_la_card() {
// El scope decide DÓNDE se coloca la Card, no qué dice: la Card de un servicio de sesión
// tiene la misma forma. Si entrara en el JSON, cambiaría el esquema de `card_core::Card`,
// que no es nuestro.
let s: Service = toml::from_str(
"label='pipewire'\nid='01HQAR53D4M2NBV8KZTYXFQA20'\nexec='/usr/bin/pipewire'",
)
.unwrap();
assert_eq!(s.scope, Scope::System, "default");
let con_sesion: Service = toml::from_str(
"label='pipewire'\nid='01HQAR53D4M2NBV8KZTYXFQA20'\nexec='/usr/bin/pipewire'\nscope='session'",
)
.unwrap();
assert_eq!(con_sesion.scope, Scope::Session);
assert_eq!(s.card(), con_sesion.card(), "el scope no cambia la Card");
}
#[test]
fn validate_all_rechaza_duplicados() {
let a = sshd();