Files
takana/scripts/mudanza/formatos/openrc.py
T
SergioandClaude Opus 5 61c33800c5 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
2026-09-11 20:30:20 +00:00

249 lines
12 KiB
Python

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