mudanza: de dónde sale cada binario — y la herramienta corrigió un error mío

«Instalá el paquete» es lo que uno ya sabía. La respuesta útil sale de cruzar dos hechos: quién posee
el fichero en el ORIGEN (el censo se lo pregunta al gestor de paquetes de esa máquina, en un lote) y
si el corpus de takana tiene una receta con ese nombre.

  receta-takana  → instalarlo del repo firmado, NO copiar el binario
  paquete-ajeno  → escribir receta, o qorpa (ADR 0015)
  suelto         → nadie lo provee: llevarlo con su entorno o escribirle receta

Medido sobre gioser (37 servicios vivos): 4 receta-takana · 4 paquete-ajeno · **11 SUELTOS** · 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.
Sorpresa medida: `/usr/bin/caddy` tampoco tiene dueño — está puesto a mano.

**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 falsas — `recipes/gitea.toml` existe y está sellada. Lo afirmé de memoria;
el programa fue a mirar. Corregido allá con la nota.

**Y un fallo del clasificador, que vale como regla**: calculaba la raíz del repo con un `dirname` de
menos, 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-11 17:02:52 +00:00
co-authored by Claude Opus 5
parent 808e4a4660
commit 6f5cc2629a
4 changed files with 130 additions and 3 deletions
+11 -1
View File
@@ -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*.
+26
View File
@@ -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 <clase>] [--paso N] [--hecho N]`
+40
View File
@@ -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
+53 -2
View File
@@ -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"),