gioser

sergio/takana

> 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…

TOML 1471Shell 173Rust 105Markdown 943545 Sutichaykuna

traducir: lector de OpenRC — el par que cierra el camino de gioser, y casi la mitad NO se puede leer

`openrc → arje` sin escribir ningún traductor de ese par: cuarto plugin, cuarto par. OpenRC es el
init de gioser, la máquina que esta mudanza termina borrando.

**El hecho que manda acá: un servicio de OpenRC es un PROGRAMA, no una declaración.** Un unit de
systemd se lee; un script de OpenRC puede hacer cualquier cosa antes de arrancar nada. Medido sobre
el `/etc/init.d` real: **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=`
que valga — leer las variables y emitir tarjeta daría un servicio que arranca OTRA COSA. Regla al
revés que en systemd: `start()` propia ⇒ SIN-TRADUCIR y NO se emite tarjeta.

Los números del corpus real cierran solos: 121 − 1 (no es openrc-run) − 60 (start propia) − 1 (sin
`command=`) = 59, y el lector emitió exactamente 59, con 59 ids únicos.

Dos cosas que OpenRC obliga a ir a buscar fuera del script: `/etc/conf.d/<x>`, donde viven los
argumentos de verdad (a diferencia del `EnvironmentFile=` de systemd, éste SÍ está en la máquina: se
lee y se aplica), y `/etc/runlevels/`, que es lo único que dice si el servicio arranca solo.

**Tres defectos que sólo aparecieron corriendo contra las 121 de verdad**, no sobre un ejemplo mío:

- `name=` no es un identificador sino un rótulo humano: en gioser vale «Aura Backend», con espacio,
  y se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero.
- Ids repetidos: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos y son scripts
  válidos; dos con el mismo nombre dan el MISMO id determinista ⇒ semilla con dos cards homónimas.
  El escritor lo detecta y no emite la segunda.
- La tarjeta decía `"desde": "systemd"` viniendo de OpenRC: el campo que existe para saber de dónde
  salió algo era justo el que mentía. Estaba cableado.

Y el centro deja de filtrar la entrada por extensión: los servicios de OpenRC no tienen ninguna, y
filtrar en el centro es que lo que no entra se pierda EN SILENCIO. Filtra el lector, que sabe, y lo
anota — así un `.bak` en `/etc/init.d` se REPORTA en vez de desaparecer.

Controles: dos corridas dan el fichero byte a byte idéntico; los tres pares previos sin regresión
(`nginx → caddy` sigue dando `Valid configuration`, `systemd → arje` sigue emitiendo sus 3 tarjetas).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x

61c33800c576cd5929f4672787fa9761c013028e · ruwaq Sergio · (1 killa ñawpaq) · tayta e1bd9198c · kay commitpi sach'ata qhaway

Semántico sayk'uy · 11

scripts/mudanza/formatos/arje.py

scripts/mudanza/formatos/openrc.py

scripts/mudanza/traducir.py

4 qillqakuna · +302 −8

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index e207b352..ea7a727e 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -341,7 +341,7 @@ Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` ser
 | familia | modelo | lectores | escritores |
 |---|---|---|---|
 | `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy |
-| `servicio` | `Servicio` / `Sistema` | systemd | arje |
+| `servicio` | `Servicio` / `Sistema` | systemd, openrc | arje |
 
 Un par cruzado se rechaza con un error, no con un intento:
 
@@ -368,6 +368,40 @@ Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`,
 semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la
 tarjeta real de `sshd`**, más `_mudanza` de procedencia.
 
+#### `openrc → arje`: casi la mitad de los servicios NO son declaraciones
+
+Un unit de systemd se lee. Un servicio de OpenRC es un **script de shell** que puede hacer cualquier
+cosa antes de arrancar nada. Medido sobre el `/etc/init.d` real de gioser —la máquina que esta
+mudanza borra— **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=` que
+valga: leer las variables y emitir una tarjeta produciría un servicio que arranca *otra cosa*.
+
+Por eso la regla va al revés que en systemd: **`start()` propia ⇒ SIN-TRADUCIR y no se emite
+tarjeta**. Los números del corpus real cierran solos:
+
+```
+  ficheros en /etc/init.d      : 121
+  no son openrc-run            :   1
+  con start()/stop() propia    :  60   → no se emite tarjeta
+  declarativos sin command=    :   1   → no hay qué arrancar
+  ⇒ traducibles                :  59   ← y el lector emitió exactamente 59
+```
+
+Lo otro que OpenRC obliga a ir a buscar es **`/etc/conf.d/<servicio>`**: el servicio está partido en
+dos ficheros y **los argumentos de verdad viven en el segundo**. A diferencia del `EnvironmentFile=`
+de systemd, éste sí está en la máquina, así que se lee y se aplica. Y el arranque automático **no lo
+dice el script** sino el enlace en `/etc/runlevels/`.
+
+#### Tres defectos que sólo aparecieron corriendo contra las 121 de verdad
+
+- **`name=` no es un identificador, es un rótulo**: en gioser vale `Aura Backend`, con espacio — que
+  se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero, que
+  además es lo que usan `rc-update` y `/etc/runlevels`.
+- **Ids repetidos**: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos, y son
+  scripts de OpenRC válidos. Dos servicios con el mismo nombre dan **el mismo id determinista** ⇒ una
+  semilla con dos cards homónimas. El escritor lo detecta y no emite la segunda.
+- **Procedencia cableada**: la tarjeta decía `"desde": "systemd"` viniendo de OpenRC. El campo que
+  existe para saber de dónde salió algo era el que mentía.
+
 #### 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
diff --git a/scripts/mudanza/formatos/arje.py b/scripts/mudanza/formatos/arje.py
index d1a13bb9..3fb6de8d 100644
--- a/scripts/mudanza/formatos/arje.py
+++ b/scripts/mudanza/formatos/arje.py
@@ -26,7 +26,7 @@ from formatos import escritor                              # noqa: E402
 SEM = "takana-mudanza"
 
 
-def _tarjeta(srv, acta):
+def _tarjeta(srv, acta, desde):
     argv_all = shlex.split(srv.cmd) if srv.cmd else []
     if not argv_all:
         return None
@@ -102,7 +102,7 @@ def _tarjeta(srv, acta):
             "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
         },
         "supervision": sup,
-        "_mudanza": {"desde": "systemd", "descripcion": srv.descripcion,
+        "_mudanza": {"desde": desde, "descripcion": srv.descripcion,
                      "tipo_origen": srv.tipo, "cwd_original": srv.cwd,
                      "usuario_original": srv.usuario, "arranque_auto": srv.arranque_auto},
     }
@@ -111,15 +111,25 @@ def _tarjeta(srv, acta):
 @escritor("arje", familia="servicio")
 def escribir(sis):
     acta = sis.acta
-    hijas = []
+    hijas, vistos = [], {}
     for srv in sis.servicios:
+        # Dos servicios con el mismo nombre ⇒ el mismo id determinista ⇒ una semilla con dos cards
+        # que se llaman igual. Pasa de verdad: `/etc/init.d` de gioser guarda copias `*.bak-FECHA`
+        # junto a los servicios vivos, y son scripts de OpenRC perfectamente válidos.
+        if srv.nombre in vistos:
+            acta.anota(srv.origen, "SIN-TRADUCIR", srv.nombre,
+                       f"nombre repetido (ya salió en la línea {vistos[srv.nombre]}): produciría DOS "
+                       "tarjetas con el mismo id. No se emite la segunda — decidí cuál vale y borrá "
+                       "la otra (suelen ser copias `*.bak` en el mismo directorio)")
+            continue
+        vistos[srv.nombre] = srv.origen
         if not srv.arranque_auto:
             # Un unit instalado pero sin `WantedBy=` no lo levanta el arranque. Declararlo igual
             # cambiaría el sistema: se emite, pero se dice.
             acta.anota(srv.origen, "decision", f"{srv.nombre} (sin WantedBy=)",
                        "el unit no se levanta al arrancar en el origen; la semilla SÍ lo va a "
                        "levantar. Sacalo si no lo querés al boot")
-        t = _tarjeta(srv, acta)
+        t = _tarjeta(srv, acta, sis.formato_origen or "?")
         if t is None:
             acta.anota(srv.origen, "SIN-TRADUCIR", srv.nombre,
                        "sin comando de arranque: NO se emite tarjeta. Una tarjeta que no arranca es "
diff --git a/scripts/mudanza/formatos/openrc.py b/scripts/mudanza/formatos/openrc.py
new file mode 100644
index 00000000..a99c4eba
--- /dev/null
+++ b/scripts/mudanza/formatos/openrc.py
@@ -0,0 +1,248 @@
+"""Lector de servicios de OpenRC → modelo pivote «servicio» (SDD 29).
+
+OpenRC es el init de gioser — la máquina que esta mudanza termina BORRANDO — así que este lector es
+el que cierra el camino: `openrc → arje`, sin escribir ningún traductor de ese par.
+
+── EL HECHO QUE MANDA ACÁ: UN SERVICIO DE OPENRC ES UN PROGRAMA ────────────────────────────────
+Un unit de systemd es declarativo: se lee. Un servicio de OpenRC es un **script de shell** que puede
+hacer cualquier cosa antes de arrancar nada. Medido en `/etc/init.d` de gioser: **59 de 121 definen
+su propia `start()`**. Casi la mitad. En ésos no hay `command=` que valga — leer las variables y
+emitir una tarjeta produciría un servicio que arranca OTRA COSA, o nada.
+
+Por eso la regla es al revés que en systemd: **`start()` propia ⇒ SIN-TRADUCIR, y no se emite
+tarjeta**. Una tarjeta ausente falla ruidosamente al primer arranque; una tarjeta que arranca lo que
+no es llega hasta producción diciendo que todo fue bien.
+
+── LO QUE SÍ HAY QUE IR A BUSCAR: `/etc/conf.d/<servicio>` ─────────────────────────────────────
+OpenRC parte el servicio en dos ficheros, y **los argumentos de verdad viven en el segundo**. Leer
+sólo `/etc/init.d/<x>` da un comando que parece completo y le faltan las opciones. A diferencia del
+`EnvironmentFile=` de systemd, este fichero SÍ está en la máquina: se lee, se aplica y se dice.
+"""
+import os, re, shlex, sys
+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
+
+_ASIG = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)$')
+_FUNC = re.compile(r'^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_:-]*)\s*\(\)\s*\{?')
+
+# Las fases que OpenRC deja redefinir. `start`/`stop` son las que vuelven intraducible el servicio;
+# las demás son trabajo aparte del arranque, igual que los `ExecStartPre=` de systemd.
+_FASES_MORTALES = {"start", "stop"}
+_FASES_EXTRA = {"start_pre", "start_post", "stop_pre", "stop_post", "reload", "restart", "status"}
+
+
+def _asignaciones(texto):
+    """Variables de NIVEL SUPERIOR y funciones definidas, en una sola pasada.
+
+    ⚠ Hay que saltear el cuerpo de las funciones: un `command=` dentro de una `start()` no es la
+    configuración del servicio, es una línea de un programa. Confundirlos es leer el fichero como si
+    fuera declarativo cuando no lo es."""
+    variables, funciones, cuerpos = {}, {}, {}
+    prof, actual, buf = 0, None, []
+    for n, cruda in enumerate(texto.splitlines(), 1):
+        ln = cruda.strip()
+        if prof == 0:
+            m = _FUNC.match(cruda)
+            if m and not ln.startswith("#"):
+                actual, buf = m.group(1), []
+                funciones[actual] = n
+                prof = cruda.count("{") - cruda.count("}")
+                if prof == 0:                     # `f() {` con la llave en la línea siguiente
+                    prof = 1
+                continue
+            if ln.startswith("#") or not ln:
+                continue
+            m = _ASIG.match(ln)
+            if m:
+                k, v = m.group(1), m.group(2).strip()
+                if v[:1] in ("'", '"') and v[-1:] == v[:1] and len(v) > 1:
+                    v = v[1:-1]
+                variables[k] = (v, n)
+        else:
+            buf.append(cruda)
+            prof += cruda.count("{") - cruda.count("}")
+            if prof <= 0:
+                prof, cuerpos[actual], actual = 0, "\n".join(buf), None
+    return variables, funciones, cuerpos
+
+
+def _expandir(v, variables, acta, linea, texto_orig):
+    """`${command} ${command_args}` es idioma corriente en estos scripts. Se expande con lo que el
+    propio fichero define; lo que quede sin resolver se DICE, no se deja con el `$` adentro."""
+    def sub(m):
+        k = m.group(1) or m.group(2)
+        if k in variables:
+            return variables[k][0]
+        acta.anota(linea, "SIN-TRADUCIR", texto_orig,
+                   f"usa `${{{k}}}`, que el script no define: lo pone el entorno del init y acá no "
+                   "se puede saber su valor")
+        return m.group(0)
+    return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)", sub, v)
+
+
+def _depend(cuerpo):
+    """`depend() { need net; after firewall }` → (necesita, tras)."""
+    nec, tras = [], []
+    for ln in (cuerpo or "").splitlines():
+        for pieza in ln.split(";"):
+            p = pieza.split()
+            if not p:
+                continue
+            if p[0] in ("need", "use"):
+                nec += p[1:]
+            elif p[0] == "after":
+                tras += p[1:]
+    return nec, tras
+
+
+def _entorno_de_args(val, srv, acta, linea):
+    """`supervise_daemon_args="--stdout … -e K=V -e K2=V2"`: el entorno del servicio vive ACÁ."""
+    try:
+        piezas = shlex.split(val)
+    except ValueError:
+        return
+    i, otros = 0, []
+    while i < len(piezas):
+        p = piezas[i]
+        if p in ("-e", "--env") and i + 1 < len(piezas) and "=" in piezas[i + 1]:
+            k, _, v = piezas[i + 1].partition("=")
+            srv.entorno[k] = v
+            i += 2
+            continue
+        otros.append(p)
+        i += 1
+    if otros:
+        acta.anota(linea, "no-aplica", " ".join(otros)[:70],
+                   "opciones del supervisor de OpenRC (bitácoras, pidfile): el destino las resuelve "
+                   "por su cuenta")
+
+
+@lector("openrc", familia="servicio")
+def leer(texto, nombre=""):
+    sis = Sistema(formato_origen="openrc")
+    acta = sis.acta
+    base = os.path.basename(nombre or "servicio")
+    srv = Servicio(nombre=base)
+
+    primera = (texto.splitlines() or [""])[0]
+    if "openrc-run" not in primera:
+        # No se saltea en silencio: `/etc/init.d` junta servicios con copias de respaldo y restos, y
+        # decir cuáles se ignoraron es lo único que distingue «no aplica» de «se me pasó».
+        acta.anota(1, "SIN-TRADUCIR", base,
+                   "el fichero no arranca con `#!/sbin/openrc-run`: no es un servicio de OpenRC")
+        return sis
+
+    variables, funciones, cuerpos = _asignaciones(texto)
+
+    # ── `/etc/conf.d/<x>` pisa al `/etc/init.d/<x>`, y ahí están los argumentos de verdad ──
+    if nombre:
+        confd = os.path.join(os.path.dirname(os.path.dirname(nombre)), "conf.d", base)
+        if os.path.isfile(confd):
+            ov, _, _ = _asignaciones(open(confd, errors="replace").read())
+            if ov:
+                variables.update(ov)
+                acta.anota(0, "traducido", f"conf.d/{base}",
+                           f"se aplicaron {len(ov)} variable(s) de `/etc/conf.d/{base}`: sin esto el "
+                           "comando saldría sin sus opciones y parecería completo")
+        else:
+            acta.anota(0, "no-aplica", f"conf.d/{base}", "el servicio no tiene fichero de conf.d")
+
+    # ── la regla dura: `start()`/`stop()` propias ⇒ el servicio es un PROGRAMA ──
+    propias = _FASES_MORTALES & set(funciones)
+    for f in sorted(propias):
+        acta.anota(funciones[f], "SIN-TRADUCIR", f"{f}() {{ … }}",
+                   f"el servicio define su propia `{f}()`: es shell arbitrario, no una declaración. "
+                   "Leer `command=` acá daría una tarjeta que arranca otra cosa. Hay que portarla a "
+                   "mano (59 de 121 servicios de gioser están en este caso)")
+    for f in sorted(_FASES_EXTRA & set(funciones)):
+        acta.anota(funciones[f], "SIN-TRADUCIR", f"{f}() {{ … }}",
+                   f"`{f}()` es una fase aparte del arranque; el destino no la tiene y hay que "
+                   "portarla a mano")
+
+    def v(k):
+        return variables[k][0] if k in variables else ""
+
+    def ln_de(k):
+        return variables[k][1] if k in variables else 0
+
+    srv.descripcion = v("description")
+    # ⚠ `name=` en OpenRC es un RÓTULO HUMANO, no un identificador: en gioser vale «Aura Backend»,
+    # con espacio y todo. Usarlo como nombre metía el espacio en el id y en el path del cgroup. El
+    # identificador es el NOMBRE DEL FICHERO, que además es lo que dicen `rc-update` y `/etc/runlevels`.
+    if "name" in variables and not srv.descripcion:
+        srv.descripcion = v("name")
+
+    cmd = v("command")
+    if cmd and not propias:
+        args = v("command_args")
+        entero = (cmd + " " + args).strip()
+        entero = _expandir(entero, variables, acta, ln_de("command"), entero)
+        srv.cmd = entero
+        srv.origen = ln_de("command")
+        acta.anota(srv.origen, "traducido", f"command={cmd}" + (f" {args}" if args else ""))
+    elif not propias:
+        acta.anota(0, "SIN-TRADUCIR", base,
+                   "el script no define `command=` ni una `start()` propia: no hay de dónde sacar "
+                   "qué arrancar")
+
+    if "command_user" in variables:
+        u, _, g = v("command_user").partition(":")
+        srv.usuario, srv.lineas["usuario"] = u, ln_de("command_user")
+        if g:
+            srv.grupo, srv.lineas["grupo"] = g, ln_de("command_user")
+    if "directory" in variables:
+        srv.cwd, srv.lineas["cwd"] = v("directory"), ln_de("directory")
+        acta.anota(srv.lineas["cwd"], "traducido", f"directory={srv.cwd}")
+
+    # ── supervisión ──
+    if v("supervisor") == "supervise-daemon":
+        srv.reinicio, srv.lineas["reinicio"] = "siempre", ln_de("supervisor")
+        acta.anota(ln_de("supervisor"), "traducido", "supervisor=supervise-daemon")
+    elif v("command_background") in ("yes", "true", "YES"):
+        # start-stop-daemon manda el proceso al fondo y OpenRC NO lo vigila: si muere, queda muerto.
+        srv.tipo, srv.reinicio = "forking", "no"
+        acta.anota(ln_de("command_background"), "decision", "command_background=yes",
+                   "OpenRC lo manda al fondo y NO lo vigila (si muere, queda muerto). El destino SÍ "
+                   "lo va a supervisar: es una mejora, pero es un cambio de comportamiento")
+    else:
+        srv.reinicio = "no"
+
+    if "respawn_delay" in variables:
+        try:
+            srv.reinicio_ms = int(float(v("respawn_delay")) * 1000)
+            acta.anota(ln_de("respawn_delay"), "traducido", f"respawn_delay={v('respawn_delay')}")
+        except ValueError:
+            acta.anota(ln_de("respawn_delay"), "SIN-TRADUCIR", f"respawn_delay={v('respawn_delay')}",
+                       "no es un número de segundos")
+    if v("respawn_max") not in ("", "0"):
+        acta.anota(ln_de("respawn_max"), "SIN-TRADUCIR", f"respawn_max={v('respawn_max')}",
+                   "OpenRC deja de reintentar tras N veces; arje reintenta SIEMPRE, así que un "
+                   "servicio que allá se rendía acá va a reintentar sin fin")
+
+    for k in ("supervise_daemon_args", "start_stop_daemon_args"):
+        if k in variables:
+            _entorno_de_args(v(k), srv, acta, ln_de(k))
+
+    srv.necesita, srv.tras = _depend(cuerpos.get("depend"))
+    if "depend" in funciones:
+        acta.anota(funciones["depend"], "traducido",
+                   f"depend() → need {' '.join(srv.necesita) or '—'} / after {' '.join(srv.tras) or '—'}")
+
+    # ── ¿lo levanta el arranque? Lo dice el enlace en `/etc/runlevels/`, no el script ──
+    if nombre:
+        rl = os.path.join(os.path.dirname(os.path.dirname(nombre)), "runlevels")
+        if os.path.isdir(rl):
+            niveles = [d for d in sorted(os.listdir(rl))
+                       if os.path.exists(os.path.join(rl, d, base))]
+            srv.arranque_auto = bool(niveles)
+            acta.anota(0, "traducido", f"runlevels: {', '.join(niveles) or 'ninguno'}",
+                       "el arranque automático lo dice el enlace en /etc/runlevels, no el script")
+        else:
+            acta.anota(0, "SIN-TRADUCIR", f"runlevels de {base}",
+                       "no se pudo mirar `/etc/runlevels`: no se sabe si el servicio arranca solo, y "
+                       "suponer que sí lo levantaría en el destino sin que nadie lo haya pedido")
+
+    if not propias:
+        sis.servicios.append(srv)
+    return sis
diff --git a/scripts/mudanza/traducir.py b/scripts/mudanza/traducir.py
index 1b4397e8..6bbf5710 100755
--- a/scripts/mudanza/traducir.py
+++ b/scripts/mudanza/traducir.py
@@ -87,11 +87,13 @@ def _fusionar(a, b):
 def _entradas(ruta):
     """Un fichero, o todos los `*.service`/`*.conf` de un directorio, en orden determinista."""
     if os.path.isdir(ruta):
+        # TODOS los ficheros regulares no ocultos, no sólo los de cierta extensión: los servicios de
+        # OpenRC no tienen ninguna. Filtrar acá por nombre sería el centro decidiendo qué es de cada
+        # formato — y lo que no entra se pierde EN SILENCIO. Filtra el lector, que sabe, y lo DICE.
         hs = sorted(f for f in os.listdir(ruta)
-                    if f.endswith((".service", ".conf")) and
-                    os.path.isfile(os.path.join(ruta, f)))
+                    if not f.startswith(".") and os.path.isfile(os.path.join(ruta, f)))
         if not hs:
-            sys.exit(f"✗ {ruta} no tiene ningún `*.service` ni `*.conf`")
+            sys.exit(f"✗ {ruta} no tiene ningún fichero regular")
         return [os.path.join(ruta, h) for h in hs]
     return [ruta]