From 8ad9f0490d401b24c5fff752b5ed7ee0cdece9e5 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 17:07:05 +0000 Subject: [PATCH] =?UTF-8?q?planear:=20alternativas=20por=20familia=20funci?= =?UTF-8?q?onal,=20con=20una=20recomendaci=C3=B3n=20que=20no=20es=20gusto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un servicio casi nunca es insustituible. `planear.py` agrupa por familia (servidor web, base de datos, caché, forja git, contenedores, dns, correo, base vectorial), muestra qué equivalentes tiene takana —marcando sellado / receta sin sellar / no está— y recomienda. Pero no «el mejor» en abstracto: un gusto disfrazado de dato es peor que no recomendar. Los criterios son hechos comprobables, en orden: 1. **El que ya corre, si takana lo construye** — porque cambiar de servidor web no es cambiar un binario: es REESCRIBIR la configuración entera, y 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 (takana puede construirlo, firmarlo y reproducirlo) diciendo lo que cuesta. 3. Si no hay ninguno, se dice. **No se sugiere «usá otro» cuando ese otro tampoco está.** Probado contra el catálogo real: `caddy` → se queda (ya corre y está sellado); `nginx` → recomienda caddy nombrando el costo; `redis` → «recomendado: NINGUNO», porque ni redis ni valkey ni memcached están en el corpus. ⚠ Y el dato que la recomendación destapa, que vale más que la recomendación: de servidores web el corpus sólo tiene **caddy y traefik**. No hay nginx ni apache. 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 38 +++++++++++++++ scripts/mudanza/planear.py | 96 +++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) 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"