planear: buscar la receta por el PAQUETE del origen — y el intérprete no es el programa

`origen_binario` buscaba la receta por el nombre del SERVICIO, y ése casi nunca es el nombre del
paquete: `sshd` lo trae `openssh`, `crond` lo trae `cronie`. El censo ya le preguntó al gestor de
paquetes quién posee cada binario, así que ese nombre también se prueba. El perfil pasó de 4 recetas
a 7, y los 27 servicios quedan: receta-takana 9 · suelto 9 · paquete-ajeno 13 · interprete 4 ·
borrado 4.

**La trampa, que es la que haría mentir al perfil.** `openclaw` lo posee el paquete `nodejs`;
`fail2ban-server`, `glances` y `uvicorn` los posee `python`. Contarlos como cubiertos porque existe
`recipes/nodejs.toml` haría salir el perfil N/N describiendo un servidor al que le faltan CUATRO
programas. Tienen clase propia (`interprete`) y cuentan las DOS cosas a la vez, porque las dos son
ciertas: su runtime entra al perfil —`openclaw` necesita `nodejs` en la imagen pase lo que pase— y el
servicio sigue listado como NO cubierto. Meterlo sólo en las raíces miente; dejarlo sólo en los
faltantes arma una imagen sin runtime y el programa, cuando llegue, no arranca.

Y el paquete del origen no se llama igual que la receta ni para el mismo intérprete: Artix empaqueta
`python` y el catálogo tiene `python3.toml`. Sin ese alias tres servicios decían «hace falta una
receta takana» teniendo el runtime sellado — dos trabajos muy distintos.

**Sellada y sin sellar tampoco son lo mismo**, y el perfil las listaba igual: una sellada se INSTALA
del repo firmado, una sin sellar hay que CONSTRUIRLA. Salió con un caso real — `recipes/qdrant.toml`
entró al catálogo desde otro frente mientras se escribía esto y no tiene artefacto. Ahora se marca en
la línea y se resume al pie.

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-12 02:37:10 +00:00
co-authored by Claude Opus 5
parent fb3a326dfd
commit 053e5e7edf
3 changed files with 96 additions and 8 deletions
+23 -1
View File
@@ -53,7 +53,7 @@ def perfil(censo, label, repo):
Lo que el perfil NO puede cubrir se lista aparte, con su motivo. Un perfil que se calla lo que le
falta sale N/N y describe un servidor incompleto — la lección de `foot` en `escritorio-sway`."""
raices, sin_receta = {}, []
raices, sin_receta, sin_sellar = {}, [], set()
for s_ in censo.get("servicio", []):
if s_.get("decision") != "muda":
continue
@@ -66,8 +66,25 @@ def perfil(censo, label, repo):
# (uvicorn y python3 son los dos `python3`), y repetir una raíz en un perfil no la
# duplica — sólo ensucia y hace creer que son dos cosas distintas.
receta = m.group(1) if m else elegido
# ⚠ Sellada y sin sellar no son lo mismo, y el perfil las listaba igual. Una sellada se
# INSTALA del repo firmado; una sin sellar hay que CONSTRUIRLA antes, y eso puede ser una
# tarde. Medido: `qdrant` entró al catálogo ayer desde otro frente y no tiene artefacto.
if "sin sellar" in detalle:
sin_sellar.add(receta)
raices.setdefault(receta, []).append((s_["name"], tuple(s_.get("ports", []))))
else:
# ⚠ Un servicio de clase `interprete` cuenta las DOS cosas a la vez, y hay que hacer las
# dos: su runtime SÍ entra al perfil —`openclaw` necesita `nodejs` en la imagen pase lo
# que pase— y el servicio sigue listado como NO CUBIERTO, porque el intérprete no es el
# programa. Meterlo sólo en las raíces haría salir el perfil N/N con la app faltando;
# dejarlo sólo en la lista de faltantes armaría una imagen sin runtime y el programa,
# cuando llegue, no arrancaría.
if clase == "interprete":
import re as _re
m = _re.search(r'`recipes/([^`]+)\.toml`', detalle)
if m:
raices.setdefault(m.group(1), []).append(
(f"{s_['name']} (sólo el runtime)", tuple(s_.get("ports", []))))
sin_receta.append((s_["name"], clase, detalle))
L = [f"[perfil.{label}]",
@@ -82,9 +99,14 @@ def perfil(censo, label, repo):
nombres = ", ".join(sorted({q[0] for q in quienes}))
puertos = sorted({p for q in quienes for p in q[1]})
det = nombres + (f" · puertos {','.join(str(x) for x in puertos)}" if puertos else "")
if receta in sin_sellar:
det = ("⚠ SIN SELLAR: hay que construirla antes de armar la imagen — " + det).strip("")
nota = f" # {det}" if det and det != receta else ""
L.append(f' "{receta}",{nota}')
L.append("]")
if sin_sellar:
L.append(f"# ⚠ {len(sin_sellar)} sin artefacto en el store: {', '.join(sorted(sin_sellar))}.")
L.append("# El perfil es correcto, pero armar la imagen las va a CONSTRUIR, no instalar.")
return "\n".join(L), sin_receta
+37 -7
View File
@@ -121,6 +121,17 @@ def _no_aplica(nombre):
return None
# Paquetes que proveen un RUNTIME, no un programa. Que exista `recipes/nodejs.toml` no significa que
# `openclaw` esté cubierto: el intérprete se resuelve y el programa sigue faltando.
INTERPRETES = {"python", "python3", "nodejs", "node", "perl", "ruby", "php", "lua", "java", "jre"}
# El paquete del origen y la receta del catálogo no siempre se llaman igual ni siquiera para el mismo
# intérprete: Artix empaqueta `python` y el catálogo tiene `python3.toml`. Sin esto, tres servicios
# salían «hace falta una receta takana» cuando el runtime YA está sellado y lo que falta es el
# programa — dos trabajos muy distintos.
ALIAS_INTERPRETE = {"python": "python3", "node": "nodejs", "jre": "java"}
# ── familias de equivalentes funcionales ────────────────────────────────────────────────────────
# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de
# dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es
@@ -285,14 +296,33 @@ def origen_binario(e, repo=None):
# 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
# ⚠ El nombre del SERVICIO no es el nombre del paquete: `sshd` lo trae `openssh`, `crond` lo trae
# `cronie`. Buscar sólo por el nombre del servicio daba «no hay receta» para cinco que el catálogo
# SÍ construye y tiene selladas. El censo ya le preguntó al gestor de paquetes quién posee el
# binario, así que ese nombre también se prueba.
import glob
for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or ""),
pkg, (pkg or "").removesuffix("-bin"), ALIAS_INTERPRETE.get(pkg or "", "")):
if not cand or not os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
continue
# ⚠⚠ Y la trampa, que es la que haría mentir al perfil: un paquete INTÉRPRETE provee el
# runtime, no el programa. `openclaw` lo posee `nodejs` y `fail2ban-server` lo posee
# `python`; tener esas recetas NO te da esos servicios. Contarlos como cubiertos haría que el
# perfil saliera N/N describiendo un servidor al que le faltan cuatro programas.
if pkg in INTERPRETES and cand in (pkg, ALIAS_INTERPRETE.get(pkg or "", "")) and cand != nombre:
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 '?'}")
return ("interprete",
f"lo corre el intérprete `{pkg}`, que takana construye (`recipes/{cand}.toml`"
+ (", sellada)" if sellado else ", sin sellar)")
+ f" — pero el intérprete NO es el programa: `{nombre}` es código que hay que "
f"llevar y declarar aparte. El perfil resuelve el runtime y nada más")
sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
via = "" if cand in (nombre, nombre.replace("_", "-")) else f" (por su paquete `{pkg}`)" \
if cand in (pkg, (pkg or "").removesuffix("-bin")) else f" (por su binario)"
return ("receta-takana",
f"takana YA lo construye{via}: `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 "