diff --git a/docs/28-servidor-de-produccion.md b/docs/28-servidor-de-produccion.md index 2e82b6fa..e0f7e7c9 100644 --- a/docs/28-servidor-de-produccion.md +++ b/docs/28-servidor-de-produccion.md @@ -975,10 +975,20 @@ una receta candidata con un caso de uso comprobado detrás: |---|---| | `caddy` | ✅ existe (`recipes/caddy.toml`) | | `openssh` | ✅ existe | -| `gitea` | ✗ — y es la que más peso tiene: aloja el `origin` de takana | +| `gitea` | ✅ **SÍ existe** (`recipes/gitea.toml`) **y está sellada** (`35bb4f04…`) — ver la corrección abajo | | `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ | | `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` | +> **CORRECCIÓN (2026-09-11).** Esta tabla decía que `gitea` NO tenía receta y que «es la que más +> peso tiene». **Las dos cosas eran falsas**: `recipes/gitea.toml` existe desde el 2026-09-09 y su +> artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo. +> +> Lo destapó `planear.py` al cruzar cada binario contra `recipes/`: de los servicios de gioser, **4 +> ya tienen receta takana y están sellados** (`caddy`, `gitea`, `python3`…), 4 vienen de un paquete +> ajeno y **11 son binarios sueltos sin dueño**. Ésos últimos son el trabajo real, no gitea. +> +> Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar. + **Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*. diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 62859faf..8b58f17c 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -187,6 +187,32 @@ comando entero salía ≠0 y se descartaba **también el `cmdline`**, que sí se servicios quedaban sin invocación —el dato que da sentido al censo— y el fallo se leía como «no se pudo». **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.** +### 4.0 bis · De dónde sale cada binario — «instalá el paquete» no es una instrucción + +El paso de servicio decía «instalá el binario/paquete en el destino», que es lo que uno ya sabía. La +respuesta útil sale de cruzar **dos hechos**: quién posee el fichero en el ORIGEN (se lo pregunta el +censo al gestor de paquetes de esa máquina, en un solo lote) y si el corpus de takana tiene una +receta con ese nombre. Tres casos, tres trabajos distintos: + +| clase | qué significa | qué hay que hacer | +|---|---|---| +| `receta-takana` | takana ya sabe construirlo | instalarlo del repo firmado — **no** copiar el binario | +| `paquete-ajeno` | lo trae el gestor del origen | escribir una receta, o `qorpa` (ADR 0015) | +| `suelto` | **nadie lo provee**: puesto a mano | llevarlo con su entorno, o escribirle receta | + +**Medido sobre gioser (37 servicios vivos):** 4 `receta-takana` · 4 `paquete-ajeno` · **11 `suelto`** +· 7 sin binario legible. Los sueltos viven en `/usr/local/bin` o en un home — `qdrant`, `matilda`, +`pacha-secretos`, `act_runner`, `shuma-gateway`— y **son los que se pierden al apagar el origen**. + +⚠ **Y corrigió un error mío.** En el SDD 28 escribí que `gitea` no tenía receta y que «es la que más +peso tiene». Las dos cosas eran falsas: `recipes/gitea.toml` existe y está sellada. Lo afirmé de +memoria; el programa fue a mirar. Es exactamente para esto que existe. + +⚠ **Y un fallo del propio clasificador, que vale como regla**: calculaba la raíz del repo con un +`dirname` de menos, así que no encontraba ninguna receta y **contestaba `suelto` a todo** — incluido +`caddy`. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no +encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta. + ### 4.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)* `aplicar.py --plan plan.toml [--dry-run] [--only ] [--paso N] [--hecho N]` diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py index e5a3de8d..15249f3d 100755 --- a/scripts/mudanza/censar.py +++ b/scripts/mudanza/censar.py @@ -104,6 +104,38 @@ def invocaciones(t, pids): return out +def duenos(t, ejecutables): + """¿Quién PROVEE el binario de cada servicio? Determinista, preguntándole al gestor de paquetes + del origen. + + Es lo que convierte «instalá el paquete» —que es lo que uno ya sabía— en una instrucción: o el + binario lo trae un paquete con nombre, o NO lo trae nadie y entonces es un compilado suelto que + hay que llevar o reconstruir. La diferencia decide el trabajo, y no se adivina. + + Una consulta por lote, no una por servicio: son decenas de servicios y cada `ssh` cuesta.""" + if not ejecutables: + return {} + lista = " ".join(shlex.quote(e) for e in sorted(set(ejecutables))) + # Se prueba el gestor que exista. `2>/dev/null || echo -` ⇒ un fichero sin dueño no aborta el lote. + guion = ( + 'for f in %s; do ' + ' if command -v pacman >/dev/null 2>&1; then o=$(pacman -Qoq "$f" 2>/dev/null); ' + ' elif command -v dpkg >/dev/null 2>&1; then o=$(dpkg -S "$f" 2>/dev/null | cut -d: -f1); ' + ' elif command -v apk >/dev/null 2>&1; then o=$(apk info -W "$f" 2>/dev/null | tail -1 ' + ' | sed "s/.* is owned by //;s/-[0-9].*//"); ' + ' elif command -v rpm >/dev/null 2>&1; then o=$(rpm -qf "$f" 2>/dev/null); ' + ' else o=""; fi; ' + ' echo "$f|${o:-}"; done' + ) % lista + out, _ = t.run(guion) + res = {} + for l in out.splitlines(): + if "|" in l: + f, o = l.split("|", 1) + res[f.strip()] = o.strip() + return res + + def procesos_vivos(t): """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración.""" out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'") @@ -389,6 +421,8 @@ def emitir_toml(c): L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}") L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}") L.append(f"cwd = {toml_escape(s.get('cwd',''))}") + L.append(f"exec = {toml_escape(s.get('exec',''))}") + L.append(f"paquete = {toml_escape(s.get('paquete',''))}") L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") for d in c["dominios"]: @@ -474,6 +508,12 @@ def main(): i = inv.get(e['name'], {}) e['cmdline'] = i.get('cmdline', '') e['cwd'] = i.get('cwd', '') + ejes = [ (e['cmdline'] or '').split(' ')[0] for e in svc if (e.get('cmdline') or '').startswith('/') ] + dn = duenos(t, ejes) + for e in svc: + exe = (e.get('cmdline') or '').split(' ')[0] + e['exec'] = exe if exe.startswith('/') else '' + e['paquete'] = dn.get(exe, '') pts = puertos(t) nombres = dominios(t) sondear = (a.probe or bool(a.host)) and not a.no_probe diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index dc87405d..fa7eca06 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -103,6 +103,51 @@ def _no_aplica(nombre): return None +def origen_binario(e, repo=None): + """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino. + + «Instalá el paquete» es lo que uno ya sabía. Esto responde otra cosa, cruzando dos hechos: quién + posee el fichero en el ORIGEN (se lo preguntó el censo a su gestor de paquetes) y si el corpus de + takana tiene una receta con ese nombre. Los tres casos piden trabajo distinto: + + · `receta-takana` — lo mejor: takana ya sabe construirlo. Se instala del repo firmado. + · `paquete-ajeno` — lo trae el gestor del origen. En el destino hace falta una receta, o + `qorpa` (ADR 0015) si se acepta una jaula con la distro ajena. + · `suelto` — NADIE lo provee: un binario puesto a mano. Hay que LLEVARLO con su árbol + o escribirle una receta. Es el que se pierde al apagar el origen. + + Medido en gioser: 13 servicios con paquete y **13 binarios sueltos** — `caddy`, `qdrant`, + `matilda`, `act_runner`, `pacha-secretos`… en `/usr/local/bin` o en un home. La mitad.""" + nombre, exe, pkg = e.get("name", ""), e.get("exec", ""), e.get("paquete", "") + # scripts/mudanza/planear.py → scripts/mudanza → scripts → RAÍZ. Con un `dirname` de menos + # apuntaba a `scripts/`, no encontraba ninguna receta, y TODO salía `suelto` — incluido `caddy`, + # que sí tiene `recipes/caddy.toml`. Un clasificador que contesta siempre lo mismo no clasifica. + repo = repo or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + if not os.path.isdir(os.path.join(repo, "recipes")): + # Falla ruidosamente: sin el catálogo no se puede distinguir `receta-takana` de `suelto`, y + # contestar `suelto` a todo sería una respuesta falsa con forma de respuesta. + raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — " + f"pasá --repo o corré desde el repo") + for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or "")): + if cand and os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")): + import glob + sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}"))) + return ("receta-takana", + f"takana YA lo construye: `recipes/{cand}.toml`" + + (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)") + + f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}") + if pkg: + return ("paquete-ajeno", + f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una " + f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)") + if exe: + return ("suelto", + f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que " + f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar " + f"el origen") + return ("desconocido", "no se pudo leer su binario: hace falta averiguarlo a mano") + + def recomendar(tipo, e, datos_que_mueren=()): """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para que aceptarla sea barato y equivocarse sea caro de no notar. @@ -212,6 +257,9 @@ def revisar(censo, interactivo=True): print(f" {t:8} {n:30} {_etiqueta(t,n,e):>18}") if e.get("_motivo"): print(f" └─ {e['_motivo']}") + if t == "servicio" and e.get("decision") != "muere" and g != "muere": + oc, od = origen_binario(e) + print(f" └─ binario: [{oc}] {od[:96]}") if not interactivo: return censo @@ -302,11 +350,14 @@ def pasos(censo, target, key): for s in svc_muda: nd = s.get("class") == "no-declarado" paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""), - (f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n" + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n" + f"# {origen_binario(s)[1]}\n" + f"#\n" + f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n" f"# cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n" + (f"# cwd : {s['cwd']}\n" if s.get("cwd") else "") + (f"# puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "") - + f"#\n# 1) instalar el binario/paquete de {s['name']} en el destino\n" + + f"#\n# 1) conseguir el binario según lo de arriba\n" f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n" f"# (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n" f"# 3) arrancarlo"),