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