Files
SergioandClaude Opus 5 d611f8577d [[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta
El SDD 30 sacó las Cards de una constante de Rust y las puso en la receta. Los USUARIOS se quedaron
donde estaban las Cards: `/etc/passwd` de la imagen es `takana_bootstrap::PRODUCT_PASSWD`, un literal
con `root` y `sshd`, y añadir un tercero era editar Rust y recompilar takana.

No es simetría por elegancia, es un servicio que no arranca: `gitea` se niega a correr como root, su
Card hace `setuidgid gitea`, y sin la cuenta arranca, muere y reintenta para siempre con un log que
dice `unknown user` — no «a la imagen le falta una cuenta».

    [[user]]
    name = "gitea"
    uid  = 916
    home = "/var/lib/gitea"

**El uid se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre a
partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje
de reproducir SIN QUE NADA FALLE — los ficheros se ven iguales y `ls -l` dice otro número. Fuera de
`hash_inputs`, medido: el hash de gitea no se movió (`b3:391a613e…` antes y después).

`takana users <recetas…> [--merge <rootfs>]` es el gemelo de `service-cards`, y fusiona **por clave,
no por línea entera** — componer dos veces no duplica, y `grep -q` de la línea completa no serviría
porque la misma cuenta con otro GECOS se leería como nueva. Tres decisiones con su control:

· Una cuenta ya presente con OTRA línea es CONFLICTO y no se pisa: sale ≠0. Pisarla es cambiarle el
  uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
· Se planea todo y sólo entonces se escribe. Fichero a fichero, un conflicto en `passwd` dejaba el
  `group` ya escrito: media cuenta es peor que ninguna, porque parece que está. Comprobado con el
  caso exacto — `group` sin la cuenta, `passwd` con otro uid — y los DOS ficheros quedan intactos.
· Un `passwd` ausente es un error, no un fichero a crear: crearlo dejaría una imagen SIN `root`.

La validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids, uid fuera de
100..=65533, `home` relativo y un `:` en cualquier campo — que partiría la línea y fallaría lejos.

Y `--user-paths` NO es `--service-paths` con otro nombre: aquél lista los servicios HABILITADOS,
éste los paquetes INSTALADOS que declaran cuentas. `postgres` instalado y sin levantar necesita su
usuario igual, porque los ficheros de la imagen ya son suyos.

`scripts/servidor-image.sh` lo aplica entre hidratar el perfil y sellar la imagen, y aborta si hay
conflicto: un rootfs con el uid equivocado produce ficheros de un dueño que no existe.

Verde: 6 tests del módulo, core 234, cli 88, bootstrap 42, `targets.py --selftest` 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
2026-09-14 15:39:52 +00:00

363 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
# targets.py — lee `docs/state/targets.toml` (el manifiesto de objetivo) y expande un perfil a su
# lista de RAÍCES. Un solo parser para dos consumidores: los scripts de imagen (shell) y el grafo
# de estado (`build-state.py`), para que el set de paquetes de la distro no vuelva a vivir en tres
# strings de shell distintos.
#
# 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 --services cli # los servicios que ESA imagen arranca, resueltos
# scripts/targets.py --selftest # roturas a propósito + un control que TIENE que pasar
# scripts/targets.py --service-paths gnome # rutas de receta de los servicios de SISTEMA
#
# ⚠ 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
# declarado, después las propias), porque hay imágenes donde el orden de hidratación importa
# (mirada: libs antes que binarios). Duplicados: se queda la primera aparición.
import json, sys, tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT = ROOT / "docs/state/targets.toml"
def load(path=DEFAULT):
"""Devuelve {perfil: {descripcion, cola, hereda, paquetes, raices}} con `raices` ya expandido."""
doc = tomllib.load(open(path, "rb"))
crudos = doc.get("perfil", {})
perfiles = {}
for n, p in crudos.items():
perfiles[n] = dict(
descripcion=p.get("descripcion", ""),
cola=p.get("cola", "corpus"),
hereda=list(p.get("hereda", [])),
paquetes=list(p.get("paquetes", [])),
# `servicios` = los LABELS que esta imagen arranca. Es el «enable» de la distro: el
# paquete declara QUÉ es el servicio (`[[service]]` en su receta) y el perfil decide SI
# arranca, igual que systemd parte [Service] de [Install]. Ver SDD 30 §3.
servicios=list(p.get("servicios", [])),
)
# Expansión de `hereda` con detección de ciclo (un manifiesto con un ciclo es un error del
# humano, no un caso a tolerar: sale ruidoso).
def expandir(n, camino):
if n in camino:
raise ValueError(f"ciclo en `hereda`: {' → '.join(camino + [n])}")
if n not in perfiles:
raise KeyError(f"perfil desconocido en `hereda`: {n}")
p = perfiles[n]
if "raices" in p:
return p["raices"]
vistos, raices = set(), []
vistos_s, svcs = set(), []
for h in p["hereda"] + [None]:
hp = perfiles[h] if h else None
for pkg in (expandir(h, camino + [n]) if h else p["paquetes"]):
if pkg not in vistos:
vistos.add(pkg); raices.append(pkg)
# los servicios heredados viajan con el perfil padre (ya expandido por la llamada de
# arriba): una imagen que hereda `cli` arranca también lo que `cli` arranca.
for s in (hp["servicios_exp"] if hp else p["servicios"]):
if s not in vistos_s:
vistos_s.add(s); svcs.append(s)
p["raices"] = raices
p["servicios_exp"] = svcs
return raices
for n in perfiles:
expandir(n, [])
return perfiles
# ── El lado «habilitar»: perfil → servicios resueltos ────────────────────────────────────────────
#
# Un servicio se declara en la receta (`[[service]]`, SDD 30 §3.1) y se HABILITA en el perfil
# (`servicios = [...]`). Este resolutor cruza las dos mitades y, sobre todo, hace la comprobación
# INVERSA — la que ninguna métrica hacía: un paquete del perfil que TRAE un demonio y que el perfil
# no arranca. Esa es la versión servicios de la lección de `foot` que este manifiesto ya aprendió
# caro: la métrica mide la clausura de lo DECLARADO y no puede ver lo que falta en la declaración.
# Una imagen puede salir 121/121 sellada, con el demonio adentro, y sin que nada lo levante nunca.
def declaraciones(recipes_dir=None):
"""Escanea el corpus y devuelve {label: [(receta, servicio_dict), ...]}.
Devuelve la LISTA por label (no el primero) a propósito: dos recetas que declaren el mismo
label es un error que hay que ver, y quedarse con una lo escondería."""
recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
por_label = {}
for f in sorted(recipes_dir.rglob("*.toml")):
try:
doc = tomllib.load(open(f, "rb"))
except tomllib.TOMLDecodeError:
continue # recetas rotas las reporta el grafo, no este resolutor
for s in doc.get("service", []):
lbl = s.get("label")
if lbl:
por_label.setdefault(lbl, []).append((doc.get("name", f.stem), s))
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 _ruta_de(nombre, recipes_dir=None):
"""La ruta de la receta que se llama `nombre`. Se busca por el campo `name`, no por el nombre de
fichero: son casi siempre iguales, pero el que manda es el campo."""
recipes_dir = Path(recipes_dir) if recipes_dir else ROOT / "recipes"
for f in sorted(recipes_dir.rglob("*.toml")):
try:
if tomllib.load(open(f, "rb")).get("name") == nombre:
return str(f.relative_to(ROOT))
except tomllib.TOMLDecodeError:
continue
return None
def _membresia():
"""{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):
"""Devuelve (habilitados, problemas) para un perfil.
habilitados: [(label, receta, exec)] en el orden declarado.
problemas: lista de strings. Distingue ERROR (rompe la imagen) de AVISO (huele mal)."""
perfiles = perfiles or load()
p = perfiles[perfil]
decl = declaraciones()
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
# 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 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}
for lbl, cands in sorted(decl.items()):
if lbl in hab:
continue
for receta, _ in cands:
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:
perfiles = load()
except FileNotFoundError:
print(f"no existe {DEFAULT.relative_to(ROOT)}", file=sys.stderr); return 1
except (ValueError, KeyError) as e:
print(f"manifiesto inválido: {e}", file=sys.stderr); return 1
if not args or args[0] in ("--lista", "-l"):
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] == "--selftest":
return 0 if autotest() else 1
if args[0] == "--service-paths":
# Las RUTAS de receta de los servicios de SISTEMA habilitados, para que el ensamblado de la
# imagen se las pase a `takana service cards`. Sólo `system`: una Card de sesión en el
# `genesis` arrancaría antes de que exista XDG_RUNTIME_DIR o un usuario (SDD 30 §4c).
if len(args) < 2 or args[1] not in perfiles:
print(f"uso: targets.py --service-paths <perfil>", file=sys.stderr)
return 1
decl = declaraciones()
memb = _membresia()
raices = set(perfiles[args[1]]["raices"])
vistas = []
for lbl in perfiles[args[1]]["servicios_exp"]:
for receta, svc in decl.get(lbl, []):
if svc.get("scope", "system") != "system":
continue
if not (receta in raices or args[1] in memb.get(receta, [])):
continue
ruta = _ruta_de(receta)
if ruta and ruta not in vistas:
vistas.append(ruta)
for r in vistas:
print(r)
return 0
if args[0] == "--user-paths":
# Las RUTAS de receta de los paquetes DEL PERFIL que declaran `[[user]]`, para que el
# ensamblado se las pase a `takana users --merge <rootfs>`. La pregunta NO es la misma que
# la de `--service-paths`: una cuenta hace falta porque el paquete está INSTALADO, no porque
# el perfil arranque su servicio. `postgres` instalado y sin levantar necesita su usuario
# igual — sus ficheros ya son suyos.
if len(args) < 2 or args[1] not in perfiles:
print("uso: targets.py --user-paths <perfil>", file=sys.stderr)
return 1
memb = _membresia()
raices = set(perfiles[args[1]]["raices"])
vistas = []
for f in sorted((ROOT / "recipes").rglob("*.toml")):
try:
doc = tomllib.load(open(f, "rb"))
except tomllib.TOMLDecodeError:
continue
if not doc.get("user"):
continue
receta = doc.get("name", f.stem)
if not (receta in raices or args[1] in memb.get(receta, [])):
continue
r = str(f.relative_to(ROOT))
if r not in vistas:
vistas.append(r)
for r in vistas:
print(r)
return 0
if args[0] in ("--services", "-s"):
if len(args) < 2 or args[1] not in perfiles:
print(f"uso: targets.py --services <perfil> (hay: {', '.join(sorted(perfiles))})",
file=sys.stderr)
return 1
habilitados, problemas = resolver_servicios(args[1], perfiles)
for lbl, receta, ex in habilitados:
print(f"{lbl:24} {receta:20} {ex}")
if not habilitados:
print("(el perfil no arranca ningún servicio)")
for pr in problemas:
print(pr, file=sys.stderr)
# Sólo un ERROR hace fallar: un AVISO es para leer, no para romper el cron.
return 1 if any(x.startswith("ERROR") for x in problemas) else 0
if args[0] == "--json":
print(json.dumps(perfiles, indent=1, ensure_ascii=False)); return 0
n = args[0]
if n not in perfiles:
print(f"perfil desconocido: {n} (hay: {', '.join(sorted(perfiles))})", file=sys.stderr)
return 1
print(" ".join(perfiles[n]["raices"]))
return 0
if __name__ == "__main__":
sys.exit(main())