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
218 lines
12 KiB
Python
Executable File
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()
|