> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
[[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
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 0879089f..da418808 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -67,6 +67,27 @@ enum Cmd {
#[arg(long, default_value = "system")]
scope: String,
},
+ /// [SDD 30] Emite las **cuentas de sistema** que declaran estas recetas (`[[user]]`) como
+ /// líneas de `/etc/passwd` y `/etc/group`, o las FUSIONA en un rootfs con `--merge`.
+ ///
+ /// Es la otra mitad de `service-cards`: la Card de `gitea` hace `setuidgid gitea` porque el
+ /// demonio se niega a correr como root, y sin la cuenta el servicio muere en bucle. Hasta hoy
+ /// el `passwd` de la imagen era una constante de Rust (`PRODUCT_PASSWD`) y añadir un usuario
+ /// era recompilar takana.
+ ///
+ /// `--merge` fusiona **por clave, no por línea**, y es idempotente: componer una imagen dos
+ /// veces no deja la cuenta dos veces. Una cuenta que ya está con OTRA línea es un conflicto y
+ /// sale ≠0 sin tocar el fichero — pisarla es cambiarle el uid a ficheros que ya son suyos.
+ Users {
+ /// Rutas de recetas TOML. `scripts/targets.py --user-paths <perfil>` las lista.
+ recipes: Vec<String>,
+ /// Qué emitir por stdout: `passwd` (default), `group`, `both` o `json`.
+ #[arg(long, default_value = "passwd")]
+ format: String,
+ /// Rootfs donde fusionar `etc/passwd` y `etc/group` en vez de imprimir.
+ #[arg(long)]
+ merge: Option<String>,
+ },
/// [Fase 0] Calcula el `ArtifactHash` de una receta SIN construir — puro sobre las recetas
/// (source_id + compiler/target/link + patches + flags + fases + hashes de deps). No baja
/// fuentes ni compila. Responde "¿cuál es el hash VIGENTE de esta receta?", que el store no
@@ -984,6 +1005,64 @@ fn main() -> anyhow::Result<()> {
}
println!("{}", serde_json::to_string_pretty(&cards)?);
}
+ Cmd::Users { recipes, format, merge } => {
+ let mut users: Vec<takana_core::user::User> = Vec::new();
+ for ruta in &recipes {
+ let r = takana_core::Recipe::load_from_path(ruta)?;
+ users.extend(r.users.iter().cloned());
+ }
+ // Las recetas se validan una por una al cargar; esto caza lo que sólo se ve al juntarlas:
+ // dos paquetes distintos que reclaman el MISMO uid con nombres distintos. En una imagen
+ // eso deja ficheros de dos dueños con el mismo número y no lo nota nadie.
+ takana_core::user::validate_all(&users)?;
+ let passwd: Vec<String> = users.iter().map(|u| u.passwd_line()).collect();
+ let group: Vec<String> = users.iter().map(|u| u.group_line()).collect();
+ if let Some(rootfs) = merge {
+ let root = std::path::Path::new(&rootfs);
+ // DOS PASADAS a propósito: se planea todo y sólo entonces se escribe. Fusionar y
+ // escribir fichero a fichero deja estado a medias cuando el segundo conflictúa —
+ // un `group` con la cuenta y un `passwd` sin ella es peor que no haber hecho nada,
+ // porque parece que la cuenta está.
+ let mut plan = Vec::new();
+ let mut conflictos_totales = Vec::new();
+ for (rel, lineas) in [("etc/passwd", &passwd), ("etc/group", &group)] {
+ let p = root.join(rel);
+ // Un `passwd` ausente es un rootfs que no es un rootfs: no se crea de la nada,
+ // porque hacerlo dejaría una imagen SIN `root` y con el usuario del paquete.
+ let previo = std::fs::read_to_string(&p).map_err(|e| {
+ anyhow::anyhow!("no pude leer {}: {e} — ¿es un rootfs?", p.display())
+ })?;
+ let (nuevo, anadidas, conflictos) = takana_core::user::fusionar(&previo, lineas);
+ for c in conflictos {
+ conflictos_totales.push(format!("{rel}: {c}"));
+ }
+ plan.push((rel, p, nuevo, anadidas));
+ }
+ if !conflictos_totales.is_empty() {
+ anyhow::bail!(
+ "cuenta(s) ya presentes con OTRA línea — no se tocó ningún fichero:\n {}",
+ conflictos_totales.join("\n ")
+ );
+ }
+ for (rel, p, nuevo, anadidas) in plan {
+ if !anadidas.is_empty() {
+ std::fs::write(&p, &nuevo)?;
+ }
+ eprintln!("{}: +{} cuenta(s)", rel, anadidas.len());
+ for a in &anadidas {
+ eprintln!(" {a}");
+ }
+ }
+ } else {
+ match format.as_str() {
+ "passwd" => println!("{}", passwd.join("\n")),
+ "group" => println!("{}", group.join("\n")),
+ "both" => println!("{}\n{}", passwd.join("\n"), group.join("\n")),
+ "json" => println!("{}", serde_json::to_string_pretty(&users)?),
+ otro => anyhow::bail!("--format inválido: {otro} (passwd | group | both | json)"),
+ }
+ }
+ }
Cmd::Hash { recipe, check } => {
let store = takana_core::Store::open(&cli.store)?;
let recipe = takana_core::Recipe::load_from_path(&recipe)?;
diff --git a/crates/takana-core/src/lib.rs b/crates/takana-core/src/lib.rs
index 9fcfed0d..c1ceb510 100644
--- a/crates/takana-core/src/lib.rs
+++ b/crates/takana-core/src/lib.rs
@@ -21,6 +21,7 @@ pub mod service;
pub mod sign;
pub mod store;
pub mod swm;
+pub mod user;
pub use caps::{AgentCapsConfig, CapRule};
pub use differs::{why_differs, Causa, Divergencia, Informe};
diff --git a/crates/takana-core/src/recipe.rs b/crates/takana-core/src/recipe.rs
index 507807f1..3cefe448 100644
--- a/crates/takana-core/src/recipe.rs
+++ b/crates/takana-core/src/recipe.rs
@@ -76,6 +76,20 @@ pub struct Recipe {
/// solo `ArtifactHash`.
#[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")]
pub services: Vec<crate::service::Service>,
+ /// **Las cuentas que este paquete necesita para correr** (`[[user]]`).
+ ///
+ /// La otra mitad del SDD 30, y no es simetría por elegancia: un demonio que se niega a correr
+ /// como root —`gitea` lo hace explícitamente— tiene su Card haciendo `setuidgid`, y sin la
+ /// cuenta muere en bucle. Hasta hoy el `/etc/passwd` de la imagen era la constante
+ /// `takana_bootstrap::PRODUCT_PASSWD`: añadir un usuario era editar Rust, exactamente lo que
+ /// este SDD arregló para las Cards y dejó sin arreglar para las cuentas.
+ ///
+ /// El `uid` **se declara, no se asigna** — ver [`crate::user`].
+ ///
+ /// **NO entra en `hash_inputs`**, como `license` y `[[service]]`: describe qué cuenta necesita
+ /// el artefacto para correr, no qué bytes tiene.
+ #[serde(default, rename = "user", skip_serializing_if = "Vec::is_empty")]
+ pub users: Vec<crate::user::User>,
/// Directorio base contra el que se resuelven rutas relativas de la receta
/// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
#[serde(skip, default)]
@@ -523,6 +537,9 @@ impl Recipe {
// después, en un rootfs que ya no reproduce y no dice por qué. Recetas sin `[[service]]`
// pasan por una lista vacía, así que esto no toca a las 979 existentes.
crate::service::validate_all(&r.services)?;
+ // Mismo criterio que los servicios: se valida al CARGAR. Un uid fuera de rango o un `:` en
+ // el nombre tienen que verse acá y no en el fichero `passwd` de una imagen ya compuesta.
+ crate::user::validate_all(&r.users)?;
Ok(r)
}
diff --git a/crates/takana-core/src/user.rs b/crates/takana-core/src/user.rs
new file mode 100644
index 00000000..3a46fa19
--- /dev/null
+++ b/crates/takana-core/src/user.rs
@@ -0,0 +1,294 @@
+//! `[[user]]` — **las cuentas que un paquete necesita para correr** (SDD 30, segunda mitad).
+//!
+//! ## Por qué existe
+//!
+//! El SDD 30 sacó las Cards de servicio de una constante de Rust y las puso en la receta: el
+//! paquete que trae el demonio es el que dice cómo se supervisa. Los **usuarios** se quedaron donde
+//! estaban las Cards — `takana_bootstrap::PRODUCT_PASSWD` es un literal con `root` y `sshd`, y
+//! añadir un tercero seguía siendo editar Rust y recompilar takana.
+//!
+//! Eso no es simetría incompleta: es un servicio que no arranca. `gitea` **se niega a correr como
+//! root** (`[F] Gitea is not supposed to be run as root`), así que su Card hace `setuidgid gitea` y
+//! sin la cuenta el demonio muere en bucle. El mismo patrón tienen postgres, caddy cuando no quiere
+//! privilegios, y cualquier cosa que haga `drop privileges` por su cuenta.
+//!
+//! ## El uid se DECLARA, no se asigna
+//!
+//! Igual que el ULID de una Card, y por la misma razón: un uid asignado al vuelo («el primero libre
+//! a partir de 1000») hace que dos imágenes del mismo perfil salgan con dueños distintos, y el
+//! rootfs **deja de reproducir sin que nada falle** — los ficheros se ven iguales, `ls -l` dice otro
+//! número. Que sea estable es requisito, no detalle.
+//!
+//! ## Fuera de `hash_inputs`
+//!
+//! Como `license`, `slots`, `evidence` y `[[service]]`: describe qué cuenta necesita el artefacto
+//! para correr, no qué bytes tiene. Declararlo en recetas YA SELLADAS no mueve un solo
+//! `ArtifactHash`.
+//!
+//! ## Lo que este módulo NO hace
+//!
+//! No crea usuarios ni escribe `/etc/passwd`: emite las LÍNEAS y quien compone la imagen las
+//! fusiona ([`User::passwd_line`], [`User::group_line`]). Tampoco pone contraseña: todas las cuentas
+//! salen con `x` en el campo de password y sin entrada en `/etc/shadow`, o sea **sin login posible**
+//! — una cuenta de servicio a la que alguien pueda entrar es una puerta que nadie declaró.
+
+use serde::{Deserialize, Serialize};
+
+/// `root` y `sshd` los pone el producto (`takana_bootstrap::PRODUCT_PASSWD`); `nobody` es
+/// convención universal. Una receta que los redeclare estaría pisando al producto con otro uid, y
+/// el síntoma sería un rootfs cuyos ficheros pertenecen a quien no debe.
+const RESERVADOS: &[(&str, u32)] = &[("root", 0), ("sshd", 74), ("nobody", 65534)];
+
+/// Una cuenta de sistema que una receta declara necesitar.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct User {
+ /// Nombre de la cuenta (`gitea`). Es también el nombre del grupo salvo que `group` diga otro.
+ pub name: String,
+ /// **Se declara, no se asigna** — ver el doc del módulo.
+ pub uid: u32,
+ /// Grupo primario. Ausente ⇒ el mismo número que `uid` (el patrón usuario-grupo propio).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub gid: Option<u32>,
+ /// Nombre del grupo primario. Ausente ⇒ `name`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub group: Option<String>,
+ /// Directorio personal, absoluto. Es donde el demonio guarda su estado (`/var/lib/gitea`).
+ pub home: String,
+ /// Shell. Default `/bin/false`: una cuenta de servicio no inicia sesión.
+ #[serde(default = "shell_por_defecto")]
+ pub shell: String,
+ /// Campo GECOS. Default: el nombre.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub comment: Option<String>,
+}
+
+fn shell_por_defecto() -> String {
+ "/bin/false".to_string()
+}
+
+impl User {
+ pub fn gid(&self) -> u32 {
+ self.gid.unwrap_or(self.uid)
+ }
+
+ pub fn group(&self) -> &str {
+ self.group.as_deref().unwrap_or(&self.name)
+ }
+
+ /// La línea de `/etc/passwd`, sin salto final.
+ pub fn passwd_line(&self) -> String {
+ let gecos = self.comment.as_deref().unwrap_or(&self.name);
+ format!(
+ "{}:x:{}:{}:{}:{}:{}",
+ self.name,
+ self.uid,
+ self.gid(),
+ gecos,
+ self.home,
+ self.shell
+ )
+ }
+
+ /// La línea de `/etc/group`, sin salto final.
+ pub fn group_line(&self) -> String {
+ format!("{}:x:{}:", self.group(), self.gid())
+ }
+
+ /// Valida la FORMA. No comprueba que el `home` exista: eso es del que compone la imagen.
+ pub fn validate(&self) -> crate::Result<()> {
+ let bad = |m: String| Err(crate::Error::Recipe(m));
+ // El nombre entra en una línea separada por `:` y es también nombre de fichero en algunos
+ // consumidores: un `:` de más parte la línea en dos campos y el fallo aparece lejos.
+ let nombre_ok = !self.name.is_empty()
+ && self.name.len() <= 32
+ && self
+ .name
+ .bytes()
+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
+ && !self.name.starts_with('-');
+ if !nombre_ok {
+ return bad(format!(
+ "user.name inválido: {:?} (minúsculas, dígitos, `_` y `-`; ≤32; sin `:`)",
+ self.name
+ ));
+ }
+ for (n, u) in RESERVADOS {
+ if self.name == *n || self.uid == *u {
+ return bad(format!(
+ "user[{}]: `{n}`/uid {u} lo reserva el producto o la convención — \
+ redeclararlo pisaría al dueño de ficheros que ya existen",
+ self.name
+ ));
+ }
+ }
+ // 1..100 es territorio de las cuentas históricas de las distros; ≥65534 son `nobody` y el
+ // uid inválido (u32::MAX). El rango de cuentas de servicio es el de en medio.
+ if !(100..=65533).contains(&self.uid) {
+ return bad(format!(
+ "user[{}].uid = {} fuera de rango: usá 100..=65533 (cuentas de servicio)",
+ self.name, self.uid
+ ));
+ }
+ if let Some(g) = self.gid {
+ if !(100..=65533).contains(&g) {
+ return bad(format!(
+ "user[{}].gid = {g} fuera de rango: usá 100..=65533",
+ self.name
+ ));
+ }
+ }
+ if !self.home.starts_with('/') || self.home.contains(':') {
+ return bad(format!(
+ "user[{}].home debe ser ruta absoluta y sin `:`, es {:?}",
+ self.name, self.home
+ ));
+ }
+ if !self.shell.starts_with('/') || self.shell.contains(':') {
+ return bad(format!(
+ "user[{}].shell debe ser ruta absoluta y sin `:`, es {:?}",
+ self.name, self.shell
+ ));
+ }
+ if let Some(c) = &self.comment {
+ if c.contains(':') {
+ return bad(format!("user[{}].comment no puede contener `:`", self.name));
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Valida una lista y rechaza nombres y uids repetidos **dentro de la misma receta**.
+pub fn validate_all(users: &[User]) -> crate::Result<()> {
+ let mut nombres = std::collections::BTreeSet::new();
+ let mut uids = std::collections::BTreeSet::new();
+ for u in users {
+ u.validate()?;
+ if !nombres.insert(&u.name) {
+ return Err(crate::Error::Recipe(format!("user.name duplicado: {:?}", u.name)));
+ }
+ if !uids.insert(u.uid) {
+ return Err(crate::Error::Recipe(format!(
+ "user.uid duplicado: {} (lo usan {:?} y otro)",
+ u.uid, u.name
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// Fusiona líneas en un fichero tipo `passwd`/`group` **por CLAVE, no por línea entera**.
+///
+/// Devuelve `(contenido_nuevo, añadidas, conflictos)`. Un conflicto es una cuenta cuyo nombre ya
+/// está y con otra línea: no se pisa nunca — se reporta. Pisar la entrada de un usuario existente
+/// es cambiarle el uid a los ficheros que ya son suyos, y eso no se ve hasta que algo no puede leer
+/// su propio estado.
+///
+/// La idempotencia es el requisito: componer una imagen dos veces no puede dejar la cuenta dos
+/// veces, y `grep -q` sobre la línea entera no alcanza porque la misma cuenta con el GECOS cambiado
+/// se leería como nueva.
+pub fn fusionar(contenido: &str, lineas: &[String]) -> (String, Vec<String>, Vec<String>) {
+ let clave = |l: &str| l.split(':').next().unwrap_or("").to_string();
+ let existentes: std::collections::BTreeMap<String, String> = contenido
+ .lines()
+ .filter(|l| !l.trim().is_empty())
+ .map(|l| (clave(l), l.to_string()))
+ .collect();
+ let (mut anadidas, mut conflictos) = (Vec::new(), Vec::new());
+ let mut out = contenido.trim_end_matches('\n').to_string();
+ for l in lineas {
+ match existentes.get(&clave(l)) {
+ Some(viejo) if viejo == l => {} // ya está, idéntica ⇒ nada que hacer
+ Some(viejo) => conflictos.push(format!("{} ≠ {}", viejo, l)),
+ None => {
+ if !out.is_empty() {
+ out.push('\n');
+ }
+ out.push_str(l);
+ anadidas.push(l.clone());
+ }
+ }
+ }
+ out.push('\n');
+ (out, anadidas, conflictos)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn gitea() -> User {
+ User {
+ name: "gitea".into(),
+ uid: 916,
+ gid: None,
+ group: None,
+ home: "/var/lib/gitea".into(),
+ shell: "/bin/false".into(),
+ comment: None,
+ }
+ }
+
+ #[test]
+ fn lineas_con_el_formato_de_passwd_y_group() {
+ let u = gitea();
+ assert_eq!(u.passwd_line(), "gitea:x:916:916:gitea:/var/lib/gitea:/bin/false");
+ assert_eq!(u.group_line(), "gitea:x:916:");
+ }
+
+ #[test]
+ fn rechaza_lo_reservado_por_el_producto() {
+ let mut u = gitea();
+ u.uid = 74; // el de `sshd` en PRODUCT_PASSWD
+ assert!(u.validate().unwrap_err().to_string().contains("reserva"));
+ let mut u = gitea();
+ u.name = "root".into();
+ assert!(u.validate().is_err());
+ }
+
+ #[test]
+ fn rechaza_nombre_con_dos_puntos_que_partiria_la_linea() {
+ let mut u = gitea();
+ u.name = "git:ea".into();
+ assert!(u.validate().unwrap_err().to_string().contains("user.name"));
+ }
+
+ #[test]
+ fn rechaza_uid_fuera_de_rango_y_home_relativo() {
+ let mut u = gitea();
+ u.uid = 42;
+ assert!(u.validate().unwrap_err().to_string().contains("fuera de rango"));
+ let mut u = gitea();
+ u.home = "var/lib/gitea".into();
+ assert!(u.validate().unwrap_err().to_string().contains("absoluta"));
+ }
+
+ #[test]
+ fn duplicados_dentro_de_la_receta() {
+ let a = gitea();
+ let mut b = gitea();
+ b.name = "otro".into();
+ assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("uid duplicado"));
+ assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado"));
+ }
+
+ #[test]
+ fn fusionar_es_idempotente_y_no_pisa() {
+ let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
+ let l = vec![gitea().passwd_line()];
+ let (uno, add1, conf1) = fusionar(base, &l);
+ assert_eq!(add1.len(), 1);
+ assert!(conf1.is_empty());
+ // Segunda pasada: nada nuevo, nada duplicado. Es lo que hace que componer dos veces sea seguro.
+ let (dos, add2, conf2) = fusionar(&uno, &l);
+ assert_eq!(uno, dos);
+ assert!(add2.is_empty() && conf2.is_empty());
+ assert_eq!(uno.lines().filter(|l| l.starts_with("gitea:")).count(), 1);
+ // Misma cuenta con otro uid: NO se pisa, se reporta.
+ let mut otro = gitea();
+ otro.uid = 917;
+ let (tres, add3, conf3) = fusionar(&uno, &[otro.passwd_line()]);
+ assert_eq!(tres, uno, "un conflicto no cambia el fichero");
+ assert!(add3.is_empty() && conf3.len() == 1, "{conf3:?}");
+ }
+}
diff --git a/docs/30-servicios-de-paquete.md b/docs/30-servicios-de-paquete.md
index 5b6e62d0..0371d695 100644
--- a/docs/30-servicios-de-paquete.md
+++ b/docs/30-servicios-de-paquete.md
@@ -298,6 +298,61 @@ Y las tres cosas de siempre: EXDEV. `store/` es bind-mount de `/dev/sdb` y `work
`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
@@ -331,6 +386,11 @@ arriba en arje.
| 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
diff --git a/recipes/gitea.toml b/recipes/gitea.toml
index 34aa5c30..f8f7c578 100644
--- a/recipes/gitea.toml
+++ b/recipes/gitea.toml
@@ -59,6 +59,21 @@ go build -trimpath -tags 'bindata sqlite sqlite_unlock_notify' \
"""
install = "true"
+# ── LA CUENTA QUE ESTE PAQUETE NECESITA (SDD 30) ────────────────────────────────────────────────
+# Fuera de `hash_inputs`: declarar esto NO re-hashea gitea.
+#
+# El `uid` se DECLARA y no se asigna, igual 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.
+#
+# 916 es el uid que Alpine reserva para `git`/gitea en sus `setup-*`; no hace falta que coincida con
+# nada, hace falta que no se mueva. `shell = /bin/false` por defecto: una cuenta de servicio a la que
+# alguien pueda entrar es una puerta que nadie declaró.
+[[user]]
+name = "gitea"
+uid = 916
+home = "/var/lib/gitea"
+
# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
# Fuera de `hash_inputs`: declarar esto NO re-hashea gitea.
#
diff --git a/scripts/servidor-image.sh b/scripts/servidor-image.sh
index c27affb2..87d7c829 100755
--- a/scripts/servidor-image.sh
+++ b/scripts/servidor-image.sh
@@ -43,6 +43,12 @@ WORKDIR="${WORKDIR:-/mnt/cosecha/servidor-build}"
IMG="${IMG:-$WORKDIR/servidor.img}"
KERNELPKG="${KERNELPKG:-linux-generic}"
ROOT_SIZE="${ROOT_SIZE:-6144}"; STORE_SIZE="${STORE_SIZE:-512}"; STATE_SIZE="${STATE_SIZE:-512}"
+# El binario: en un hub de desarrollo está en `target/release`, en una caja instalada es
+# `/usr/bin/takana` y `target/` ni existe. Es el bloqueo que ya costó una vez en la caja nueva
+# («unhashable 875» con el lab bien): los scripts asumían árbol de desarrollo.
+TAKANA="${TAKANA:-$ROOT/target/release/takana}"
+[ -x "$TAKANA" ] || TAKANA="$(command -v takana || true)"
+[ -n "$TAKANA" ] && [ -x "$TAKANA" ] || { echo "!! no encuentro el binario takana (probá TAKANA=/usr/bin/takana)" >&2; exit 2; }
[ -n "${AUTHKEYS:-}" ] || { echo "!! falta AUTHKEYS: una imagen sin clave deja la caja INALCANZABLE" >&2; exit 2; }
[ -r "$AUTHKEYS" ] || { echo "!! no puedo leer AUTHKEYS=$AUTHKEYS" >&2; exit 2; }
@@ -74,6 +80,25 @@ cp -al "$PDIR/." "$RFS/"
echo "==> hidratando perfil '$PERFIL'"
python3 scripts/hydrate-profile.py "$PERFIL" --into "$RFS" --store "$STORE" --keep
+# ── 2 bis. las CUENTAS que los paquetes del perfil declaran (`[[user]]`, SDD 30) ─────────────────
+# El `/etc/passwd` del product-rootfs trae `root` y `sshd` y nada más, porque es una constante de
+# Rust. Un paquete cuyo demonio se niega a correr como root —`gitea` lo hace explícitamente— tiene
+# su Card haciendo `setuidgid`, así que SIN esta línea el servicio arranca, muere y reintenta para
+# siempre, y el log dice «unknown user», no «a la imagen le falta una cuenta».
+#
+# `--merge` fusiona por clave y es idempotente: componer dos veces no duplica. Si una cuenta ya está
+# con OTRA línea, sale ≠0 sin tocar ningún fichero — y acá eso ABORTA la imagen a propósito: un
+# rootfs con el uid equivocado produce ficheros de un dueño que no existe, y eso se descubre dentro
+# de la VM, varios pasos más tarde.
+USERPATHS=$(python3 scripts/targets.py --user-paths "$PERFIL")
+if [ -n "$USERPATHS" ]; then
+ echo "==> cuentas : $(echo "$USERPATHS" | wc -l) receta(s) declaran usuarios"
+ # shellcheck disable=SC2086
+ "$TAKANA" --store "$STORE" users $USERPATHS --merge "$RFS"
+else
+ echo "==> cuentas : ninguna receta del perfil declara [[user]]"
+fi
+
# ── 3. reparar el init que la hidratación pisó (ver cabecera, punto 2) ───────────────────────────
antes=$(readlink "$RFS/sbin/init" 2>/dev/null || echo "(fichero)")
ln -sf /usr/bin/arje-zero "$RFS/sbin/init"
diff --git a/scripts/targets.py b/scripts/targets.py
index c1f99821..9e0080b8 100755
--- a/scripts/targets.py
+++ b/scripts/targets.py
@@ -305,6 +305,34 @@ def main():
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))})",