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/`**: 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/` ───────────────────────────────────── +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 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]