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
122 lines
5.5 KiB
Python
122 lines
5.5 KiB
Python
#!/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)
|