Files
takana/scripts/mudanza/declarar.py
T
SergioandClaude Opus 5 fefa92ec5a traducir: familia «servicio» — systemd → tarjeta de arje, y el pivote pasa a ser uno POR FAMILIA
Un unit de systemd no es un sitio web. Meterlo en `Sitio`/`Ruta` sería justo el «parecerse» que el
acta existe para evitar, así que cada familia tiene su modelo y el centro empareja SÓLO dentro de la
familia. Un par cruzado se rechaza con un error, no con un intento: `systemd → caddy` sale por
`sys.exit`, porque traducir entre familias daría un fichero que PARECE correcto.

Dos familias hoy: `web` (nginx, apache → caddy) y `servicio` (systemd → arje). 3 pares con 5 plugins.

**Dónde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema.** El payload `Native`
de arje acepta `exec`, `argv` y `envp` y nada más (comprobado en la semilla real del producto): NO
hay campo de usuario. Un `User=git` traducido en silencio correría el servicio COMO ROOT — escalación
de privilegios en un fichero generado que nadie vuelve a leer. Sale SIN-TRADUCIR con su línea. Igual
`EnvironmentFile=` (el fichero no está en la máquina donde se traduce ⇒ envp incompleto, falla
tarde), `Type=forking` (arje supervisa al proceso que lanza ⇒ BUCLE DE REINICIO) y todo el
endurecimiento, que callado entrega un servicio MENOS confinado que el original.

Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
semilla del producto — buscarlo antes de declararlo intraducible evitó una elisión inventada.

**Lector y escritor no opinan del mismo campo.** `User=` lo CAPTURA el lector y lo JUZGA el escritor;
cuando los dos anotaban, el acta decía dos cosas distintas de la misma línea, y un acta que se
contradice se deja de leer. El lector registra en qué línea vio cada campo (`Servicio.lineas`) para
que el escritor señale la línea real en vez de un 0.

Controles: la tarjeta generada tiene el MISMO juego de claves que la tarjeta real de `sshd` del
producto (+`_mudanza` de procedencia); dos corridas dan el fichero byte a byte idéntico; y la familia
`web` sigue validando con el caddy del corpus (`Valid configuration` en los dos pares).

De paso, `ulid_determinista` sale a `ids.py`: lo necesitaban `declarar.py` y el escritor de arje, y
copiarlo habría dejado dos generadores de id que se pueden separar sin que nada falle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 19:15:21 +00:00

218 lines
12 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
from ids import ulid_determinista # noqa: E402
def tarjeta(nombre, cmdline, cwd, puertos):
"""Una entrada `genesis`. El molde sale de la semilla real del producto."""
argv_all = shlex.split(cmdline)
if not argv_all:
return None
if cwd and cwd not in ("/", ""):
# Regla 2: `Native` no tiene `cwd`. Se envuelve, igual que la tarjeta de sshd del producto.
exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(cwd)} && exec {cmdline}"]
else:
exec_, argv = argv_all[0], argv_all[1:]
return {
"flow": {"input": [], "output": []},
"genesis": [],
"id": ulid_determinista("takana-mudanza", nombre),
"label": nombre,
"lifecycle": "daemon",
"lineage": None,
"payload": {"Native": {"argv": argv, "envp": [], "exec": exec_}},
"permissions": {"filesystem": "read-write", "ipc": {"allow": []},
"networking": "full", "processes": True},
"priority": "normal",
"provides": [],
"requires": [],
"schema_version": 1,
"soma": {
"cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{nombre}"},
"cpu_affinity": None,
"namespaces": {k: False for k in
("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
"rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
},
"supervision": {"Restart": {"initial": 500, "max": 20000}},
"_mudanza": {"puertos": puertos, "cwd_original": cwd},
}
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 = {}, []
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
raices.setdefault(receta, []).append((s_["name"], tuple(s_.get("ports", []))))
else:
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 "")
nota = f" # {det}" if det and det != receta else ""
L.append(f' "{receta}",{nota}')
L.append("]")
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)
hijas, sin_cmdline, envueltos = [], [], []
for s in censo.get("servicio", []):
if s.get("decision") != "muda":
continue
cmd = (s.get("cmdline") or "").strip()
if not cmd:
# Regla 1: no se inventa una invocación.
sin_cmdline.append(s["name"])
continue
t = tarjeta(s["name"], cmd, (s.get("cwd") or "").strip(), s.get("ports", []))
if t is None:
sin_cmdline.append(s["name"])
continue
if t["payload"]["Native"]["exec"] == "/bin/sh":
envueltos.append(s["name"])
hijas.append(t)
hijas.sort(key=lambda t: t["label"]) # orden estable ⇒ `diff` útil
semilla = {
"flow": {"input": [], "output": []},
"genesis": hijas,
"id": ulid_determinista("takana-mudanza-raiz", a.label),
"label": a.label,
"lifecycle": "daemon",
"lineage": None,
"payload": "Virtual",
"permissions": {"filesystem": "read-write", "ipc": {"allow": []},
"networking": "full", "processes": True},
"priority": "normal",
"provides": ["Spawn", "Journal"],
"requires": [],
"schema_version": 1,
"soma": {
"cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"},
"cpu_affinity": None,
"namespaces": {k: False for k in
("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
"rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
},
"supervision": {"Restart": {"initial": 500, "max": 20000}},
}
txt = json.dumps(semilla, indent=1, sort_keys=True) + "\n"
print(f"\n══ SEMILLA '{a.label}' ══")
print(f" {len(hijas)} servicio(s) declarados")
if envueltos:
print(f" {len(envueltos)} envuelto(s) en `sh -c 'cd … && exec …'` porque dependen de su "
f"directorio\n (el payload Native de arje no tiene `cwd`): {' '.join(envueltos)}")
if sin_cmdline:
print(f"\n{len(sin_cmdline)} servicio(s) NO se declararon: no se pudo leer su cmdline.")
print(f" {' '.join(sin_cmdline)}")
print(" Una tarjeta que no puede arrancar es PEOR que una ausente: la ausente falla")
print(" al primer arranque, la rota deja el servicio caído sin que nada avise.")
print(" ⚠ El entorno (`envp`) va VACÍO: el censo no lee /proc/<pid>/environ porque trae")
print(" tokens. Un servicio que dependa de variables va a necesitar que alguien las escriba.")
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()