diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 8b58f17c..565d7254 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -213,6 +213,44 @@ memoria; el programa fue a mirar. Es exactamente para esto que existe. `caddy`. Un clasificador que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no encuentra el catálogo, en vez de dar una respuesta falsa con forma de respuesta. +### 4.0 ter · Alternativas: elegir el equivalente, con una recomendación que no sea gusto + +Un servicio casi nunca es insustituible: nginx, apache y caddy sirven HTTP; redis y valkey son caché. +`planear.py` agrupa por **familia funcional**, muestra qué equivalentes tiene takana, y recomienda — +pero **no «el mejor» en abstracto**: un gusto disfrazado de dato es peor que no recomendar. + +Los criterios son hechos comprobables, en este orden: + +1. **El que YA CORRE, si takana lo construye.** Y la razón es el costo real: *cambiar de servidor web + no es cambiar un binario — es reescribir la configuración entera*. Eso casi siempre pesa más que + cualquier ventaja teórica del otro. +2. Si el que corre **no** está en el catálogo pero un equivalente sí, se recomienda ése — porque es + el que takana puede construir, firmar y reproducir — **diciendo lo que cuesta**. +3. Si no hay ninguno, se dice eso. **No se sugiere «usá otro» cuando ese otro tampoco está.** + +Medido contra el catálogo real: + +``` +caddy → alternativas: **caddy**✓ nginx✗ apache✗ lighttpd✗ traefik✓ haproxy✗ + recomendado: caddy — es el que YA corre y takana lo construye (sellado). + Cambiar obliga a REESCRIBIR la configuración entera. + +nginx → recomendado: caddy — el que corre (nginx) no está en el catálogo; de la familia + sí están sellados: caddy, traefik. Cambiar implica reescribir la + configuración: es una decisión, no un trámite. + +redis → alternativas: redis✗ valkey✗ memcached✗ + recomendado: NINGUNO — de la familia «caché en memoria» no hay nada en el + catálogo. Hay que escribir la receta del que se elija. +``` + +⚠ **El dato que la recomendación destapa**: de servidores web el corpus sólo tiene **`caddy` y +`traefik`**. No hay nginx, ni apache, ni lighttpd. Saberlo antes de mudar vale más que la +recomendación misma. + +Si se elige un equivalente, queda en el censo (`alternativa = "…"`) y **el plan lo dice en su paso**, +con la advertencia arriba de todo: la configuración del origen no sirve tal cual. + ### 4.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)* `aplicar.py --plan plan.toml [--dry-run] [--only ] [--paso N] [--hecho N]` diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index fa7eca06..737d313a 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -28,6 +28,8 @@ Uso: """ import argparse, os, sys, shlex, time, tomllib +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # ── tamaños ───────────────────────────────────────────────────────────────────────────────────── _MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024} @@ -103,6 +105,74 @@ def _no_aplica(nombre): return None +# ── familias de equivalentes funcionales ──────────────────────────────────────────────────────── +# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de +# dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es +# mejor — eso sale de datos medibles (¿está en el catálogo? ¿está sellado?) y del costo de cambiar. +FAMILIAS = { + "servidor web / proxy": ["caddy", "nginx", "apache", "httpd", "lighttpd", "traefik", "haproxy"], + "base de datos": ["postgresql", "mariadb", "mysql", "sqlite"], + "caché en memoria": ["redis", "valkey", "memcached"], + "forja git": ["gitea", "forgejo", "gitlab"], + "contenedores": ["docker", "podman", "containerd"], + "dns": ["unbound", "bind", "dnsmasq", "coredns"], + "correo": ["postfix", "exim", "opensmtpd"], + "base vectorial": ["qdrant", "milvus", "weaviate"], +} + + +def _receta(nombre, repo): + """(existe, sellado) para un nombre de receta.""" + import glob + f = os.path.join(repo, "recipes", f"{nombre}.toml") + if not os.path.exists(f): + return False, False + return True, bool(glob.glob(os.path.join(repo, "store", f"*-{nombre}"))) + + +def alternativas(nombre, repo): + """Equivalentes funcionales del servicio, con una recomendación RAZONADA. + + ── LOS CRITERIOS, Y POR QUÉ ÉSOS ──────────────────────────────────────────────────────────── + No se recomienda «el mejor» en abstracto: eso es gusto, y un gusto disfrazado de dato es peor que + no recomendar. Se recomienda con hechos comprobables y en este orden: + + 1. **El que YA CORRE, si takana lo construye.** Cambiar de servidor web no es cambiar un + binario: es **reescribir la configuración entera**. Ése es el costo real y casi siempre + supera cualquier ventaja teórica del otro. + 2. Si el que corre NO está en el catálogo pero un equivalente sí, se recomienda el equivalente + — porque es el que takana puede construir, firmar y reproducir. Y se dice lo que cuesta. + 3. Si no hay ninguno, se dice que hay que escribir la receta. No se sugiere «usá otro» sin + decir que ese otro tampoco está. + + Devuelve `(lista, recomendado, motivo)` o `(None, None, None)` si el servicio no cae en ninguna + familia conocida — que es lo normal y no es un error.""" + n = nombre.lower().replace("_", "-") + fam = next((f for f, miembros in FAMILIAS.items() if n in miembros), None) + if not fam: + return None, None, None + lista = [] + for cand in FAMILIAS[fam]: + existe, sellado = _receta(cand, repo) + lista.append({"nombre": cand, "receta": existe, "sellado": sellado, "actual": cand == n}) + yo = next((x for x in lista if x["actual"]), None) + disponibles = [x for x in lista if x["sellado"]] + + if yo and yo["sellado"]: + return (lista, n, + f"es el que YA corre y takana lo construye (sellado). Cambiar a otro de la familia " + f"«{fam}» no es cambiar un binario: obliga a REESCRIBIR la configuración entera") + if disponibles: + otros = ", ".join(x["nombre"] for x in disponibles) + return (lista, disponibles[0]["nombre"], + f"el que corre ({n}) no está en el catálogo de takana; de la familia «{fam}» sí " + f"están sellados: {otros}. Cambiar implica reescribir la configuración — es una " + f"decisión, no un trámite") + return (lista, None, + f"de la familia «{fam}» NO hay ninguno en el catálogo de takana: ni {n} ni sus " + f"equivalentes. Hay que escribir la receta del que se elija") + + def origen_binario(e, repo=None): """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino. @@ -260,6 +330,14 @@ def revisar(censo, interactivo=True): if t == "servicio" and e.get("decision") != "muere" and g != "muere": oc, od = origen_binario(e) print(f" └─ binario: [{oc}] {od[:96]}") + lista, rec, motivo = alternativas(n, _REPO) + if lista: + op = " ".join( + ("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"]) + + ("✓" if x["sellado"] else ("·" if x["receta"] else "✗")) + for x in lista) + print(f" └─ alternativas: {op} (✓ sellado · receta sin sellar · ✗ no está)") + print(f" recomendado: {rec or 'ninguno'} — {motivo[:110]}") if not interactivo: return censo @@ -279,6 +357,18 @@ def revisar(censo, interactivo=True): resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n" f" [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower() e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "") + if e["decision"] == "muda" and t == "servicio": + lista, alt_rec, motivo = alternativas(n, _REPO) + if lista and len([x for x in lista if x["sellado"]]) > 1: + op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "") + for i, x in enumerate(lista)) + print(f" alternativas: {op}") + print(f" recomendado: {alt_rec} — {motivo[:100]}") + r2 = input(f" número, o Enter para {alt_rec}: ").strip() + if r2.isdigit() and int(r2) < len(lista): + e["alternativa"] = lista[int(r2)]["nombre"] + else: + e["alternativa"] = alt_rec return censo @@ -350,7 +440,11 @@ def pasos(censo, target, key): for s in svc_muda: nd = s.get("class") == "no-declarado" paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""), - (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n" + ((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n" + f"# Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n" + f"# La del origen no sirve tal cual.\n#\n") + if s.get("alternativa") and s["alternativa"] != s["name"] else "") + + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n" f"# {origen_binario(s)[1]}\n" f"#\n" f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"