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
+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"),