From 95c16097e42af94a02ecfa3417c8aaea21ecdbcc Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 14:51:08 +0000 Subject: [PATCH] =?UTF-8?q?SDD=2029=20+=20censar.py:=20la=20mudanza=20como?= =?UTF-8?q?=20PRODUCTO=20=E2=80=94=20censo,=20plan,=20aplicaci=C3=B3n.=20S?= =?UTF-8?q?in=20IA.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrección de rumbo pedida por el usuario: el SDD 28 derivó hacia «poner ESTA caja a punto» — instalar caddy, copiar claves, montar discos—, y eso es configurar un servidor, no construir algo. **caddy no es un hueco de la imagen: es una instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.** Y la consecuencia de método: todo lo que hice a mano en el SDD 28 ES la especificación de este programa. Cada paso fue determinista; el único juicio fue «¿esto se muda o muere?», que es justo lo que se le pregunta al usuario. No hace falta IA: hace falta que esté escrito. **Etapa 1 implementada: `scripts/mudanza/censar.py`.** Sólo lee. Cruza lo DECLARADO contra lo VIVO y reporta tres clases, cada una justificada por un error medido: - `rc-status` decía `stopped` de cinco servicios que estaban VIVOS ⇒ la verdad es `ppid==1`, no el init. - De 29 dominios, 10 vivos: 13 sin DNS, 2 en 502 y **4 que ya resuelven a otra máquina** — por eso se compara la IP del DNS contra las de la máquina, o `ya-mudado` se lee como `vivo`. - `du -sh` no dice cuánto ocupa COPIAR con hardlinks (60 G vs 85 G): se reportan los dos. - Los nombres no coinciden (`act-runner`/`act_runner`, `crond`/`cronie`, `dbus-daemon`/`dbus`): sin normalizar, el mismo servicio sale a la vez como `no-declarado` y `declarado-muerto`. - Lo descartado se CUENTA: un `ppid==1` que no es servicio suele ser un huérfano, y eso es hallazgo. Probado contra gioser, donde las respuestas ya se sabían a mano: encuentra MÁS (29 dominios contra los 19 que probé; apareció `hifas.gioser.net`). Y afinarlo importó: la primera versión daba 28 `no-declarado` con ruido, la segunda da 18 y son reales (`matilda`, `pacha`, `puerta-…`, `adb`). Un guardián con hallazgos falsos se ignora entero. El SDD deja escritas las etapas 2 (plan exportable: el fichero ES la interfaz) y 3 (aplicación idempotente que verifica EN DESTINO y deja el server corriendo), y mide qué ata la imagen a Hetzner: menos de lo que parece — BIOS vs UEFI y los metadatos de red. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 110 ++++++++++ scripts/mudanza/censar.py | 440 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 550 insertions(+) create mode 100644 docs/29-mudanza.md create mode 100755 scripts/mudanza/censar.py diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md new file mode 100644 index 00000000..d8f56779 --- /dev/null +++ b/docs/29-mudanza.md @@ -0,0 +1,110 @@ +# SDD 29 — La mudanza: imagen portable, pasos exportables, y una app que censa, confirma y ejecuta + +Escrito 2026-09-11, a pedido del usuario, corrigiendo el rumbo del SDD 28: + +> «quiero que estas máquinas sean un ejemplo. Tú dices "falta caddy", pero caddy es una instalación +> mía particular. Quiero que quede una imagen instalable siempre desde Hetzner o cualquier otro +> servicio, poder ejecutar una instalación paso a paso y que los pasos sean exportables para +> reproducirlos en cualquier lado; y que si estoy mudando, la app mudadora detecte qué servicios hay, +> qué contenido, confirme con el usuario qué se muda y qué se borra, y deje al servidor nuevo +> corriendo. Todo automático, sin IA.» + +## 0. La corrección que ordena todo + +El SDD 28 derivó hacia «poner ESTA caja a punto»: instalar caddy, copiar claves, montar discos. Eso +es configurar un servidor, no construir un producto. **caddy no es un hueco de la imagen: es una +instalación particular del usuario, y el programa tiene que DESCUBRIRLA, no traerla.** + +La imagen lleva lo que hace a un sistema takana. Todo lo demás —qué servicios corren, qué dominios +sirven, qué datos pesan— **se censa en la máquina vieja**, se confirma con el usuario, y se ejecuta. + +Y hay una consecuencia de método que conviene decir fuerte: **todo lo que hice a mano en el SDD 28 es +la especificación de este programa.** Censar, cruzar declaración contra realidad, separar vivo de +fósil, confirmar, copiar, verificar. Cada paso fue determinista; ninguno necesitó juicio salvo +«¿esto se muda o muere?», que es justamente lo que se le pregunta al usuario. **No hace falta IA: hace +falta que esté escrito.** + +## 1. Las tres etapas + +``` + CENSO PLAN APLICACIÓN + censar.py → .toml (decisiones) → ejecutar, idempotente + sólo lee revisable · exportable deja el server corriendo +``` + +El fichero del medio es el producto real: **es «los pasos exportables»**. Se revisa, se versiona, se +lleva a otro proveedor, se vuelve a correr. Un plan que se ejecutó es también el registro de lo que +se hizo. + +## 2. Etapa 1 — el censo *(implementado: `scripts/mudanza/censar.py`)* + +**La regla que lo ordena: cruzar lo DECLARADO contra lo VIVO**, y reportar tres clases: + +| clase | qué es | por qué importa | +|---|---|---| +| `vivo` | declarado **y** corriendo | candidato a mudarse | +| `declarado-muerto` | declarado, sin proceso | candidato a morir | +| **`no-declarado`** | **corriendo, sin declaración** | **el trabajo real: nadie sabe cómo levantarlo** | + +No es teoría — cada regla salió de un error medido: + +- **`rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los cinco estaban + vivos.** Un censo que lea el init y no `/proc` produce un plan que no arranca. +- **De 29 dominios del Caddyfile, sólo 10 están vivos**: 13 no tienen DNS, 2 dan 502, y **4 ya + resuelven a otra máquina** — su bloque de config es fósil aunque el sitio funcione. Por eso el + censo compara la IP del DNS contra las IPs de la máquina: sin eso, `ya-mudado` se lee como `vivo`. +- **El caddy de la caja nueva murió en un reinicio y nadie lo notó**, porque estaba arrancado a mano + y no declarado. Ésa es la clase `no-declarado`, y es la que duele. +- **`du -sh` no dice cuánto ocupa COPIAR** un árbol con hardlinks (medido: 60 G vs 85 G). El censo + reporta los dos números. +- **Los nombres no coinciden entre init y proceso**: `act-runner`/`act_runner`, `crond`/`cronie`, + `dbus-daemon`/`dbus`, `fail2ban-server`/`fail2ban`. Sin normalizar, el mismo servicio sale a la vez + como `no-declarado` y `declarado-muerto`: las dos clases que más importan, las dos mal. +- **Lo descartado se cuenta, no se esconde.** Un proceso con `ppid==1` que no es un servicio suele + ser un huérfano, y eso es un hallazgo (los `firefox` fugados que dejaron la granja sin compilar + hora y media fueron exactamente eso). + +⚠ **Y el criterio que decide si el censo sirve**: un guardián con hallazgos falsos se ignora entero. +La primera versión daba 28 `no-declarado` con ruido; tras normalizar nombres da 18, y son reales +(`matilda`, `pacha`, `puerta-…`, `adb`, `ModemManager`). Afinarlo no es cosmética: es la diferencia +entre que alguien lo lea o no. + +## 3. Etapa 2 — el plan *(pendiente)* + +El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las decisiones puestas +(`muda` / `muere`), más el orden y las dependencias. Requisitos: + +1. **Nada sin decidir se ejecuta.** Una entrada vacía aborta: el silencio no es consentimiento. +2. **Lo que muere se dice por su nombre**, con su tamaño, antes de borrar nada. +3. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió. +4. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad. + +## 4. Etapa 3 — la aplicación *(pendiente)* + +Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28: + +1. **Idempotente y reanudable.** Cada copia grande se cortó al menos una vez. +2. **Verifica en DESTINO, no en origen.** «Lo copié» no prueba nada: el `rsync` que llenó el disco + dejó 1367 artefactos vacíos y sólo se vio contando en el destino. +3. **Los servicios `no-declarado` se declaran**, no se replican a mano. Es la salida natural hacia + `arje-absorb`, que ya traduce sysvinit/runit/dinit/openrc a una Semilla — pero **leyendo la + declaración, que es justo la que miente**: hay que alimentarlo con el censo. +4. **Deja el servidor corriendo**, y lo prueba desde afuera: cada dominio `vivo` del plan tiene que + contestar 200 contra la IP NUEVA antes de dar la mudanza por hecha. + +## 5. La imagen, independiente del proveedor + +Lo que hoy la ata a Hetzner es poco y está medido: + +- **Arranque**: la imagen es GPT+BIOS. Hetzner Cloud arranca BIOS; otros proveedores piden UEFI. Los + dos caminos existen (`install-image.sh` / `install-image-efi.sh`); falta **una imagen que haga las + dos** (ESP + BIOS boot en el mismo disco) para no elegir por proveedor. +- **Red**: `netup` hace DHCPv4 y ya cubre el caso difícil (IP `/32` con la puerta fuera del prefijo, + que es lo de Hetzner). Falta IPv6 estático y los metadatos de otros proveedores. +- **Instalación**: `rescue → dd → reboot` no es de Hetzner: sirve en cualquier entorno de rescate con + SSH. Lo único propio es cómo se ENTRA a ese rescate, que es una línea por proveedor. +- **Disco**: ya se resuelve solo — el store es la última partición y crece al disco entero en el + primer arranque. + +⇒ **la imagen está más cerca de ser portable que la herramienta de mudarse.** Por eso el orden es +censo → plan → aplicación, y la portabilidad de la imagen va en paralelo. diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py new file mode 100755 index 00000000..c988f29f --- /dev/null +++ b/scripts/mudanza/censar.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""censar.py — CENSO de una máquina: qué corre, qué sirve, qué datos tiene. Sin IA, sin adivinar. + +Primer paso de la mudanza (SDD 29). Produce un inventario TOML que un humano revisa y que las etapas +siguientes consumen. NO toca nada: todo lo que hace son lecturas. + +── LA REGLA QUE LO ORDENA TODO ───────────────────────────────────────────────────────────────── +Cruzar SIEMPRE lo DECLARADO contra lo VIVO, y reportar las tres clases por separado: + + vivo declarado Y corriendo ⇒ candidato a mudarse + declarado-muerto declarado y sin proceso ⇒ candidato a morir + no-declarado corriendo y sin declaración ⇒ EL TRABAJO REAL: nadie sabe cómo levantarlo + +No es teoría. Medido sobre gioser (2026-09-11): + · `rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los CINCO estaban + vivos colgando de PID 1. Un censo que lea sólo el init habría producido un plan que no arranca. + · De los 19 dominios del Caddyfile, 8 no tenían DNS, 2 daban 502 y 3 ya vivían en OTRA máquina. + Sólo 6 eran reales. Un censo que lea sólo la config habría mudado 13 fósiles. + · Y el propio caddy de la caja NUEVA murió en un reinicio sin que nadie lo notara, porque estaba + arrancado a mano y no declarado. Esa es la clase `no-declarado`, y es la que duele. + +── POR QUÉ CADA SONDA ────────────────────────────────────────────────────────────────────────── +`ppid==1` es la verdad de qué corre, no el init. Los sockets en LISTEN dicen qué SIRVE. El DNS y un +GET dicen si alguien lo usa. `du` dice qué pesa. Nada de esto necesita juicio: son hechos, y el +juicio (qué se muda, qué muere) lo pone el usuario en la etapa siguiente, sobre este fichero. + +Uso: + scripts/mudanza/censar.py --host root@1.2.3.4 [--key ~/.ssh/id] [--out censo.toml] + scripts/mudanza/censar.py --local # censa esta máquina + +La superficie va en inglés (CLAUDE.md regla 4); los mensajes, en castellano. +""" +import argparse, json, os, re, shlex, subprocess, sys, time + +# ── ejecución de sondas ───────────────────────────────────────────────────────────────────────── + +class Objetivo: + """Una máquina a censar. `run` devuelve (salida, ok) y NUNCA levanta: una sonda que falla es un + dato («esa máquina no tiene ss»), no un motivo para abortar el censo entero.""" + + def __init__(self, host=None, key=None, timeout=25): + self.host, self.key, self.timeout = host, key, timeout + + def run(self, cmd): + if self.host: + base = ["ssh", "-o", "StrictHostKeyChecking=accept-new", + "-o", f"ConnectTimeout={self.timeout}", "-o", "BatchMode=yes"] + if self.key: + base += ["-i", os.path.expanduser(self.key)] + argv = base + [self.host, cmd] + else: + argv = ["sh", "-c", cmd] + try: + p = subprocess.run(argv, capture_output=True, text=True, timeout=self.timeout * 4) + return p.stdout.strip(), p.returncode == 0 + except Exception as e: + return f"(sonda falló: {e})", False + + +# ── sondas ────────────────────────────────────────────────────────────────────────────────────── + +def identidad(t): + """Quién es esta máquina. El hostname MIENTE (gioser dice `momento`): la IP es el discriminador.""" + out, _ = t.run("hostname; ip -4 addr show scope global 2>/dev/null | grep -o 'inet [0-9.]*' | " + "cut -d' ' -f2 | head -3; uname -r; cat /proc/1/comm") + ls = [l for l in out.splitlines() if l.strip()] + return { + "hostname": ls[0] if ls else "?", + "ips": [l for l in ls[1:-2]] if len(ls) > 3 else [], + "kernel": ls[-2] if len(ls) >= 2 else "?", + "pid1": ls[-1] if ls else "?", + } + + +def procesos_vivos(t): + """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración.""" + out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'") + vivos = {} + for l in out.splitlines(): + parts = l.split() + if len(parts) == 2 and parts[0].isdigit(): + vivos.setdefault(parts[1], []).append(int(parts[0])) + # los kernel threads no son servicios + return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq", + "migration", "rcu_", "irq/", "idle_"))} + + +def puertos(t): + """Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto.""" + out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3") + res = [] + for l in out.splitlines(): + m = re.search(r'(\S+):(\d+)\s', l) + proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l) + if m: + res.append({"addr": m.group(1), "port": int(m.group(2)), + "proc": (proc.group(1) or proc.group(2)) if proc else "?"}) + # dedup por (puerto, proc) + vistos, uniq = set(), [] + for r in sorted(res, key=lambda x: x["port"]): + k = (r["port"], r["proc"]) + if k not in vistos: + vistos.add(k); uniq.append(r) + return uniq + + +def declarados(t): + """Lo que el init DICE que hay. Se compara contra lo vivo — no se cree.""" + fuentes = {} + # `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser + # daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por + # runlevel, que es lo que de verdad se declara. + out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'") + if ok and out: + fuentes["openrc"] = out.split() + out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'") + if ok and out: + fuentes["systemd"] = [s.replace(".service", "") for s in out.split()] + out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null") + if ok and out: + fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()] + out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in " + "json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null") + if ok and out: + fuentes["arje-seed"] = out.split() + out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null") + if ok and out: + fuentes["runit"] = out.split() + return fuentes + + +def dominios(t): + """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real. + + Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero + 5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE + resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá + es un fósil.""" + confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*", + "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"] + out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null") + nombres = set() + for l in out.splitlines(): + s = l.strip() + if s.startswith("#"): + continue + # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com» + if s.endswith("{") and not s.startswith(("@", "}")): + cand = s[:-1] + elif s.lower().startswith(("server_name", "servername", "serveralias")): + cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else "" + else: + continue + for d in re.split(r'[,\s]+', cand): + d = d.strip().rstrip(";") + if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I): + nombres.add(d.lower()) + return sorted(nombres) + + +def sondear_dominio(d, ips_maquina): + """DNS + HTTP desde AFUERA (desde donde corre el censo). Determinista, sin juicio.""" + ip = "" + try: + r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10) + if r.returncode == 0 and r.stdout.strip(): + ip = r.stdout.split()[0] + except Exception: + pass + code = "000" + if ip: + try: + r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}", + "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20) + code = r.stdout.strip() or "000" + except Exception: + pass + if not ip: + clase = "fosil-sin-dns" + elif ip not in ips_maquina: + clase = "ya-mudado" # resuelve a otra máquina: el bloque de acá es fósil + elif code.startswith(("2", "3")): + clase = "vivo" + else: + clase = "backend-caido" + return {"domain": d, "dns": ip, "http": code, "class": clase} + + +def datos(t, raices): + """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE + para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan + los dos números.""" + res = [] + for r in raices: + out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; " + f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1") + ls = out.splitlines() + if ok and ls: + res.append({"path": r, "size": ls[0], + "size_copy": ls[1] if len(ls) > 1 else ls[0]}) + return res + + +def montajes(t): + out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ") + res = [] + for l in out.splitlines(): + f = l.split() + if len(f) >= 6: + res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]}) + return res + + +def fuera_de_git(t): + """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la + EXISTENCIA, nunca el contenido: un censo no copia secretos.""" + cands = ["~/.ssh", "/root/.ssh", "~/.config/hammer", "/root/.config/hammer", + "/etc/caddy", "/etc/nginx", "/etc/letsencrypt", "/var/lib/gitea", + "/etc/arje/cards.d", "/ente"] + res = [] + for c in cands: + out, ok = t.run(f"ls -d {c} >/dev/null 2>&1 && ls -1 {c} 2>/dev/null | head -20") + if ok: + res.append({"path": c, "entries": out.split() if out else []}) + out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l") + if out.isdigit() and int(out) > 0: + res.append({"path": "crontab", "entries": [f"{out} líneas"]}) + return res + + +# ── clasificación ─────────────────────────────────────────────────────────────────────────────── + +# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como +# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero. +NO_SERVICIOS = { + "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo", + "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl", +} + + +def normalizar(n): + """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice + `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`, + `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como + `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con + `act_runner`.""" + n = n.strip().lower() + for suf in (".service", ".socket", ".timer", ".target"): + if n.endswith(suf): + n = n[: -len(suf)] + n = n.replace("_", "-") + n = re.sub(r'[.\-]tty\d+$', '', n) # agetty.tty1 / agetty-tty2 → agetty + n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n + return n + + +def emparenta(vivo, declarado): + """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos: + el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`, + `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`. + + Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como + `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal.""" + if vivo == declarado: + return True + for suf in ("-daemon", "-server", "-bin"): + if vivo.endswith(suf) and vivo[: -len(suf)] == declarado: + return True + # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo + # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver. + if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]): + return True + return False + + +def es_nombre_de_servicio(n): + """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron + de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades.""" + return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n) + + +def clasificar(vivos, decl): + """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo.""" + # índice normalizado: nombre-canónico → fuentes que lo declaran + idx = {} + for fuente, nombres in decl.items(): + for n in nombres: + if es_nombre_de_servicio(n.lower()): + idx.setdefault(normalizar(n), set()).add(fuente) + + out, vistos, descartados = [], set(), [] + for nombre, pids in sorted(vivos.items()): + canon = normalizar(nombre) + if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon): + # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y + # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo). + descartados.append(nombre) + continue + fuentes = sorted(idx.get(canon, set())) + if not fuentes: # segunda pasada: demonio vs unidad + emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs] + fuentes = sorted(set(emparentados)) + if fuentes: + vistos.update(d for d in idx if emparenta(canon, d)) + vistos.add(canon) + out.append({"name": nombre, "pids": pids, + "class": "vivo" if fuentes else "no-declarado", + "declared_in": fuentes}) + for canon in sorted(set(idx) - vistos): + if canon in NO_SERVICIOS: + continue + out.append({"name": canon, "pids": [], + "class": "declarado-muerto", + "declared_in": sorted(idx[canon])}) + return out, descartados + + +# ── salida ────────────────────────────────────────────────────────────────────────────────────── + +def toml_escape(s): + return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def emitir_toml(c): + L = [] + L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo") + L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.") + L.append(f"schema = \"takana-censo/1\"") + L.append(f"fecha = {toml_escape(c['fecha'])}") + L.append("") + L.append("[maquina]") + for k, v in c["identidad"].items(): + L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}") + L.append("") + for m in c["montajes"]: + L.append("[[montaje]]") + for k, v in m.items(): + L.append(f"{k} = {toml_escape(v)}") + L.append("") + for s in c["servicios"]: + L.append("[[servicio]]") + L.append(f"name = {toml_escape(s['name'])}") + L.append(f"class = {toml_escape(s['class'])}") + L.append(f"declared_in = {json.dumps(s['declared_in'])}") + L.append(f"pids = {json.dumps(s['pids'])}") + L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}") + L.append('decision = "" # muda | muere | (vacío = sin decidir)') + L.append("") + for d in c["dominios"]: + L.append("[[dominio]]") + for k, v in d.items(): + L.append(f"{k} = {toml_escape(v)}") + L.append('decision = "" # muda | muere | (vacío = sin decidir)') + L.append("") + for d in c["datos"]: + L.append("[[datos]]") + for k, v in d.items(): + L.append(f"{k} = {toml_escape(v)}") + L.append('decision = "" # muda | muere | (vacío = sin decidir)') + L.append("") + for f in c["fuera_de_git"]: + L.append("[[fuera_de_git]]") + L.append(f"path = {toml_escape(f['path'])}") + L.append(f"entries = {json.dumps(f['entries'])}") + L.append("") + return "\n".join(L) + "\n" + + +def resumen(c): + i = c["identidad"] + print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══") + print(f" kernel {i['kernel']} · PID 1: {i['pid1']}") + por_clase = {} + for s in c["servicios"]: + por_clase.setdefault(s["class"], []).append(s["name"]) + print("\n── servicios ──") + for cl in ("vivo", "no-declarado", "declarado-muerto"): + n = por_clase.get(cl, []) + marca = "⚠ " if cl == "no-declarado" else " " + print(f"{marca}{cl:18} {len(n):3} {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}") + if c.get("huerfanos"): + print(f" descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): " + f"{len(c['huerfanos'])} {' '.join(sorted(c['huerfanos'])[:8])}") + if por_clase.get("no-declarado"): + print(" ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.") + if c["dominios"]: + print("\n── dominios ──") + pd = {} + for d in c["dominios"]: + pd.setdefault(d["class"], []).append(d["domain"]) + for cl in ("vivo", "ya-mudado", "backend-caido", "fosil-sin-dns"): + n = pd.get(cl, []) + if n: + print(f" {cl:16} {len(n):3} {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}") + if c["datos"]: + print("\n── datos ──") + for d in c["datos"]: + extra = f" (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else "" + print(f" {d['path']:28} {d['size']:>7}{extra}") + print() + + +def main(): + ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.") + ap.add_argument("--host", help="usuario@host a censar por SSH") + ap.add_argument("--key", help="clave SSH") + ap.add_argument("--local", action="store_true", help="censar esta máquina") + ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)") + ap.add_argument("--data-root", action="append", default=[], + help="raíz de datos a medir (repetible). Default: las habituales") + a = ap.parse_args() + if not a.host and not a.local: + ap.error("hace falta --host o --local") + + t = Objetivo(a.host, a.key) + print(" censando… (sólo lecturas, no se toca nada)", file=sys.stderr) + + ident = identidad(t) + vivos = procesos_vivos(t) + decl = declarados(t) + svc, huerfanos = clasificar(vivos, decl) + pts = puertos(t) + doms = [sondear_dominio(d, set(ident["ips"])) for d in dominios(t)] + raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"] + dat = datos(t, raices) + censo = { + "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts, + "dominios": doms, "datos": dat, "montajes": montajes(t), + "fuera_de_git": fuera_de_git(t), + } + resumen(censo) + if a.out: + with open(a.out, "w") as f: + f.write(emitir_toml(censo)) + print(f" censo → {a.out} (decidí `decision` en cada entrada y pasalo a la etapa 2)") + + +if __name__ == "__main__": + main()