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
+36
View File
@@ -301,6 +301,42 @@ reconstruyen el servidor desde cero**:
Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar. Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar.
Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez. Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.
### 4.0 duodecies · La cuenta de lo que falta, bien hecha
`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. Con eso el perfil pasó de
**4 recetas a 7**, y la clasificación de los 27 servicios quedó así:
```
receta-takana 9 paquete-ajeno 13
suelto 9 interprete 4 borrado 4
```
#### La trampa: el intérprete NO es el programa
`openclaw` lo posee el paquete `nodejs`, y `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;
- el **servicio sigue listado como no cubierto**, con su motivo.
Meterlo sólo en las raíces haría un perfil que miente; dejarlo sólo en los faltantes armaría una
imagen sin runtime, y el programa, cuando llegue, no arrancaría.
(Y el paquete del origen no se llama igual que la receta ni para el mismo intérprete: Artix
empaqueta `python`, 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 no son lo mismo
El perfil las listaba igual. Una sellada se **instala** del repo firmado; una sin sellar hay que
**construirla**, y eso puede ser una tarde. 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 el perfil lo
marca en la línea y lo resume al pie.
### 4.0 undecies · Cuatro binarios que ya no existen — el paso que CADUCA ### 4.0 undecies · Cuatro binarios que ya no existen — el paso que CADUCA
El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir: El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir:
+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 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`.""" 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", []): for s_ in censo.get("servicio", []):
if s_.get("decision") != "muda": if s_.get("decision") != "muda":
continue 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 # (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. # duplica — sólo ensucia y hace creer que son dos cosas distintas.
receta = m.group(1) if m else elegido 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", [])))) raices.setdefault(receta, []).append((s_["name"], tuple(s_.get("ports", []))))
else: 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)) sin_receta.append((s_["name"], clase, detalle))
L = [f"[perfil.{label}]", L = [f"[perfil.{label}]",
@@ -82,9 +99,14 @@ def perfil(censo, label, repo):
nombres = ", ".join(sorted({q[0] for q in quienes})) nombres = ", ".join(sorted({q[0] for q in quienes}))
puertos = sorted({p for q in quienes for p in q[1]}) 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 "") 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 "" nota = f" # {det}" if det and det != receta else ""
L.append(f' "{receta}",{nota}') L.append(f' "{receta}",{nota}')
L.append("]") 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 return "\n".join(L), sin_receta
+37 -7
View File
@@ -121,6 +121,17 @@ def _no_aplica(nombre):
return None 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 ──────────────────────────────────────────────────────── # ── familias de equivalentes funcionales ────────────────────────────────────────────────────────
# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de # 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 # 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. # 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 — " raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — "
f"pasá --repo o corré desde el repo") f"pasá --repo o corré desde el repo")
for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or "")): # ⚠ El nombre del SERVICIO no es el nombre del paquete: `sshd` lo trae `openssh`, `crond` lo trae
if cand and os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")): # `cronie`. Buscar sólo por el nombre del servicio daba «no hay receta» para cinco que el catálogo
import glob # 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}"))) sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
return ("receta-takana", return ("interprete",
f"takana YA lo construye: `recipes/{cand}.toml`" f"lo corre el intérprete `{pkg}`, que takana construye (`recipes/{cand}.toml`"
+ (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)") + (", sellada)" if sellado else ", sin sellar)")
+ f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}") + 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: if pkg:
return ("paquete-ajeno", return ("paquete-ajeno",
f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una " f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una "