"""Escritor de tarjetas de arje ← modelo pivote «servicio» (SDD 29). Emite una **semilla** (`seed.card.json`): una card `Virtual` raíz cuyos `genesis` son los servicios. Es el mismo molde que `declarar.py` saca del censo — la diferencia es de dónde viene el dato: allá de `/proc` (lo que CORRE), acá de los units (lo que está DECLARADO). Se complementan, y ninguno de los dos solo alcanza: en gioser el init daba `stopped` para cinco servicios vivos. ⚠ **LO QUE ESTE ESCRITOR NO PUEDE Y NO FINGE** El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla real del producto, `crates/takana-bootstrap/src/lib.rs`). No hay campo de usuario. Por lo tanto un `User=www-data` no tiene traducción: la tarjeta correría el servicio **como root**. Eso no es perder una opción, es **escalar privilegios en un fichero generado que nadie vuelve a leer**, así que sale como SIN-TRADUCIR y el traductor termina con código ≠ 0. `Type=forking` es el otro: arje supervisa al proceso que lanza, y un demonio que se va a segundo plano y sale deja a su padre muerto a los milisegundos ⇒ **bucle de reinicio**. Casi siempre se arregla con la bandera de primer plano del propio demonio (`-g 'daemon off;'`, `-D`, `--foreground`), pero cuál es depende del demonio: decirlo es del humano, adivinarlo no. """ import json, shlex, sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from ids import ulid_determinista # noqa: E402 from formatos import escritor # noqa: E402 SEM = "takana-mudanza" def _tarjeta(srv, acta, desde): argv_all = shlex.split(srv.cmd) if srv.cmd else [] if not argv_all: return None if srv.cwd and srv.cwd not in ("/", ""): # `Native` no tiene `cwd`: se envuelve, que es el idioma que la tarjeta de sshd ya usa. exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(srv.cwd)} && exec {srv.cmd}"] acta.anota(srv.lineas.get("cwd", srv.origen), "decision", f"{srv.nombre}: cwd={srv.cwd}", "el payload `Native` no tiene `cwd`: se envuelve en `sh -c 'cd … && exec …'`") else: exec_, argv = argv_all[0], argv_all[1:] if srv.usuario and srv.usuario != "root": acta.anota(srv.lineas.get("usuario", srv.origen), "SIN-TRADUCIR", f"{srv.nombre}: User={srv.usuario}", "el payload `Native` de arje NO tiene campo de usuario ⇒ esta tarjeta corre el " "servicio COMO ROOT. Hay que bajar el privilegio dentro del comando " "(p. ej. `setpriv --reuid`) antes de desplegarla") if srv.grupo and srv.grupo != "root": acta.anota(srv.lineas.get("grupo", srv.origen), "SIN-TRADUCIR", f"{srv.nombre}: Group={srv.grupo}", "mismo motivo que `User=`: no hay dónde ponerlo en la tarjeta") if srv.tipo == "forking": acta.anota(srv.lineas.get("tipo", srv.origen), "SIN-TRADUCIR", f"Type=forking ({srv.nombre})", "arje supervisa al proceso que lanza: un demonio que se va a segundo plano sale " "enseguida y queda en BUCLE DE REINICIO. Hay que agregarle su bandera de primer " "plano, que depende del demonio") elif srv.tipo in ("notify", "dbus"): acta.anota(srv.lineas.get("tipo", srv.origen), "decision", f"Type={srv.tipo} ({srv.nombre})", f"arje no implementa el protocolo de `{srv.tipo}`: se trata como `simple`, o sea " "que se da por arrancado al ejecutarse y no cuando el servicio avisa que está listo") # `oneshot` SÍ tiene destino: la semilla real del producto usa `\"supervision\": \"OneShot\"`. if srv.tipo == "oneshot": sup = "OneShot" elif srv.reinicio == "no": sup = "OneShot" acta.anota(srv.lineas.get("reinicio", srv.origen), "decision", f"Restart=no ({srv.nombre})", "sin reinicio, la tarjeta se emite como `OneShot`: arje no lo revive si muere") else: ini = srv.reinicio_ms or 500 sup = {"Restart": {"initial": ini, "max": max(ini * 40, 20000)}} if srv.reinicio == "si-falla": acta.anota(srv.lineas.get("reinicio", srv.origen), "decision", f"Restart=si-falla ({srv.nombre})", "arje reinicia SIEMPRE, no sólo tras fallo: una salida limpia también lo revive") if srv.entorno_ficheros: acta.anota(srv.lineas.get("entorno_ficheros", srv.origen), "SIN-TRADUCIR", f"EnvironmentFile ({srv.nombre})", "no se copió ninguna variable de esos ficheros: el `envp` de la tarjeta sale " "INCOMPLETO y el servicio va a fallar tarde") return { "flow": {"input": [], "output": []}, "genesis": [], "id": ulid_determinista(SEM, srv.nombre), "label": srv.nombre, "lifecycle": "daemon", "lineage": None, "payload": {"Native": {"argv": argv, "envp": [f"{k}={v}" for k, v in sorted(srv.entorno.items())], "exec": exec_}}, "permissions": {"filesystem": "read-write", "ipc": {"allow": []}, "networking": "full", "processes": True}, "priority": "normal", "provides": [], # `After=`/`Requires=` se conservan por NOMBRE: arje resuelve el orden por su grafo, y los # objetivos de systemd (`network.target`…) no existen acá — por eso se filtran los `.target`. "requires": sorted({d.replace(".service", "") for d in (srv.tras + srv.necesita) if not d.endswith(".target")}), "schema_version": 1, "soma": { "cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{srv.nombre}"}, "cpu_affinity": None, "namespaces": {k: False for k in ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")}, "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None}, }, "supervision": sup, "_mudanza": {"desde": desde, "descripcion": srv.descripcion, "tipo_origen": srv.tipo, "cwd_original": srv.cwd, "usuario_original": srv.usuario, "arranque_auto": srv.arranque_auto}, } @escritor("arje", familia="servicio") def escribir(sis, etiqueta="mudanza"): acta = sis.acta 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, 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 " "peor que ninguna — la ausente falla ruidosamente al primer arranque") continue hijas.append(t) hijas.sort(key=lambda t: t["label"]) # orden estable ⇒ `diff` útil entre corridas # ⚠ La raíz se calca de la semilla REAL del producto (`STAGE1_SEED_CARD`), y el calco importa: # cuando esto vivía en dos sitios, cada copia tenía un campo mal y ninguno de los dos igual. # `provides` debe anunciar Spawn y Journal —si no, las hijas no tienen quién las lance ni dónde # escribir— y la supervisión de una card `Virtual` es `OneShot`: no hay proceso que reiniciar, # así que un `Restart` acá le pide a arje que respawnee algo que nunca corrió. raiz = { "flow": {"input": [], "output": []}, "genesis": hijas, "id": ulid_determinista(SEM + "-raiz", etiqueta), "label": etiqueta, "lifecycle": "daemon", "lineage": None, "payload": "Virtual", "permissions": {"filesystem": "read-write", "ipc": {"allow": []}, "networking": "full", "processes": True}, "priority": "normal", "provides": ["Spawn", "Journal"], "requires": [], "schema_version": 1, "soma": {"cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"}, "cpu_affinity": None, "namespaces": {k: False for k in ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")}, "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None}}, "supervision": "OneShot", } return json.dumps(raiz, indent=1, sort_keys=True, ensure_ascii=False) + "\n"