planear: alternativas por familia funcional, con una recomendación que no es gusto

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-11 17:07:05 +00:00
co-authored by Claude Opus 5
parent 6f5cc2629a
commit 8ad9f0490d
2 changed files with 133 additions and 1 deletions
+38
View File
@@ -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 `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. 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`)* ### 4.1 El aplicador *(implementado: `scripts/mudanza/aplicar.py`)*
`aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]` `aplicar.py --plan plan.toml [--dry-run] [--only <clase>] [--paso N] [--hecho N]`
+95 -1
View File
@@ -28,6 +28,8 @@ Uso:
""" """
import argparse, os, sys, shlex, time, tomllib import argparse, os, sys, shlex, time, tomllib
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ── tamaños ───────────────────────────────────────────────────────────────────────────────────── # ── tamaños ─────────────────────────────────────────────────────────────────────────────────────
_MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024} _MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}
@@ -103,6 +105,74 @@ def _no_aplica(nombre):
return None 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): 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. """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": if t == "servicio" and e.get("decision") != "muere" and g != "muere":
oc, od = origen_binario(e) oc, od = origen_binario(e)
print(f" └─ binario: [{oc}] {od[:96]}") 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: if not interactivo:
return censo return censo
@@ -279,6 +357,18 @@ def revisar(censo, interactivo=True):
resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n" resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
f" [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower() f" [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "") 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 return censo
@@ -350,7 +440,11 @@ def pasos(censo, target, key):
for s in svc_muda: for s in svc_muda:
nd = s.get("class") == "no-declarado" nd = s.get("class") == "no-declarado"
paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""), 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"# {origen_binario(s)[1]}\n"
f"#\n" f"#\n"
f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n" f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"