mudanza: el perfil — el software del servidor nuevo se DECLARA, no se instala a mano

`declarar.py --perfil-out` emite un `[perfil.<label>]` listo para `targets.toml`, generado desde el
censo: cada raíz está ahí porque un servicio que CORRÍA la necesita y takana tiene receta.

Por qué un perfil y no una lista de `install`: mudar servicio por servicio a una caja viva va contra
el diseño —el software de una máquina se declara y viene en la imagen, reproducible y firmado— y un
servidor armado a fuerza de instalaciones sueltas no se puede volver a construir. Que es justo el
problema que esta mudanza existe para no repetir: el `caddy` de gioser no tiene dueño ni receta y se
pierde con la máquina.

Y hay un impedimento medido, no estético: `takana install` REPRODUCE desde fuente y eso exige el lab
entero en el cliente (SDD 28 §5.4), que una caja de destino no tiene. El perfil lo resuelve por el
lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.

Del censo de gioser salen `caddy`, `gitea`, `python3` (agrupado: lo piden python3 y uvicorn).
Verificado que el instrumental lo acepta: `targets.py gioser-mudado` expande a 33 raíces.

⚠ Y dice lo que NO puede cubrir, uno por uno con su motivo: 34 servicios entre `suelto`,
`paquete-ajeno` y `desconocido`. Un perfil que se calla lo que le falta sale N/N describiendo un
servidor incompleto — la lección de `foot` en escritorio-sway.

Con esto la mudanza produce TRES documentos declarativos que reconstruyen el servidor desde cero:
el perfil (qué software), la semilla (qué corre y cómo) y el plan (qué datos, en qué orden). Ninguno
es un log de lo hecho: los tres son entradas que se vuelven a ejecutar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-11 17:16:16 +00:00
co-authored by Claude Opus 5
parent cc5aebd8c4
commit 68fdd9eb72
2 changed files with 122 additions and 1 deletions
+50
View File
@@ -251,6 +251,56 @@ recomendación misma.
Si se elige un equivalente, queda en el censo (`alternativa = "…"`) y **el plan lo dice en su paso**,
con la advertencia arriba de todo: la configuración del origen no sirve tal cual.
### 4.0 quater · El perfil: el software del servidor nuevo se DECLARA, no se instala
`declarar.py --perfil-out` emite además un `[perfil.<label>]` listo 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 va contra el diseño: el software de una máquina se
**declara** 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` de gioser no tiene dueño ni receta, y se pierde con la
máquina**.
Y hay un impedimento medido, no estético: `takana install` **reproduce desde fuente**, y eso exige el
LAB entero en el cliente ([SDD 28 §5.4](28-servidor-de-produccion.md)). 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.
Generado desde el censo de gioser:
```toml
[perfil.gioser-mudado]
descripcion = "servicios mudados desde momento — generado por scripts/mudanza/declarar.py"
hereda = ["base"]
paquetes = [
"caddy",
"gitea",
"python3", # python3, uvicorn
]
```
Verificado que el instrumental lo acepta: `scripts/targets.py gioser-mudado` expande a **33 raíces**.
**Y dice lo que NO puede cubrir**, uno por uno con su motivo: 34 servicios entre `suelto`,
`paquete-ajeno` y `desconocido`. *Un perfil que se calla lo que le falta sale N/N describiendo un
servidor incompleto* — la lección de `foot` en `escritorio-sway`, que llegó a 121/121 sellado y sin
emulador de terminal.
### 4.1.bis Los tres artefactos declarativos
Con esto la mudanza deja de ser una secuencia de comandos y pasa a producir **tres documentos que
reconstruyen el servidor desde cero**:
| artefacto | qué declara | lo consume |
|---|---|---|
| `[perfil.<x>]``targets.toml` | **qué software** lleva | `servidor-image.sh` al armar la imagen |
| `seed.card.json` | **qué corre y cómo** | `arje-zero` al arrancar |
| `plan.toml` | **qué datos y en qué orden** | `aplicar.py`, o un humano línea por línea |
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.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)*
`aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]`
+72 -1
View File
@@ -31,7 +31,10 @@ 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, shlex, sys, tomllib
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
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # sin I, L, O, U
@@ -85,11 +88,63 @@ def tarjeta(nombre, cmdline, cwd, puertos):
}
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:
@@ -150,6 +205,22 @@ def main():
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}")