"""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/` ───────────────────────────────────── OpenRC parte el servicio en dos ficheros, y **los argumentos de verdad viven en el segundo**. Leer sólo `/etc/init.d/` 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/` pisa al `/etc/init.d/`, 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