[[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
This commit is contained in:
Sergio
2026-09-14 15:39:52 +00:00
co-authored by Claude Opus 5
parent 1b3af538d3
commit d611f8577d
8 changed files with 519 additions and 0 deletions
+79
View File
@@ -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)?;