> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
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.
diff --git a/crates/takana-core/src/service.rs b/crates/takana-core/src/service.rs
index 42252c6a..91e93fd3 100644
--- a/crates/takana-core/src/service.rs
+++ b/crates/takana-core/src/service.rs
@@ -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();
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 06195c68..ddf11a6b 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -582,6 +582,29 @@ paquetes = [
# `wireplumber` es lo que le da DISPOSITIVOS a PipeWire (que ya entra por la clausura): sin él hay
# socket de audio y nada que suene.
"wireplumber",
+ # ── LOS DOS QUE LA IMAGEN INYECTABA A MANO Y NINGÚN PERFIL DECLARABA ─────────────────────────
+ # Hallados el 2026-09-12 por `targets.py --servicios escritorio-gnome`, que los rechazó con «lo
+ # declara `arje-logind-compat`, que NO pertenece al perfil». No era falso positivo: los dos
+ # estaban en CERO perfiles y sin embargo `scripts/gnome/qemu-desktop-image.sh` los copia al
+ # rootfs a mano, y el de COSMIC hace `exit 1` si falta logind-compat. O sea: dos binarios
+ # imprescindibles, presentes en la imagen y ausentes del destino declarado — la misma forma del
+ # agujero de `foot`, encontrada esta vez por una comprobación en vez de por una imagen inusable.
+ "arje-logind-compat", "arje-polkit-compat",
+]
+
+# ── QUÉ ARRANCA (el «enable»; SDD 30 §3) ────────────────────────────────────────────────────────
+# Hoy estos ocho los lanza a mano `scripts/gnome/gnome-start-qemu.sh` con `&`, sin supervisión, sin
+# backoff y sin el `CRASHED` real — o sea sin nada de lo que arje es PID 1 para dar. Declararlos acá
+# no los mueve todavía: los pone en el mismo sitio que el resto del destino de la imagen, y hace que
+# `scripts/targets.py --servicios escritorio-gnome` pueda contradecir a la realidad en vez de que la
+# realidad viva sólo dentro de un script de 500 líneas.
+#
+# Los tres de SESIÓN (pipewire, pipewire-pulse, wireplumber) salen con AVISO a propósito: fuera de
+# mirada nadie entrega Cards de sesión, así que están declarados y NO arrancados por arje. Un hueco
+# escrito se ve; uno omitido, no.
+servicios = [
+ "dbus-system", "logind-compat", "polkit-compat", "accounts-daemon", "upowerd", "colord",
+ "pipewire", "pipewire-pulse", "wireplumber",
]
[perfil.escritorio-cosmic]
@@ -765,6 +788,11 @@ paquetes = [
# Arreglarlo de verdad pide re-sellar pipewire, que arrastra 7 artefactos —entre ellos dos Rust
# que ya murieron por OOM— así que se paga el día que pipewire se re-selle por otro motivo.
"expat-shared", "libffi-shared", "bzip2-shared", "ncurses-shared",
+ # `arje-logind-compat` (2026-09-12): mismo hallazgo que en `escritorio-gnome` — la imagen lo
+ # inyecta a mano y `scripts/cosmic/qemu-desktop-image.sh` hace `exit 1` si falta, pero no estaba
+ # en ningún perfil. COSMIC NO usa `arje-polkit-compat` (verificado: sus scripts no lo nombran),
+ # así que va sólo este.
+ "arje-logind-compat",
]
[perfil.escritorio-sway]
diff --git a/recipes/arje-logind-compat.toml b/recipes/arje-logind-compat.toml
index 8bd9ab43..6f2782fb 100644
--- a/recipes/arje-logind-compat.toml
+++ b/recipes/arje-logind-compat.toml
@@ -78,3 +78,20 @@ cat > /out/usr/share/dbus-1/system.d/org.freedesktop.login1.conf <<'XML'
</busconfig>
XML
'''
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# `ARJE_LOGIN_STATE=1` NO es opcional y la razón está medida en `scripts/gnome/gnome-start-qemu.sh`:
+# es la perilla que hace que el daemon ESCRIBA `/run/systemd/{sessions,seats,users}`, que es lo que
+# libelogind LEE para resolver la C-ABI de sd-login. Su default es `off` porque en un host con
+# systemd real pisaría el estado del sistema; acá no hay systemd y el que la prende es el lanzador
+# de sesión. Sin ella mutter muere con "Failed to find any matching session".
+#
+# Va DESPUÉS del bus de sistema (registra `org.freedesktop.login1` ahí). Ese orden hoy lo garantiza
+# el script esperando el socket; en Cards lo daría el grafo de capacidades de arje, pero el
+# ordenamiento es por spawn y no por READINESS (SDD 30 §5) — el hueco está contado, no tapado.
+[[service]]
+label = "logind-compat"
+id = "01HQAR53D4M2NBV8KZTYXFQA21"
+exec = "/usr/bin/arje-logind-compat"
+envp = [["ARJE_LOGIN_STATE", "1"]]
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/arje-polkit-compat.toml b/recipes/arje-polkit-compat.toml
index 9d7de65d..61dcc619 100644
--- a/recipes/arje-polkit-compat.toml
+++ b/recipes/arje-polkit-compat.toml
@@ -83,3 +83,10 @@ cat > /out/usr/share/dbus-1/system.d/zz-arje-polkit-compat.conf <<'XML'
</busconfig>
XML
'''
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+[[service]]
+label = "polkit-compat"
+id = "01HQAR53D4M2NBV8KZTYXFQA22"
+exec = "/usr/bin/arje-polkit-compat"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/dbus.toml b/recipes/dbus.toml
index b26696e0..f01cee85 100644
--- a/recipes/dbus.toml
+++ b/recipes/dbus.toml
@@ -47,3 +47,26 @@ rm -rf /out/run
[deps]
build = ["meson", "samurai", "python3", "pkgconf", "expat"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# ⚠ ESTO NO ES UNA TRANSCRIPCIÓN DEL SCRIPT DE SESIÓN, Y LA DIFERENCIA IMPORTA. `gnome-start-qemu.sh`
+# lo lanza como `dbus-daemon --system --fork`; una Card NO PUEDE hacer eso. arje supervisa **al hijo
+# directo**: `Type=forking` no existe en su modelo (DE-SYSTEMD-A-ARJE.md §2), así que un daemon que
+# forkea y sale deja a arje viendo morir al padre con éxito y reencarnándolo para siempre. Por eso la
+# Card usa `--nofork --nopidfile`: el pidfile tampoco tiene sentido cuando quien sigue el proceso es
+# PID 1.
+#
+# El bus de SISTEMA es scope `system` (arranca al boot, antes de cualquier sesión). El bus de SESIÓN
+# que gnome-shell exige es otra cosa y no va acá: lo levanta el lanzador de sesión con
+# `--print-address`, porque su dirección es un dato que hay que devolverle al que lanza.
+#
+# La Card se declara en `dbus` y NO en `dbus-shared` a propósito: las dos publican `dbus-daemon`
+# —verificado en el store— pero la compartida existe para publicar la librería, no para ser el
+# daemon canónico. Dos recetas del mismo perfil declarando el mismo label es un error que
+# `scripts/targets.py --servicios` rechaza, y con razón: la imagen no sabría cuál arranca.
+[[service]]
+label = "dbus-system"
+id = "01HQAR53D4M2NBV8KZTYXFQA20"
+exec = "/usr/bin/dbus-daemon"
+argv = ["--system", "--nofork", "--nopidfile"]
+restart = { initial_ms = 200, max_ms = 10000 }
diff --git a/recipes/incoming-gnome/accountsservice.toml b/recipes/incoming-gnome/accountsservice.toml
index 5a9ce834..7ab7825f 100644
--- a/recipes/incoming-gnome/accountsservice.toml
+++ b/recipes/incoming-gnome/accountsservice.toml
@@ -99,3 +99,12 @@ install = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson ins
[deps]
build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "py3-setuptools", "gi-foreign-girs", "gobject-introspection", "glib", "glib-introspected", "polkit", "dbus-shared", "libelogind", "libffi", "pcre2", "zlib-shared", "expat"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# Se lanza EXPLÍCITO, no por activación de D-Bus, y el motivo está medido en el script de sesión: la
+# activación por bus funciona, pero deja el arranque esperando el timeout y no deja log donde mirar.
+[[service]]
+label = "accounts-daemon"
+id = "01HQAR53D4M2NBV8KZTYXFQA23"
+exec = "/usr/libexec/accounts-daemon"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/incoming-gnome/colord.toml b/recipes/incoming-gnome/colord.toml
index 4c406b9c..2021ec3d 100644
--- a/recipes/incoming-gnome/colord.toml
+++ b/recipes/incoming-gnome/colord.toml
@@ -70,3 +70,12 @@ install = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson ins
# librería para leer la base de perfiles ICC). Ya estaba en el corpus y sellada, provee sqlite3.pc.
[deps]
build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "glib", "lcms2", "sqlite", "libgusb", "libgudev", "libudev-zero", "libusb", "json-glib", "hwdata", "polkit", "dbus-shared", "expat", "libffi", "pcre2", "zlib"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# Mutter lo pide al arrancar y, si no está, se come 25 s de timeout de activación D-Bus. Por eso es
+# un servicio declarado y no algo que el bus levante cuando alguien pregunte.
+[[service]]
+label = "colord"
+id = "01HQAR53D4M2NBV8KZTYXFQA25"
+exec = "/usr/libexec/colord"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/incoming-gnome/upower.toml b/recipes/incoming-gnome/upower.toml
index aa64d55b..088ce6d0 100644
--- a/recipes/incoming-gnome/upower.toml
+++ b/recipes/incoming-gnome/upower.toml
@@ -56,3 +56,10 @@ install = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson insta
[deps]
build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "py3-setuptools", "gobject-introspection", "gi-foreign-girs", "glib", "glib-introspected", "libgudev", "udev-pc", "libudev-zero", "polkit", "libffi", "pcre2", "zlib-shared", "expat", "dbus-shared"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+[[service]]
+label = "upowerd"
+id = "01HQAR53D4M2NBV8KZTYXFQA24"
+exec = "/usr/libexec/upowerd"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/incoming-gnome/wireplumber.toml b/recipes/incoming-gnome/wireplumber.toml
index 0e3d26d8..768c6c3e 100644
--- a/recipes/incoming-gnome/wireplumber.toml
+++ b/recipes/incoming-gnome/wireplumber.toml
@@ -74,3 +74,13 @@ install = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson insta
build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "lua", "pipewire",
"glib", "dbus-shared", "libelogind", "alsa-lib", "libsndfile", "pulseaudio",
"expat", "pcre2", "libffi", "libudev-zero", "zlib-shared"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# El gestor de sesión de pipewire. Mismo `scope = "session"` y misma advertencia: fuera de mirada
+# nadie entrega Cards de sesión todavía.
+[[service]]
+label = "wireplumber"
+id = "01HQAR53D4M2NBV8KZTYXFQA28"
+exec = "/usr/bin/wireplumber"
+scope = "session"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/pipewire.toml b/recipes/pipewire.toml
index 45e39b6b..4c9b48f7 100644
--- a/recipes/pipewire.toml
+++ b/recipes/pipewire.toml
@@ -143,3 +143,30 @@ ln -sf /usr/share/alsa/alsa.conf.d/99-pipewire-default.conf /out/etc/alsa/conf.d
[deps]
build = ["meson", "samurai", "python3", "pkgconf", "dbus-shared", "expat", "alsa-lib", "libsndfile",
"pulseaudio", "glib", "pcre2", "libffi", "libudev-zero", "zlib-shared", "gettext-tiny"]
+
+# ── LOS SERVICIOS QUE ESTE PAQUETE TRAE (SDD 30) ────────────────────────────────────────────────
+# `scope = "session"`, y eso es una afirmación con consecuencia: estos NO van en el `genesis` de la
+# seed. Necesitan `XDG_RUNTIME_DIR` y un usuario logueado, así que arrancarlos al boot los pondría a
+# correr antes de que exista ninguna de las dos cosas.
+#
+# ⚠ Y hoy NO HAY QUIÉN LOS ENTREGUE a arje fuera de mirada: ahí el compositor arma las Cards de
+# sesión (`mirada-compositor/src/session.rs`, `requires = [wayland_floor()]`) y las pasa a PID 1 por
+# `RunCard`; mutter y kwin no tienen esa integración y sus demonios de sesión los sigue lanzando el
+# script. Se declaran igual para que el hueco se CUENTE —`targets.py --servicios` avisa por cada
+# uno— en vez de quedar omitido, que es como se ve un hueco que nadie escribió.
+[[service]]
+label = "pipewire"
+id = "01HQAR53D4M2NBV8KZTYXFQA26"
+exec = "/usr/bin/pipewire"
+scope = "session"
+restart = { initial_ms = 500, max_ms = 20000 }
+
+# El puente que habla protocolo PulseAudio contra el servidor. Depende de que `pipewire` ya haya
+# creado su socket: hoy el script lo espera con un bucle sobre `$XDG_RUNTIME_DIR/pipewire-0`, que es
+# justo la readiness que arje no tiene por unidad (SDD 30 §5).
+[[service]]
+label = "pipewire-pulse"
+id = "01HQAR53D4M2NBV8KZTYXFQA27"
+exec = "/usr/bin/pipewire-pulse"
+scope = "session"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/scripts/targets.py b/scripts/targets.py
index 57ea812c..457b8d58 100755
--- a/scripts/targets.py
+++ b/scripts/targets.py
@@ -7,7 +7,13 @@
# Uso: scripts/targets.py cli # raíces expandidas, una línea separada por espacios
# scripts/targets.py --lista # perfiles disponibles, con su descripción
# scripts/targets.py --json # todo el manifiesto expandido
-# scripts/targets.py --servicios cli # los servicios que ESA imagen arranca, resueltos
+# scripts/targets.py --services cli # los servicios que ESA imagen arranca, resueltos
+# scripts/targets.py --selftest # roturas a propósito + un control que TIENE que pasar
+#
+# ⚠ La FLAG va en inglés (regla 4 del CLAUDE.md: la superficie de CLI es contrato) aunque `--lista`
+# sea deuda vieja del mismo fichero. La CLAVE del TOML sí es `servicios`, en castellano, porque ahí
+# la convención local es esa (`paquetes`, `hereda`, `cola`, `descripcion`) y mezclar idiomas DENTRO
+# del manifiesto sería peor que la coherencia con el resto de la superficie.
# Como módulo: from targets import load → {perfil: {descripcion, cola, hereda, paquetes, raices}}
#
# `hereda` es transitivo y el orden se PRESERVA (primero las raíces de lo heredado, en el orden
@@ -96,14 +102,30 @@ def declaraciones(recipes_dir=None):
return por_label
+# Los grafos por COLA. `build-state.json` es sólo el corpus: las recetas de un escritorio viven en
+# `recipes/incoming-<x>/` y `build-state.py` sólo las carga con su flag, así que su membresía de
+# perfil está en el grafo de SU cola. Mirar sólo el del corpus daría "no puedo comprobar" para todo
+# GNOME/KDE/COSMIC — que es peor que no comprobar: parece una respuesta.
+GRAFOS = ["build-state.json", "build-state-gnome.json", "build-state-kde.json",
+ "build-state-cosmic.json", "build-state-wlr.json"]
+
+
def _membresia():
- """{receta: [perfiles]} desde build-state.json. `{}` si no está (no es fatal: degrada a no
- poder comprobar la pertenencia, y se dice)."""
- try:
- d = json.load(open(ROOT / "docs/state/build-state.json"))
- except (FileNotFoundError, json.JSONDecodeError):
- return {}
- return {k: v.get("perfiles", []) for k, v in d.get("nodes", {}).items()}
+ """{receta: [perfiles]} fusionando TODOS los grafos de estado. `{}` si no hay ninguno (no es
+ fatal: degrada a no poder comprobar la pertenencia, y se dice)."""
+ memb = {}
+ for nombre in GRAFOS:
+ try:
+ d = json.load(open(ROOT / "docs/state" / nombre))
+ except (FileNotFoundError, json.JSONDecodeError):
+ continue
+ for k, v in d.get("nodes", {}).items():
+ # unión: una receta del corpus puede aparecer en varios grafos con perfiles distintos
+ ya = memb.setdefault(k, [])
+ for perf in v.get("perfiles", []):
+ if perf not in ya:
+ ya.append(perf)
+ return memb
def resolver_servicios(perfil, perfiles=None):
@@ -117,30 +139,54 @@ def resolver_servicios(perfil, perfiles=None):
memb = _membresia()
raices = set(p["raices"])
+ def en_el_perfil(receta):
+ """`True`/`False`/`None` (= no se puede saber).
+
+ Las RAÍCES mandan sobre el grafo, y el orden importa: `targets.toml` es la fuente de verdad
+ de lo que la imagen quiere y `build-state.json` es DERIVADO de él —lo regenera el cron cada
+ 30 min—. Preguntarle primero al derivado haría que añadir una raíz se leyera como error
+ hasta la siguiente cosecha, que es castigar al que arregla el manifiesto."""
+ if receta in raices:
+ return True
+ perfs = memb.get(receta)
+ if perfs is None:
+ return None
+ return perfil in perfs
+
habilitados, problemas = [], []
for lbl in p["servicios_exp"]:
cands = decl.get(lbl, [])
if not cands:
problemas.append(f"ERROR {lbl}: habilitado por el perfil y NINGUNA receta lo declara")
continue
- if len(cands) > 1:
+ # La unicidad del label se juzga DENTRO del perfil, no en el corpus entero: el mismo
+ # servicio existe legítimamente en dos colas (p.ej. `upower` en incoming-gnome y en
+ # incoming-kde) y son la misma unidad en dos imágenes distintas. Exigir unicidad global
+ # convertiría esa convivencia normal en un error.
+ dentro = [(r, s) for r, s in cands if en_el_perfil(r) is True]
+ incierto = [(r, s) for r, s in cands if en_el_perfil(r) is None]
+ if not dentro and incierto:
+ quienes = ", ".join(r for r, _ in incierto)
+ problemas.append(f"AVISO {lbl}: no puedo comprobar que `{quienes}` esté en el perfil "
+ f"(sin build-state.json y no es raíz)")
+ receta, svc = incierto[0]
+ elif not dentro:
quienes = ", ".join(r for r, _ in cands)
- problemas.append(f"ERROR {lbl}: lo declaran {len(cands)} recetas ({quienes})")
+ problemas.append(
+ f"ERROR {lbl}: lo declara(n) {quienes}, que NO pertenece(n) al perfil ⇒ el card "
+ f"apuntaría a un binario que la imagen no trae")
continue
- receta, svc = cands[0]
- habilitados.append((lbl, receta, svc.get("exec", "?")))
- # La receta que lo trae tiene que estar EN la imagen. Si no, el card apuntaría a un binario
- # que el rootfs no contiene: arje lo encarnaría con ENOENT en cada reintento del backoff.
- perfs = memb.get(receta)
- if perfs is None:
- if receta not in raices:
- problemas.append(
- f"AVISO {lbl}: no puedo comprobar que `{receta}` esté en el perfil "
- f"(sin build-state.json y no es raíz)")
- elif perfil not in perfs:
+ elif len(dentro) > 1:
+ quienes = ", ".join(r for r, _ in dentro)
+ problemas.append(f"ERROR {lbl}: lo declaran {len(dentro)} recetas DEL PERFIL ({quienes})")
+ continue
+ else:
+ receta, svc = dentro[0]
+ if svc.get("scope", "system") != "system":
problemas.append(
- f"ERROR {lbl}: lo declara `{receta}`, que NO pertenece al perfil "
- f"(está en: {', '.join(perfs) or 'ningún perfil'})")
+ f"AVISO {lbl}: es de SESIÓN (`{receta}`) — hoy lo lanza el script de sesión, no "
+ f"arje: fuera de mirada nadie entrega cards de sesión (SDD 30 §4c)")
+ habilitados.append((lbl, receta, svc.get("exec", "?")))
# La inversa: paquetes del perfil que TRAEN demonio y nadie arranca.
hab = {l for l, _, _ in habilitados}
@@ -148,14 +194,65 @@ def resolver_servicios(perfil, perfiles=None):
if lbl in hab:
continue
for receta, _ in cands:
- en_perfil = perfil in memb.get(receta, []) if memb else receta in raices
- if en_perfil:
+ if en_el_perfil(receta) is True:
problemas.append(
f"AVISO {lbl}: `{receta}` está en la imagen y TRAE este servicio, "
f"pero el perfil no lo arranca")
return habilitados, problemas
+def autotest():
+ """Roturas A PROPÓSITO + un control que TIENE que pasar.
+
+ Un guardián que nunca falló no se sabe si sirve, y uno que sólo se prueba con casos rotos puede
+ estar gritando siempre. Por eso el primer caso es el BUENO: si ese no pasa en verde, el resto de
+ los ✓ no prueban nada."""
+ global declaraciones, _membresia
+ orig_d, orig_m = declaraciones, _membresia
+ SVC = {"label": "sshd", "exec": "/bin/busybox"}
+ SES = {"label": "pipewire", "exec": "/usr/bin/pipewire", "scope": "session"}
+
+ def perfil(svcs, raices=("openssh",)):
+ return dict(descripcion="", cola="corpus", hereda=[], paquetes=list(raices),
+ servicios=list(svcs), raices=list(raices), servicios_exp=list(svcs))
+
+ casos = [
+ # (nombre, perfil, declaraciones, membresía, ¿espera ERROR?)
+ ("CONTROL: declarado, habilitado y la receta en el perfil",
+ perfil(["sshd"]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
+ ("habilitado y NADIE lo declara",
+ perfil(["sshd"]), {}, {}, True),
+ ("dos recetas DEL PERFIL declaran el mismo label",
+ perfil(["sshd"], ("openssh", "dropbear")),
+ {"sshd": [("openssh", SVC), ("dropbear", SVC)]}, {}, True),
+ ("lo declara una receta que NO está en el perfil",
+ perfil(["sshd"], ()), {"sshd": [("openssh", SVC)]}, {"openssh": ["otro"]}, True),
+ ("mismo label en DOS COLAS, sólo una en el perfil ⇒ NO es colisión",
+ perfil(["sshd"]), {"sshd": [("openssh", SVC), ("openssh-kde", SVC)]},
+ {"openssh": ["prueba"], "openssh-kde": ["otro"]}, False),
+ ("servicio de SESIÓN ⇒ avisa pero no rompe",
+ perfil(["pipewire"], ("pipewire",)), {"pipewire": [("pipewire", SES)]}, {}, False),
+ ("la INVERSA: en la imagen, trae demonio, nadie lo arranca ⇒ AVISO",
+ perfil([]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
+ ]
+ todo_ok = True
+ try:
+ for nombre, p_, decl, memb, espera in casos:
+ declaraciones = lambda *a, **k: decl
+ _membresia = lambda: memb
+ _, prob = resolver_servicios("prueba", {"prueba": p_})
+ hay = any(x.startswith("ERROR") for x in prob)
+ ok = hay == espera
+ todo_ok &= ok
+ print(f"{'✓' if ok else '✗'} {nombre}")
+ for x in prob:
+ print(f" {x}")
+ finally:
+ declaraciones, _membresia = orig_d, orig_m
+ print("TODAS OK" if todo_ok else "HAY FALLOS")
+ return todo_ok
+
+
def main():
args = sys.argv[1:]
try:
@@ -169,9 +266,11 @@ def main():
for n, p in sorted(perfiles.items()):
print(f"{n:20} {len(p['raices']):3} raíces [{p['cola']}] {p['descripcion']}")
return 0
- if args[0] in ("--servicios", "-s"):
+ if args[0] == "--selftest":
+ return 0 if autotest() else 1
+ if args[0] in ("--services", "-s"):
if len(args) < 2 or args[1] not in perfiles:
- print(f"uso: targets.py --servicios <perfil> (hay: {', '.join(sorted(perfiles))})",
+ print(f"uso: targets.py --services <perfil> (hay: {', '.join(sorted(perfiles))})",
file=sys.stderr)
return 1
habilitados, problemas = resolver_servicios(args[1], perfiles)