> 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…
declarar: `/proc` pasa a ser un lector del centro — y había DOS emisores de tarjetas, mal los dos
`declarar.py` tenía su propio molde de card de arje y `formatos/arje.py` el suyo: el N×M que el
pivote existe para evitar, adentro de mi propio código. Y no quedó en teoría — CADA COPIA TENÍA UN
CAMPO MAL DE LA RAÍZ, Y NINGUNO DE LOS DOS EL MISMO:
campo declarar.py arje.py semilla REAL del producto
provides ["Spawn","Journal"] ✓ [] ✗ ["Spawn","Journal"]
supervision Restart{…} ✗ "OneShot" ✓ "OneShot"
Las consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni dónde
escribir, y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.
Ahora hay un lector `proc` (el censo es un formato de origen, igual que systemd u OpenRC) y el
escritor `arje` es el ÚNICO que emite tarjetas; `declarar.py` queda con lo suyo, el perfil. Tres
lectores en la familia: systemd y openrc leen LO DECLARADO —que es lo que miente, `rc-status` daba
`stopped` para cinco servicios vivos— y `proc` lee LO QUE CORRE, que es donde aparecen los 15
`no-declarado`.
Dos cosas más, las dos sobre no mentir:
· **Una semilla vacía parecería un éxito.** Si ningún servicio tiene `decision = "muda"`, el lector
lo DICE y sale ≠0 en vez de emitir cero tarjetas en silencio. Mismo modo de fallo que un artefacto
vacío en el store.
· **El acta se ahogaba en su propio ruido.** Anotaba el `envp` vacío una vez POR SERVICIO: 26 líneas
idénticas que tapaban los dos hallazgos reales (`shuma-daemon` corre como `sergio`; los que no
tienen cmdline). La limitación es del lector y vale para todos ⇒ una entrada nombrando a los 26.
Un acta donde casi todo es la misma línea se deja de leer, y entonces no queda ningún acta. Y al
revés: las entradas que SÍ son por servicio ahora lo nombran (`gitea: cwd=/var/lib/gitea`).
Controles: la raíz generada coincide campo por campo con la del producto; dos corridas dan el fichero
byte a byte idéntico; 26 tarjetas con 26 ids únicos.
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 2e9186a7..59d094a8 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -301,6 +301,49 @@ reconstruyen el servidor desde cero**:
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.0 octies · `/proc` es un formato de origen más — y había DOS emisores de tarjetas
+
+`declarar.py` tenía su propio molde de card de arje, y `formatos/arje.py` el suyo. Dos emisores del
+mismo formato es exactamente el N×M que el pivote existe para evitar, y no quedó en teoría: **cada
+copia tenía un campo mal de la raíz, y ninguno de los dos el mismo.**
+
+| campo de la raíz | `declarar.py` | `formatos/arje.py` | la semilla REAL del producto |
+|---|---|---|---|
+| `provides` | `["Spawn","Journal"]` ✓ | `[]` ✗ | `["Spawn","Journal"]` |
+| `supervision` | `Restart{…}` ✗ | `"OneShot"` ✓ | `"OneShot"` |
+
+Las dos consecuencias son concretas: sin `Spawn`/`Journal` las hijas no tienen quién las lance ni
+dónde escribir; y una card `Virtual` con `Restart` le pide a arje que respawnee algo que nunca corrió.
+
+Ahora hay un lector `proc` —el censo es un formato de origen, igual que systemd u OpenRC— y el
+escritor `arje` es el único que emite tarjetas. `declarar.py` queda con lo suyo: el **perfil**.
+
+```
+familia «servicio»: systemd ─┐
+ openrc ─┼─► Servicio/Sistema ──► arje
+ proc ─┘
+```
+
+Los tres se complementan y ninguno solo alcanza: systemd y OpenRC leen **lo declarado**, que es lo
+que miente —`rc-status` daba `stopped` para cinco servicios vivos—, y `proc` lee **lo que corre**,
+que es lo único donde aparecen los 15 `no-declarado`.
+
+#### Una semilla vacía parecería un éxito
+
+Si ningún servicio tiene `decision = "muda"`, el lector `proc` **no devuelve cero tarjetas en
+silencio**: lo dice y sale ≠0. Es el mismo modo de fallo que un artefacto vacío en el store — un
+ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
+
+#### El acta se estaba ahogando en su propio ruido
+
+La primera versión anotaba el `envp` vacío **una vez por servicio**: 26 líneas idénticas que tapaban
+los dos hallazgos que sí eran de un servicio concreto —`shuma-daemon` corre como `sergio`, y los que
+no tienen línea de comando—. La limitación es del *lector* y vale igual para todos, así que va **una
+entrada nombrando a los 26**. Un acta donde casi todo es la misma línea se deja de leer, y entonces
+no queda ningún acta. Lo mismo con la supervisión. Y al revés: las entradas que sí son por servicio
+ahora lo **nombran** (`gitea: cwd=/var/lib/gitea`), porque cuatro `WorkingDirectory=/home/sergio`
+sin nombre obligan a buscar cuál es cuál.
+
### 4.0 septies · El censo real de gioser, y dos huecos que destapó
Corrido contra `204.168.193.248` — que es `gioser.net`, identificado **por IP y no por hostname**,
diff --git a/scripts/mudanza/declarar.py b/scripts/mudanza/declarar.py
index bf4ee37a..cd649631 100755
--- a/scripts/mudanza/declarar.py
+++ b/scripts/mudanza/declarar.py
@@ -35,43 +35,7 @@ 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},
- }
-
+import formatos # noqa: E402
def perfil(censo, label, repo):
"""Emite un `[perfil.<label>]` para `docs/state/targets.toml`.
@@ -135,61 +99,30 @@ def main():
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"
+ # ── 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")
- 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.")
+ 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")
diff --git a/scripts/mudanza/formatos/arje.py b/scripts/mudanza/formatos/arje.py
index 3fb6de8d..8bdeb0b4 100644
--- a/scripts/mudanza/formatos/arje.py
+++ b/scripts/mudanza/formatos/arje.py
@@ -33,18 +33,18 @@ def _tarjeta(srv, acta, desde):
if srv.cwd and srv.cwd not in ("/", ""):
# `Native` no tiene `cwd`: se envuelve, que es el idioma que la tarjeta de sshd ya usa.
exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(srv.cwd)} && exec {srv.cmd}"]
- acta.anota(srv.lineas.get("cwd", srv.origen), "decision", f"WorkingDirectory={srv.cwd}",
+ acta.anota(srv.lineas.get("cwd", srv.origen), "decision", f"{srv.nombre}: cwd={srv.cwd}",
"el payload `Native` no tiene `cwd`: se envuelve en `sh -c 'cd … && exec …'`")
else:
exec_, argv = argv_all[0], argv_all[1:]
if srv.usuario and srv.usuario != "root":
- acta.anota(srv.lineas.get("usuario", srv.origen), "SIN-TRADUCIR", f"User={srv.usuario}",
+ acta.anota(srv.lineas.get("usuario", srv.origen), "SIN-TRADUCIR", f"{srv.nombre}: User={srv.usuario}",
"el payload `Native` de arje NO tiene campo de usuario ⇒ esta tarjeta corre el "
"servicio COMO ROOT. Hay que bajar el privilegio dentro del comando "
"(p. ej. `setpriv --reuid`) antes de desplegarla")
if srv.grupo and srv.grupo != "root":
- acta.anota(srv.lineas.get("grupo", srv.origen), "SIN-TRADUCIR", f"Group={srv.grupo}",
+ acta.anota(srv.lineas.get("grupo", srv.origen), "SIN-TRADUCIR", f"{srv.nombre}: Group={srv.grupo}",
"mismo motivo que `User=`: no hay dónde ponerlo en la tarjeta")
if srv.tipo == "forking":
@@ -109,7 +109,7 @@ def _tarjeta(srv, acta, desde):
@escritor("arje", familia="servicio")
-def escribir(sis):
+def escribir(sis, etiqueta="mudanza"):
acta = sis.acta
hijas, vistos = [], {}
for srv in sis.servicios:
@@ -137,18 +137,24 @@ def escribir(sis):
continue
hijas.append(t)
+ hijas.sort(key=lambda t: t["label"]) # orden estable ⇒ `diff` útil entre corridas
+ # ⚠ La raíz se calca de la semilla REAL del producto (`STAGE1_SEED_CARD`), y el calco importa:
+ # cuando esto vivía en dos sitios, cada copia tenía un campo mal y ninguno de los dos igual.
+ # `provides` debe anunciar Spawn y Journal —si no, las hijas no tienen quién las lance ni dónde
+ # escribir— y la supervisión de una card `Virtual` es `OneShot`: no hay proceso que reiniciar,
+ # así que un `Restart` acá le pide a arje que respawnee algo que nunca corrió.
raiz = {
"flow": {"input": [], "output": []},
"genesis": hijas,
- "id": ulid_determinista(SEM + "-raiz", "mudanza"),
- "label": "mudanza",
+ "id": ulid_determinista(SEM + "-raiz", etiqueta),
+ "label": etiqueta,
"lifecycle": "daemon",
"lineage": None,
"payload": "Virtual",
"permissions": {"filesystem": "read-write", "ipc": {"allow": []},
"networking": "full", "processes": True},
"priority": "normal",
- "provides": [],
+ "provides": ["Spawn", "Journal"],
"requires": [],
"schema_version": 1,
"soma": {"cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"},
diff --git a/scripts/mudanza/formatos/proc.py b/scripts/mudanza/formatos/proc.py
new file mode 100644
index 00000000..47358a00
--- /dev/null
+++ b/scripts/mudanza/formatos/proc.py
@@ -0,0 +1,77 @@
+"""Lector del CENSO (lo que `/proc` dice que corre) → modelo pivote «servicio» (SDD 29).
+
+El tercer lector de la familia, y el que no tiene equivalente en ningún init: `systemd` y `openrc`
+leen **lo declarado**, que es exactamente lo que miente — en gioser `rc-status` daba `stopped` para
+cinco servicios que estaban vivos, y los `no-declarado` (15 de 39) no aparecen en ninguna
+declaración porque, por definición, nadie los declaró. Éste lee **lo que corre**.
+
+Los tres se complementan y ninguno solo alcanza. Que los tres desemboquen en el mismo escritor es
+justamente el punto del pivote: la tarjeta de arje se emite en UN lugar, no uno por procedencia.
+
+── LO QUE ESTE LECTOR NO PUEDE ─────────────────────────────────────────────────────────────────
+El censo **no lee `/proc/<pid>/environ`** — trae tokens y contraseñas, y un censo se guarda en un
+fichero que se manda por ahí. Así que el `envp` de toda tarjeta sale VACÍO. Un servicio que dependa
+de variables de entorno va a arrancar y fallar, y eso se dice por su nombre, no en una nota general.
+"""
+import sys, os, tomllib
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from modelo import Servicio, Sistema # noqa: E402
+from formatos import lector # noqa: E402
+
+
+@lector("proc", familia="servicio")
+def leer(texto, nombre=""):
+ sis = Sistema(formato_origen="proc")
+ acta = sis.acta
+ censo = tomllib.loads(texto)
+ servicios = censo.get("servicio", [])
+ if not servicios:
+ acta.anota(0, "SIN-TRADUCIR", nombre or "censo",
+ "el censo no tiene ningún `[[servicio]]`: ¿es el fichero correcto?")
+ return sis
+
+ decididos = [s for s in servicios if s.get("decision") == "muda"]
+ if not decididos:
+ # Salir con cero tarjetas y código 0 sería una semilla VACÍA que parece un éxito. Es el mismo
+ # modo de fallo que un artefacto vacío en el store: un ausente falla ruidosamente, un vacío
+ # llega hasta el final diciendo que todo fue bien.
+ acta.anota(0, "SIN-TRADUCIR", nombre or "censo",
+ f"ninguno de los {len(servicios)} servicios tiene `decision = \"muda\"`. La "
+ "semilla saldría VACÍA y parecería un éxito: decidí primero (`planear.py --decide`)")
+ return sis
+
+ for s in decididos:
+ srv = Servicio(nombre=s["name"], tipo="simple")
+ srv.cmd = (s.get("cmdline") or "").strip()
+ srv.cwd = (s.get("cwd") or "").strip()
+ srv.usuario = (s.get("usuario") or "").strip()
+ if srv.usuario:
+ srv.lineas["usuario"] = 0
+ # Está corriendo y se decidió mudarlo ⇒ se quiere arriba. Es una decisión, y se anota como
+ # tal: el censo no dice si estaba en el arranque, dice que está VIVO ahora.
+ srv.arranque_auto = True
+ srv.reinicio = "siempre"
+ if not srv.cmd:
+ acta.anota(0, "SIN-TRADUCIR", s["name"],
+ "no se pudo leer su línea de comando en el origen: NO se emite tarjeta. Una "
+ "tarjeta que no arranca es peor que una ausente — la ausente falla ruidosamente "
+ "al primer arranque, la rota deja el servicio caído sin que nada avise")
+ continue
+ if s.get("ports"):
+ acta.anota(0, "traducido", f"{s['name']} sirve {', '.join(str(p) for p in s['ports'])}")
+ sis.servicios.append(srv)
+
+ # ⚠ UNA entrada, no una por servicio. La limitación es del LECTOR y vale igual para todos, así
+ # que repetirla 26 veces no informa 26 veces: tapa los hallazgos que sí son de un servicio
+ # concreto (el que corre como `sergio`, el que no tiene cmdline). Un acta en la que casi todo es
+ # la misma línea se deja de leer, y entonces no queda ningún acta.
+ if sis.servicios:
+ acta.anota(0, "decision", f"supervisión de las {len(sis.servicios)} tarjetas",
+ "el censo dice que CORREN, no si el origen las levantaba al arrancar; se declaran "
+ "con reinicio automático porque se decidió mudarlas. Sacá del `genesis` las que no "
+ "quieras al boot")
+ acta.anota(0, "SIN-TRADUCIR", f"envp VACÍO en las {len(sis.servicios)} tarjetas",
+ "el censo NO lee `/proc/<pid>/environ` (trae tokens), así que ninguna tarjeta "
+ "lleva entorno. El que dependa de variables arranca y falla: " +
+ " ".join(x.nombre for x in sis.servicios))
+ return sis