`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
173 lines
10 KiB
Python
Executable File
173 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""declarar.py — convierte los servicios de un censo en una SEMILLA de arje (SDD 29 etapa 3).
|
||
|
||
El paso que cierra la mudanza. Copiar un binario no lo levanta al arrancar: hay que DECLARARLO. Esto
|
||
toma lo que el censo leyó de `/proc` —la invocación real— y emite `seed.card.json`.
|
||
|
||
── POR QUÉ NO ALCANZA CON `arje-absorb` ────────────────────────────────────────────────────────
|
||
`arje-absorb` ya traduce sysvinit/runit/dinit/openrc a una Semilla, y está bien hecho. Pero lee **la
|
||
declaración del init ajeno**, que es exactamente la que miente: en gioser `rc-status` daba `stopped`
|
||
para cinco servicios que estaban vivos, y los que NADIE declara —los `no-declarado`, que son el
|
||
trabajo real— no aparecen en ninguna. Absorber la declaración reproduce el agujero.
|
||
|
||
Esto lee lo VIVO. Los dos se complementan: absorb para lo declarado, esto para lo que corre.
|
||
|
||
── LAS TRES REGLAS ─────────────────────────────────────────────────────────────────────────────
|
||
1. **Un servicio sin `cmdline` legible NO se emite**, y se dice. Una tarjeta que no puede arrancar
|
||
es peor que una tarjeta ausente: la ausente falla ruidosamente al primer arranque, la rota
|
||
arranca el sistema y deja el servicio caído sin que nada avise.
|
||
2. **El `cwd` se preserva envolviendo**, porque el payload `Native` de arje acepta `exec`/`argv`/
|
||
`envp` y **NO `cwd`** (comprobado sobre la semilla real). Un servicio que depende de su
|
||
directorio —como `puerta-f6e393ff`, que corre con `cwd=/mnt/vvv/tawasuyu/shared/tejido`— arrancaría
|
||
en `/` y fallaría de un modo difícil de atribuir. Se envuelve en `sh -c 'cd … && exec …'`, que es
|
||
el idioma que la propia tarjeta de `sshd` ya usa.
|
||
3. **Los IDs son DETERMINISTAS**, derivados del nombre. El mismo censo produce la misma semilla byte
|
||
a byte, así que dos corridas se pueden comparar con `diff` — que es lo que vuelve revisable un
|
||
fichero generado.
|
||
|
||
⚠ NO se copia el entorno. El censo no lo lee (trae tokens), así que un servicio que dependa de
|
||
variables de entorno va a necesitar que alguien las escriba. Se avisa por nombre.
|
||
|
||
Uso:
|
||
scripts/mudanza/declarar.py --censo censo.toml [--label mi-servidor] --out seed.card.json
|
||
"""
|
||
import argparse, hashlib, json, os, shlex, sys, tomllib
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from planear import origen_binario, alternativas, _REPO # noqa: E402
|
||
import formatos # noqa: E402
|
||
|
||
def perfil(censo, label, repo):
|
||
"""Emite un `[perfil.<label>]` para `docs/state/targets.toml`.
|
||
|
||
── POR QUÉ UN PERFIL Y NO UNA LISTA DE `install` ────────────────────────────────────────────
|
||
Mudar servicio por servicio a una caja viva es lo que uno haría en cualquier distro, y en takana
|
||
es ir contra el diseño: **el software de una máquina se DECLARA en `targets.toml` y viene en la
|
||
imagen**, reproducible y firmado. Un servidor armado a fuerza de instalaciones sueltas no se
|
||
puede volver a construir — que es exactamente el problema que esta mudanza existe para no repetir
|
||
(el caddy puesto a mano en gioser no tiene dueño ni receta: se pierde con la máquina).
|
||
|
||
Además hay un impedimento medido: `takana install` REPRODUCE desde fuente, y eso exige el LAB
|
||
entero en el cliente (SDD 28 §5.4). Una caja de destino no lo tiene. El perfil esquiva el
|
||
problema por el lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.
|
||
|
||
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, sin_sellar = {}, [], set()
|
||
for s_ in censo.get("servicio", []):
|
||
if s_.get("decision") != "muda":
|
||
continue
|
||
elegido = s_.get("alternativa") or s_["name"]
|
||
clase, detalle = origen_binario(dict(s_, name=elegido), repo)
|
||
if clase == "receta-takana":
|
||
import re as _re
|
||
m = _re.search(r'`recipes/([^`]+)\.toml`', detalle)
|
||
# Se agrupa POR RECETA, no por servicio: dos servicios pueden necesitar la misma
|
||
# (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}]",
|
||
f'descripcion = "servicios mudados desde {censo.get("maquina",{}).get("hostname","?")} '
|
||
f'— generado por scripts/mudanza/declarar.py"',
|
||
'hereda = ["base"]',
|
||
"# Generado desde un CENSO, no escrito a mano: cada raíz está acá porque un servicio que",
|
||
"# CORRÍA en la máquina vieja la necesita, y takana tiene receta para construirla.",
|
||
"paquetes = ["]
|
||
for receta in sorted(raices):
|
||
quienes = raices[receta]
|
||
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
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Genera una Semilla de arje desde un censo decidido.")
|
||
ap.add_argument("--censo", required=True)
|
||
ap.add_argument("--label", default="takana-servidor", help="etiqueta de la Semilla raíz")
|
||
ap.add_argument("--out", help="fichero de salida (default: stdout)")
|
||
ap.add_argument("--perfil-out", help="además, escribir un [perfil.<label>] para targets.toml")
|
||
a = ap.parse_args()
|
||
|
||
with open(a.censo, "rb") as f:
|
||
censo = tomllib.load(f)
|
||
|
||
# ── las tarjetas las emite el CENTRO, no este guión ─────────────────────────────────────────
|
||
# Antes esto tenía su propio molde de card, y `formatos/arje.py` el suyo. Dos emisores del mismo
|
||
# formato es el N×M que el pivote existe para evitar — y no fue teórico: cada copia tenía un
|
||
# campo mal de la raíz, y ninguno de los dos el mismo. `declarar.py` lee el censo con el lector
|
||
# `proc` y escribe con el escritor `arje`; lo propio que queda acá es el PERFIL.
|
||
lectores, escritores = formatos.cargar()
|
||
sistema = lectores["proc"][0](open(a.censo).read(), a.censo)
|
||
txt = escritores["arje"][0](sistema, a.label)
|
||
hijas = json.loads(txt)["genesis"]
|
||
|
||
print(f"\n══ SEMILLA '{a.label}' ══")
|
||
print(f" {len(hijas)} servicio(s) declarados")
|
||
por = sistema.acta.por_estado()
|
||
for e, titulo in (("SIN-TRADUCIR", "⚠ NO se pudo traducir — trabajo a mano"),
|
||
("decision", "◆ traducido con una DECISIÓN — revisalo")):
|
||
if e in por:
|
||
print(f"\n {titulo} ({len(por[e])}):")
|
||
for x in por[e][:12]:
|
||
print(f" {x['directiva'][:58]}")
|
||
if x["motivo"]:
|
||
print(f" └─ {x['motivo'][:92]}")
|
||
if len(por[e]) > 12:
|
||
print(f" … y {len(por[e])-12} más (usá `traducir.py --from proc --to arje` para verlas)")
|
||
|
||
if a.perfil_out:
|
||
bloque, sin_receta = perfil(censo, a.label, _REPO)
|
||
open(a.perfil_out, "w").write(bloque + "\n")
|
||
n_r = len([l for l in bloque.splitlines() if l.strip().startswith('"')])
|
||
print(f"\n══ PERFIL '{a.label}' ══")
|
||
print(f" {n_r} receta(s) que takana YA construye → {a.perfil_out}")
|
||
print(" Pegalo en docs/state/targets.toml y armá la imagen: el software del servidor nuevo")
|
||
print(" se DECLARA, no se instala a mano. Además `takana install` reproduce desde fuente y")
|
||
print(" eso exige el lab entero en el cliente — el perfil lo resuelve en el hub, que sí lo tiene.")
|
||
if sin_receta:
|
||
print(f"\n ⚠ {len(sin_receta)} servicio(s) que el perfil NO puede cubrir:")
|
||
for nom, cl, det in sin_receta:
|
||
print(f" {nom:22} [{cl}] {det[:84]}")
|
||
print(" Un perfil que se calla lo que le falta sale N/N describiendo un servidor")
|
||
print(" incompleto. Para cada uno: escribir la receta, elegir un equivalente que sí")
|
||
print(" esté, o llevar el binario a mano sabiendo que no se reproduce.")
|
||
if a.out:
|
||
open(a.out, "w").write(txt)
|
||
print(f"\n semilla → {a.out}")
|
||
else:
|
||
print(txt)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|