[[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:
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user