[[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)?;
+1
View File
@@ -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};
+17
View File
@@ -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)
}
+294
View File
@@ -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:?}");
}
}
+60
View File
@@ -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
+15
View File
@@ -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.
#
+25
View File
@@ -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"
+28
View File
@@ -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))})",