Files
takana/docs/30-servicios-de-paquete.md
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

401 lines
25 KiB
Markdown

# SDD 30 — Los servicios que trae un paquete
> **Estado:** §3, §4a y §4b IMPLEMENTADOS y **VALIDADOS EN ARRANQUE REAL** (2026-09-12/14);
> §4c CERRADO también para GNOME (2026-09-14, con control); §5 abierto. Nace de una pregunta directa:
> *«los paquetes que tienen sus servicios para systemd, openrc u otros, ¿ya saben empaquetarse en
> takana con arje?»*. La respuesta medida era **no**, y este documento dice exactamente qué faltaba,
> qué se cerró y qué queda.
>
> Hermanos: [SDD 12](12-init-real.md) (arje como PID 1, contrato de la seed) ·
> [SDD 06](06-swm-format.md) (la mutación `init_rule`) · `tawasuyu/03_ukupacha/arje/DE-SYSTEMD-A-ARJE.md`
> (el manual de correspondencia de arje, autoritativo sobre el lado init).
## 1. El hueco, medido
Cuatro hallazgos, todos verificados en el árbol el 2026-09-12:
1. **La receta no tenía dónde declararlo.** `Recipe` era
`name, version, source, build, deps, evidence, slots, license, foreign`. Ninguna de las 979
recetas podía decir «yo traigo un demonio».
2. **Los dos servicios del producto vivían en constantes de Rust.**
`takana_bootstrap::STAGE1_SEED_CARD` (hammerd + console-getty) y `SSHD_SERVICE_CARD`, compuestas
por `product_seed_card()`. Añadir un tercero era editar Rust y recompilar takana.
3. **Los escritorios no usaban arje para esto en absoluto.** `scripts/gnome/gnome-start-qemu.sh`
lanza a mano `dbus-daemon --system --fork`, `arje-logind-compat &`, `accounts-daemon &`,
`upowerd &`, `pipewire &`, `pipewire-pulse &`, `wireplumber &` y `colord &`, con esperas de
socket por `sleep 0.25` en bucle; la imagen parchea la seed con python para que `console-getty`
ejecute `plasma-start` en vez de `/bin/sh`. Es **un card por IMAGEN, no por paquete**: esos ocho
demonios corren sin supervisión, sin backoff y sin el `CRASHED` real — justo lo que arje es PID 1
para dar.
4. **Las unidades upstream se sellan inertes.** 62 ficheros `.service` en el store (61 de usuario,
1 de sistema: `linux-pam`), que nadie lee ni traduce. Los `-Dsystemd=disabled` de las recetas son
por *dependencia*, no política de servicios.
## 2. Por qué NO alcanza con `arje-absorb`
arje **ya trae el traductor**: `init/arje-absorb`, con lectores de systemd, OpenRC, runit, dinit y
sysvinit (`--from auto --root / --output seed.card.json`). Es la primera respuesta que uno da, y es
la equivocada — por una razón exacta, escrita en su propio módulo de systemd:
> *Se absorbe lo que está **habilitado**, no todo lo instalado: los symlinks de `<target>.wants/`.
> Una unidad instalada pero no habilitada no arranca hoy, y meterla en el genesis la haría arrancar
> mañana — cambiaría el sistema en vez de retratarlo.*
`arje-absorb` **retrata un sistema vivo**. Un rootfs de takana no tiene ese estado que retratar:
medido, hay **62 `.service` sellados y CERO directorios `.wants`** — nadie corrió nunca un
`systemctl enable`, porque no hay systemd. Absorber un rootfs nuestro devuelve la lista vacía, y es
la respuesta **correcta** a la pregunta que absorb contesta.
⇒ **El «enable» de una distro construida desde fuente no se puede leer del árbol: hay que
declararlo.** Ese es el trabajo que le toca a takana, y es el que faltaba.
## 3. La decisión: declarar en dos mitades
Se parte igual que systemd parte `[Service]` de `[Install]`, porque son dos hechos de dueño distinto:
| hecho | dueño | dónde |
|---|---|---|
| **qué es** el servicio (exec, argv, supervisión, cgroup, red) | el PAQUETE | `[[service]]` en la receta ✅ |
| **si arranca** en esta imagen | el PERFIL | `docs/state/targets.toml` ◑ (§4) |
### 3.1 `[[service]]` en la receta (implementado)
`crates/takana-core/src/service.rs`. Se traduce 1:1 a una Card de arje (`Service::card()`), con la
forma del esquema validado de `card_core::Card`. Ejemplo real, `recipes/openssh.toml`:
```toml
[[service]]
label = "sshd"
id = "01HQAR53D4M2NBV8KZTYXFQA12"
exec = "/bin/busybox"
argv = ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
envp = [["HOME", "/root"]]
networking = "full"
cgroup = "arje.slice/sshd"
restart = { initial_ms = 500, max_ms = 20000 }
```
Tres propiedades que no son de estilo:
- **Fuera de `hash_inputs`**, como `license`, `slots` y `evidence`: describe cómo se *supervisa* el
artefacto, no qué bytes tiene ⇒ se puebla en recetas YA SELLADAS sin mover un `ArtifactHash`.
**Medido**: el hash de openssh es `b3:938835e6…` con el binario viejo (que ignora el bloque) y el
mismo con el nuevo (que lo parsea y lo excluye). Si entrara al hash, declarar los servicios del
corpus costaría reconstruirlo entero y no se haría nunca.
- **El `id` (ULID) se declara, no se genera.** Un id aleatorio haría que la seed —y con ella el
`product_rootfs_hash`— cambiara en cada corrida. La reproducibilidad del producto depende de que
esto sea estable, así que `validate()` lo exige y el de sshd es *el que la constante ya usaba*.
- **Se valida al CARGAR la receta**, no al emitir la seed: un `id` mal puesto tiene que verse en la
receta, no tres pasos después en un rootfs que ya no reproduce y no dice por qué.
**La prueba que autoriza el diseño** (`la_receta_de_openssh_reproduce_la_card_hardcodeada`): el card
generado desde `recipes/openssh.toml` se compara **entero** contra `SSHD_SERVICE_CARD`, que es la
card que el producto **ya bootea en QEMU** (e2e por `scripts/ssh-e2e-test.sh`). Empatan ⇒ mover el
servicio de la constante a la receta no cambia un byte de la seed. No es «una card parecida»: es la
misma.
### 3.2 Los tres árboles que no se tocaban — decisión
Había **tres convenciones de «dónde vive un servicio»**, ninguna conectada con las otras:
| árbol | quién lo escribe | quién lo lee |
|---|---|---|
| `/ente/seed.card.json` (genesis) | `takana_bootstrap` | **arje-zero al boot** ✅ |
| `/etc/arje/cards.d/*.json` | nadie | **arje, bajo demanda** (`ARJE_CARDS_DIR`) ✅ |
| `/etc/hammer/init.d/<svc>.rule` | la mutación `init_rule` del `.swm` | **NADIE** ❌ |
`docs/06-swm-format.md` afirma que «el init (arje) lee ese árbol». **No lo lee**: el único uso de
`INIT_RULES_DIR` en todo el repo es escribirlo, y el contrato real de arje son los dos primeros.
Para colmo `takana query service:<n>` busca en un CUARTO juego de rutas (`/etc/init.d/`,
`/etc/service/<n>/run`).
**Decisión:** los árboles canónicos son los de arje — **genesis de la seed** para lo que arranca al
boot, **`cards.d/`** para lo que se levanta bajo demanda. `/etc/hammer/init.d/*.rule` queda
**derogado**: o la mutación `init_rule` emite un card a `cards.d/`, o se retira del formato `.swm`.
Es su propia unidad de trabajo (toca el esquema `.swm`, que es contrato firmado) y hasta entonces la
afirmación del SDD 06 queda marcada como falsa aquí.
## 4. Lo que queda: del card a la imagen
**(a) El perfil habilita — HECHO.** `servicios = [...]` por perfil en `targets.toml` (se hereda como
`paquetes`), y `scripts/targets.py --services <perfil>` resuelve label → receta → exec cruzando las
declaraciones del corpus con la membresía de perfil de los grafos de estado (los CINCO: el del
corpus más los de cada cola, porque las recetas de un escritorio viven en `recipes/incoming-<x>/` y
su membresía está en el grafo de SU cola).
Lo que más valió no fue resolver, fue **la comprobación inversa**: avisar de los paquetes que están
en la imagen, TRAEN un demonio y el perfil no arranca. Y encontró algo el primer día:
> **`arje-logind-compat` y `arje-polkit-compat` 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. Dos binarios imprescindibles, presentes en la imagen y ausentes del destino
> declarado. Es la misma forma del agujero de `foot`, encontrada esta vez por una comprobación en
> vez de por una imagen inusable. Corregido: son raíces de `escritorio-gnome` (los dos) y de
> `escritorio-cosmic` (sólo logind-compat; sus scripts no nombran polkit-compat).
**Precedencia, y no es un detalle:** una RAÍZ del perfil está en el perfil por definición, aunque el
grafo todavía no lo diga. `build-state.json` es DERIVADO de `targets.toml` y 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 — castigar al que arregla el manifiesto.
El guardián se prueba solo: `scripts/targets.py --selftest` corre 7 casos —**el primero es el
CONTROL, que tiene que pasar en verde**— incluidos «dos recetas del mismo perfil declaran el mismo
label» (ERROR) y «el mismo label en dos colas distintas» (NO es colisión: `upower` existe
legítimamente en incoming-gnome y en incoming-kde, y son la misma unidad en dos imágenes).
**(b) El emisor — HECHO, y la trampa quedó armada en un test.** `service_cards()` lee la receta que
viaja DENTRO de cada artefacto sellado (el sidecar de provenance `.hammer/recipe.toml`, vía
`Store::recipe_for_hash`) y filtra `scope = "system"`. Se lee de ahí y no de `recipes/` porque
`seal_product_rootfs()` sirve a las DOS vetas —`product` desde recetas locales y `product_from_repo`
desde el repo firmado— y **sólo una tiene `recipes/` a mano**: el sidecar es la única fuente que
ambas comparten, que es lo que sostiene que las dos converjan al mismo `product-rootfs`.
La trampa era real y sigue ahí: **el sidecar no entra en el `ArtifactHash`**, así que un artefacto
sellado antes de que su receta declarara servicios es un cache-hit perfectamente válido y no se
reconstruye nunca solo. Si el emisor devolviera lista vacía sin protestar, el producto saldría
**booteando, verde y sin sshd**. Por eso `service_cards()` **falla ruidosamente** con un mensaje que
explica la causa y da el arreglo, y hay un test
(`service_cards_falla_ruidosamente_con_sidecar_rancio`) que exige que el mensaje siga diciendo las
dos cosas. Es la regla 3 del CLAUDE.md aplicada antes de pagarla: un ausente falla ruidosamente; un
vacío llega hasta el final diciendo que todo fue bien.
### 4b.1 La migración de openssh, medida — y un hecho nuevo
Re-sellar openssh era el paso que faltaba, y se hizo **con control** porque openssh no estaba
certificado como reproducible (su propia receta dice «el criterio es construye y corre»). El
procedimiento, por si hay que repetirlo con otro componente de servicio:
1. **Manifiesto de control ANTES de tocar nada**: `sha256` + modo de los 33 ficheros.
2. **Respaldo por hardlink** — y acá saltó el gotcha conocido: `store/` es un bind-mount de
`/dev/sdb` y `work/` vive en `/dev/sdc`, así que `cp -al` a `work/` muere con `Invalid
cross-device link`. El respaldo tiene que ir **dentro del mount del store**; `store/.respaldo-…`
sirve y es invisible para `store-gc.sh`, que sólo considera dirs `^[0-9a-f]{64}-`.
3. Borrar el artefacto (los bytes sobreviven por el respaldo: 11 enlaces → 10) y reconstruir bajo
`flock -o`.
4. Comparar árbol nuevo contra el manifiesto.
**Resultado: openssh REPRODUCE BIT A BIT.** 33/33 ficheros, modos idénticos, y la única diferencia
en todo el árbol es `.hammer/recipe.toml` — exactamente el sidecar que se quería migrar y nada más.
Eso es un hecho nuevo sobre el corpus, no sólo un paso de esta campaña: openssh pasa de «no
certificado» a «reproduce», comprobado contra un control tomado antes del borrado.
Coste que conviene saber: los árboles ya hidratados compartían inodes con el artefacto viejo, y el
nuevo tiene inodes propios (`%h = 1`). El contenido es el mismo, pero hasta que esos árboles se
refresquen el disco lleva dos copias.
### 4b.2 La cadena entera, probada en un arranque real
No alcanza con que los tests pasen: el punto de todo esto es que **un servicio declarado en una
receta termine supervisado por PID 1**. Medido el 2026-09-14, `scripts/product-boot-test.sh` sobre
el `product-rootfs` ensamblado por la ruta real:
```
ok card sshd en la seed
==> boot QEMU (mem 2048), hostfwd :2223->:22
PRODUCT_SSH_OK uid=0
```
La cadena completa, sin ningún eslabón escrito a mano:
recipes/openssh.toml `[[service]]`
→ sidecar `.hammer/recipe.toml` DENTRO del artefacto sellado
`service_cards()` (scope = system)
`genesis` de `/ente/seed.card.json`
→ arje-zero encarna sshd al boot
→ la sesión SSH responde
**Y el hash no se movió por esto.** El `product-rootfs` salió con hash distinto al anterior
(`5011955a…``8639894332…`), y había que saber si lo movía este cambio: **no**. La seed de ambos
árboles es **byte-idéntica** (`diff` sobre el JSON: sin diferencias); lo que cambió fueron `netup`
—otro artefacto que cuando se selló el viejo— y, por arrastre, `ente/attest.json`, que registra su
BLAKE3. La constante y la receta producen exactamente la misma seed, comprobado sobre el árbol real
y no sólo en un test.
**(c) Los de GNOME: DECLARADOS, no todavía arrancados por arje.** Las nueve unidades que
`gnome-start-qemu.sh` levanta con `&` están declaradas en sus recetas y habilitadas en el perfil
(`dbus-system`, `logind-compat`, `polkit-compat`, `accounts-daemon`, `upowerd`, `colord` de sistema;
`pipewire`, `pipewire-pulse`, `wireplumber` de sesión). Verificado que ninguna movió su hash: 9/9
idénticos a los que los grafos ya registraban.
Dos cosas que NO son transcripción y hay que saber:
1. **`dbus-daemon --fork` no se puede traducir tal cual.** arje supervisa al **hijo directo**:
`Type=forking` no existe en su modelo, 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 --nopidfile`.
2. **`scope = "system" | "session"`.** No es cosmético: decide DÓNDE va la Card. Las de sesión
necesitan `XDG_RUNTIME_DIR` y un usuario logueado, así que en el `genesis` arrancarían antes de
que exista ninguna de las dos cosas. Y su destino **no está resuelto fuera de mirada** — ahí las
arma el compositor (`mirada-compositor/src/session.rs`, `requires = [wayland_floor()]`, entregadas
a PID 1 por `RunCard`), pero mutter y kwin no tienen esa integración. `--services` avisa por cada
una: el hueco queda contado, no omitido.
#### 4c.1 GNOME con los demonios supervisados por arje — medido contra un control
El bloqueo que este documento daba por corpus (gnome-shell en deuda) **se levantó solo**: otro
agente selló `evolution-data-server` y `gnome-shell` mientras esto se escribía. `escritorio-gnome`
quedó 309/309 y el cierre hidrata completo (319 nodos, 8,2 G).
**Se corrió un CONTROL primero**, y fue lo que hizo interpretable el resultado: la misma imagen sin
inyectar cards, para saber si GNOME arranca hoy en este hub. Arranca — y trae un fallo propio que
sirvió de sonda.
| | control (el script con `&`) | con Cards en el `genesis` |
|---|---|---|
| `colord` | `!! colord MURIÓ al arrancar` | `ya vive (pid 175)` — lo encarnó PID 1 |
| `org.freedesktop.ColorManager` | `NO apareció en 40s` | **`OK`** |
| `login1` / `Accounts` / `UPower` | OK | OK |
| compositor | `wayland-0`, shell vivo | `wayland-0`, shell vivo |
**El fallo del control desapareció.** No es una mejora buscada: `colord` moría arrancado por el
script y vive arrancado por arje, con el bus ya listo porque la espera está dentro de su `argv`. Es
exactamente lo que un supervisor está para dar, y el control es lo único que permite decirlo — sin
él, «ColorManager OK» sería un dato suelto en vez de una diferencia.
Los PIDs lo confirman: `polkit-compat`=119, `colord`=175, `upowerd`=178, `accounts-daemon`=180 —
números bajos, de antes de que el lanzador de sesión existiera.
**Qué NO se comprobó:** no hay `screendump`. QEMU salió por timeout antes de poder pedirlo, y el
control tampoco lo tuvo, así que la comparación es serial contra serial. Lo que se afirma acá es
sobre los DEMONIOS (nombres del bus, PIDs, supervisión), no sobre el pintado; para afirmar que la
imagen pinta sigue rigiendo la regla de validar escritorios con pantalla.
#### Las piezas, y por qué cada una está donde está
- **`takana service-cards <recetas…>`** — la traducción `[[service]]` → Card, con **una sola
implementación**. El script de imagen ya parchea la seed con python; si además compusiera el JSON
de la Card habría dos generadores del mismo formato divergiendo en silencio, y el formato es
contrato con `card_core::Card`, que no es nuestro.
- **`targets.py --service-paths <perfil>`** — une las dos mitades: qué es el servicio (receta) y si
arranca (perfil). Sólo `scope = system`.
- **`scripts/gnome/inyectar-cards.py`** — compone la seed, idempotente por `label`. Es un fichero y
no un heredoc porque **el script de imagen ya tiene uno de python y anidar dos falló en vivo**: el
terminador del interno cierra el externo y media cosa se ejecuta como shell.
- **La espera del bus dentro del `argv`** de las cinco recetas de sistema. Sin ella un daemon puede
arrancar antes de que dbus escuche y quedarse en modo idle sin registrar su nombre — un fallo que
**no se ve**, porque el proceso vive y el bus no lo tiene. Los 5 hashes no se movieron.
- **El guardia de `gnome-start` es por «¿está corriendo?», no por una perilla de la imagen.** Así es
correcto venga de donde venga el proceso, y la misma copia sirve en una imagen sin cards. Una
condición que consulta el estado real no puede desincronizarse de él; un flag sí.
#### Lo que este frente se encontró, no lo que se propuso
`--service-paths` devolvió **nueve** servicios de sistema y sólo seis eran míos: otro agente declaró
`cupsd`, `bluetoothd` y `NetworkManager` en sus recetas y los habilitó en el perfil, con `-f`,
`--nodetach` y `--no-daemon` — o sea que leyó y aplicó la regla de que arje supervisa al hijo directo
y `Type=forking` no existe. El mecanismo se usó sin que nadie lo coordinara, que es la única prueba
que vale de que el sitio donde se puso la declaración era el correcto.
#### Lo que queda de §4c
La base de la imagen es el `product-rootfs`, no `work/metal-rootfs`: ese fichero **no existe en este
hub y ningún script del repo lo produce**. El producto sirve porque ya es arje-zero PID 1 + seed +
busybox, pero el andamiaje de imagen de escritorio sigue dependiendo de un insumo sin dueño, y eso es
su propia deuda.
Y las tres cosas de siempre: EXDEV. `store/` es bind-mount de `/dev/sdb` y `work/` vive en
`/dev/sdc`, así que **todo lo que enlaza en vez de copiar** se rompe al cruzar — lo pegaron
`hydrate-profile.py` (que ya lo detecta y dice dónde poner el `--into`) y el staging de
`install-image-efi.sh` (configurable por `STAGE`). Hidratado, fundido y staging tienen que vivir bajo
el mismo montaje.
## 4d. La otra mitad, que faltaba: `[[user]]` *(2026-09-14)*
Declarar cómo se supervisa un demonio no alcanza cuando el demonio **se niega a correr como root**.
`gitea` lo dice y sale:
[F] Gitea is not supposed to be run as root. If you need to use privileged TCP ports please
instead use `setcap` and the `cap_net_bind_service` permission.
Su Card hace `setuidgid gitea`, y sin la cuenta el servicio arranca, muere y reintenta para siempre
con un log que dice `unknown user` — no «a la imagen le falta una cuenta». El `/etc/passwd` de la
imagen era **la constante `takana_bootstrap::PRODUCT_PASSWD`** (`root` y `sshd`, nada más): añadir
un usuario era editar Rust y recompilar takana, que es **exactamente el hueco del §1 de este
documento**, resuelto para las Cards y dejado abierto para las cuentas. Simetría, no elegancia.
```toml
[[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`, como `[[service]]`: medido, el hash de `gitea` no se movió al declararlo.
### La pregunta que NO es la misma que la de los servicios
`--service-paths` lista las recetas de los servicios **habilitados**; `--user-paths` lista las de los
paquetes **instalados** que declaran cuentas. No es el mismo conjunto y confundirlos rompe el caso
normal: `postgres` instalado y sin levantar necesita su usuario igual, porque los ficheros que ya
están en la imagen **ya son suyos**.
### Fusionar, no escribir
`takana users <recetas…> --merge <rootfs>` fusiona **por clave, no por línea entera**: componer la
imagen dos veces no deja la cuenta dos veces (y `grep -q` sobre la línea completa no serviría — la
misma cuenta con el GECOS cambiado se leería como nueva). Tres decisiones, las tres con su control:
1. **Una cuenta ya presente con OTRA línea es un 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.
2. **Se planea todo y sólo entonces se escribe.** Fichero a fichero, un conflicto en `passwd`
dejaría el `group` ya escrito: media cuenta es peor que ninguna, porque parece que está.
3. **Un `passwd` ausente es un error, no un fichero a crear.** Crearlo dejaría una imagen **sin
`root`** y con el usuario del paquete.
Y la validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids (los reserva el
producto), uid fuera de `100..=65533`, `home` relativo, y un `:` en cualquier campo — que partiría
la línea en dos y fallaría lejos de acá.
**Verificado**: 6 tests del módulo, merge idempotente (segunda pasada `+0`), y los dos controles
negativos con el fichero **intacto** después. `scripts/servidor-image.sh` lo aplica entre hidratar el
perfil y sellar la imagen.
## 5. El hueco que no es nuestro: no hay readiness
arje ordena el arranque por **capacidades**, con orden topológico real
(`arje-zero/src/graph/resolve.rs::plan_spawn`: punto fijo de satisfacibilidad + Kahn, detecta ciclos
y descarta insatisfacibles). Pero eso ordena el **spawn**, no el **estar listo**: no existe una
primitiva «esperá a que aparezca este socket». El propio manual de arje lo lista como hueco abierto
(§13: *«readiness real de `Type=notify` — el shim registra pero no ordena»*).
Es lo que el script de GNOME resuelve con `while [ ! -S "$XDG_RUNTIME_DIR/pipewire-0" ]; sleep 0.25`.
Mientras el hueco siga abierto, un card que necesita esperar lo hace **dentro de su propio `argv`**
con `sh -c '…; exec …'` — que es lo que ya hace el de sshd (levanta la red y genera las host keys
antes del `exec`). Este diseño **no lo esconde**: lo deja a la vista en el `argv` en vez de meterlo
en un wrapper, para que se vea cuántos servicios están pagando el hueco y valga como argumento río
arriba en arje.
## 6. Estado
| pieza | estado |
|---|---|
| `[[service]]` en la receta + traducción a Card + validación | ✅ `crates/takana-core/src/service.rs` |
| Fuera de `hash_inputs` (medido, hash de openssh sin mover) | ✅ |
| Equivalencia receta → card hardcodeada de sshd | ✅ test en `takana-bootstrap` |
| `openssh` declara su servicio | ✅ `recipes/openssh.toml` |
| El perfil habilita (`servicios` + `targets.py --services`) | ✅ §4a |
| Guardián con roturas a propósito + control (`--selftest`) | ✅ 7/7 |
| El vigía CORRE SOLO (cosecha-cron) → `docs/state/servicios.txt` | ✅ 0 errores, 18 avisos |
| Los dos compat que no estaban en ningún perfil | ✅ corregido §4a |
| Los 9 de GNOME declarados y habilitados (hash sin mover, 9/9) | ✅ §4c |
| Emisor desde el sidecar (`service_cards`, falla ruidosa + test) | ✅ §4b |
| Re-sellado de openssh con control → **reproduce bit a bit** | ✅ §4b.1 |
| La cadena receta→sidecar→card→genesis→arje, **en arranque real** | ✅ §4b.2 (`PRODUCT_SSH_OK`) |
| Que la imagen de ESCRITORIO los arranque y no el script `&` | ✅ §4c.1, con control (y arregló `colord`) |
| `takana service-cards` + `--service-paths` + el inyector | ✅ §4c.1 |
| `[[user]]` en la receta + validación + fusión idempotente | ✅ `crates/takana-core/src/user.rs`, §4d |
| `takana users` (+`--merge`) y `targets.py --user-paths` | ✅ §4d |
| `gitea` declara su servicio **y** su cuenta (hash sin mover) | ✅ `recipes/gitea.toml` |
| El merge de cuentas en el camino de imagen | ✅ `scripts/servidor-image.sh` |
| Que el resto de imágenes (escritorios) llamen al merge | ◻ hoy sólo `servidor-image.sh` |
| `escritorio-cosmic`: auditar su `cosmic-start.sh` y habilitar | ◻ hoy sale con 5 AVISO |
**La primera lectura del vigía**, para que no haya que creerle a este documento: 0 errores y 18
avisos. El más ruidoso es `dbus-system`, que viaja en **seis** perfiles y sólo GNOME lo arranca —
que es exactamente el tipo de hecho que antes no tenía dónde verse.
| Derogar `/etc/hammer/init.d/*.rule` en el `.swm` | ◻ §3.2 |
| Readiness por unidad en arje | ◻ §5 — río arriba |