Files
takana/scripts/mudanza/declarar.py
T
SergioandClaude Opus 5 053e5e7edf 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
2026-09-12 02:37:10 +00:00

173 lines
10 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()