Files
takana/scripts/mudanza/modelo.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

122 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""modelo.py — el MODELO PIVOTE de configuración de servicio, y el acta que lo acompaña (SDD 29).
── POR QUÉ UN PIVOTE Y NO PARES ────────────────────────────────────────────────────────────────
Traducir de a pares crece en N×M: con nginx, apache, caddy, haproxy y lighttpd son 20 traductores, y
cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un formato
nuevo cuesta UNO o DOS plugins, y estrena todos los pares de golpe. Es la diferencia entre un centro
de traducción y una pila de conversores.
── EL ACTA VIAJA DENTRO DEL MODELO, NO AL COSTADO ──────────────────────────────────────────────
Ésta es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor,
lo que un LECTOR no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el
escritor puede emitirlo como comentario y el humano lo ve en el fichero que va a desplegar.
Doctrina heredada de `soltar`/`paskaq` (tawasuyu): **elisión honesta** — lo que no se pudo traducir
se reporta con su línea y su motivo, nunca se descarta en silencio.
"""
from dataclasses import dataclass, field
@dataclass
class Accion:
"""Qué hace una ruta. `tipo` ∈ {proxy, archivos, redir, responder, intentar}."""
tipo: str
valor: str = ""
extra: dict = field(default_factory=dict)
@dataclass
class Ruta:
"""Un camino dentro de un sitio. `clase` ∈ {prefijo, exacto, regex}.
`regex` se conserva a propósito aunque casi ningún destino lo soporte igual: **perder la
distinción sería traducir mal en silencio**. El escritor decide si sabe emitirla o la manda al
acta."""
patron: str
clase: str = "prefijo"
acciones: list = field(default_factory=list)
origen: int = 0
@dataclass
class Sitio:
nombres: list = field(default_factory=list)
puertos: list = field(default_factory=list) # "80", "443"
tls: dict = field(default_factory=dict) # {cert, key}
raiz: str = ""
indices: list = field(default_factory=list)
rutas: list = field(default_factory=list)
acciones: list = field(default_factory=list) # a nivel de sitio
origen: int = 0
@dataclass
class Acta:
"""Registro de procedencia. Cuatro estados, y el que importa es el primero."""
entradas: list = field(default_factory=list)
def anota(self, linea, estado, texto, motivo=""):
# estado ∈ {SIN-TRADUCIR, decision, traducido, no-aplica}
self.entradas.append({"linea": linea, "estado": estado,
"directiva": texto, "motivo": motivo})
def sin_traducir(self):
return [e for e in self.entradas if e["estado"] == "SIN-TRADUCIR"]
def por_estado(self):
d = {}
for e in self.entradas:
d.setdefault(e["estado"], []).append(e)
return d
@dataclass
class Config:
"""Lo que un lector produce y un escritor consume."""
formato_origen: str = ""
sitios: list = field(default_factory=list)
acta: Acta = field(default_factory=Acta)
# ── FAMILIA «servicio» ──────────────────────────────────────────────────────────────────────────
# ⚠ El pivote NO es uno solo: es uno POR FAMILIA. Un unit de systemd no es un sitio web, y meterlo a
# la fuerza en `Sitio`/`Ruta` sería justo el «parecerse» que el acta existe para evitar. Cada familia
# tiene su modelo, y el registro sólo empareja lectores con escritores de la MISMA familia — un par
# cruzado se rechaza ruidosamente en vez de producir basura plausible.
@dataclass
class Servicio:
"""Un proceso supervisado. El denominador común de systemd, OpenRC, runit, s6 y arje.
`usuario` se conserva aunque el destino no siempre sepa emitirlo: es el campo donde una elisión
silenciosa **escala privilegios** en vez de sólo perder una opción."""
nombre: str = ""
descripcion: str = ""
tipo: str = "simple" # simple | forking | oneshot | notify | dbus
cmd: str = "" # la línea de arranque, tal cual
cwd: str = ""
usuario: str = ""
grupo: str = ""
entorno: dict = field(default_factory=dict)
entorno_ficheros: list = field(default_factory=list) # no se pueden leer al traducir
tras: list = field(default_factory=list) # orden (After=)
necesita: list = field(default_factory=list) # dependencia dura (Requires=)
reinicio: str = "no" # no | siempre | si-falla
reinicio_ms: int = 0
arranque_auto: bool = False # ¿lo levanta el arranque? (WantedBy=)
plantilla: bool = False # `foo@.service`: no instanciable a ciegas
origen: int = 0
# {campo: línea} — el lector anota DÓNDE vio cada cosa para que el escritor pueda señalar la
# línea real en vez de un 0. Un acta que dice «línea 0» obliga a buscar a mano lo que ya se sabía.
lineas: dict = field(default_factory=dict)
@dataclass
class Sistema:
"""Lo que produce un lector de la familia «servicio» y consume su escritor."""
formato_origen: str = ""
servicios: list = field(default_factory=list)
acta: Acta = field(default_factory=Acta)