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' 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' 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-/` 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 (hay: {', '.join(sorted(perfiles))})", + print(f"uso: targets.py --services (hay: {', '.join(sorted(perfiles))})", file=sys.stderr) return 1 habilitados, problemas = resolver_servicios(args[1], perfiles)