diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 4308b9f3..563cbd28 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -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. É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 El más urgente de todo el censo, y lo encontró la lista de lo que el perfil no puede cubrir: diff --git a/scripts/mudanza/declarar.py b/scripts/mudanza/declarar.py index cd649631..49e8e1c8 100755 --- a/scripts/mudanza/declarar.py +++ b/scripts/mudanza/declarar.py @@ -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 diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index ab406277..46980729 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -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 SÍ 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 "