> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
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
diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 565d7254..8ad3b3f2 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -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]`
diff --git a/scripts/mudanza/declarar.py b/scripts/mudanza/declarar.py
index a64a42bd..a371385c 100755
--- a/scripts/mudanza/declarar.py
+++ b/scripts/mudanza/declarar.py
@@ -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}")