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();
+28
View File
@@ -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]
+17
View File
@@ -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 }
+7
View File
@@ -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 }
+23
View File
@@ -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 }
@@ -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 }
+9
View File
@@ -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 }
+7
View File
@@ -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 }
+10
View File
@@ -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 }
+27
View File
@@ -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 }
+127 -28
View File
@@ -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})")
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:
problemas.append(
f"ERROR {lbl}: lo declara `{receta}`, que NO pertenece al perfil "
f"(está en: {', '.join(perfs) or 'ningún perfil'})")
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
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"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)