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:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user