diff --git a/tools/README.md b/tools/README.md index f092e5c..7aa3dd4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -50,6 +50,47 @@ Un informe que siempre saca ruido deja de leerse, así que las excepciones conocidas se declaran ahí **con su motivo**. El script avisa cuando una excepción ya no se dispara: significa que aquello se arregló y la entrada sobra. +## Temporizador semanal + +`audita-semanal.py` pasa la auditoría y decide si molestar. Instalado como +servicio de host en chemavx-k8 (**fuera del cluster**, como seo-watch y el +canario de hermes): + +```bash +sudo cp tools/audita-exports.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload && sudo systemctl enable --now audita-exports.timer +systemctl list-timers audita-exports.timer # sábados 07:00 UTC +sudo systemctl start audita-exports.service # forzar una pasada +``` + +**Callado por diseño.** Sólo escribe a Telegram si hay hallazgos nuevos *y* +cambian respecto a la semana anterior: si la deriva sigue igual porque aún no se +ha arreglado, no repite. Un aviso que llega todas las semanas deja de leerse. + +Qué sí rompe el silencio: + +| Situación | Aviso | +|---|---| +| Deriva nueva | 🟠 con el informe | +| El repo local por detrás de `origin` | 🟠/🟡 — se comparó contra ficheros viejos | +| Excepciones que ya no se disparan | 🟡 — sobran, bórralas | +| El propio auditor ha fallado | 🔴 — un vigilante roto no es un "todo bien" | + +Tener cambios **sin commitear** no avisa: es estado de trabajo normal y la copia +local es justamente la intención que hay que comparar. Sale como contexto entre +paréntesis cuando ya se avisa por otra cosa. + +Probado viéndolo hacer las cuatro cosas, no sólo la buena: con el repo limpio se +calla; con una deriva inyectada manda el aviso; repetida la misma deriva no +insiste; y con el auditor roto a propósito (error de sintaxis y muerte temprana) +manda el 🔴. Ojo con ese último caso: un error de sintaxis en Python sale con +código 1, **el mismo que "hay deriva"**, así que no basta mirar el código de +salida — se exige además que el informe traiga su línea de resumen. + +El token de Telegram sale del secret `grafana-telegram-infisical` (ns +`monitoring`), el mismo que usan seo-watch y hermes-bot. Al venir de Infisical, +una rotación llega sola a los tres y no hay copia en disco. + ### Lo que NO hace No compara valores de `Secret` —sólo nombres de clave—, se salta lo que ignora diff --git a/tools/audita-exports.service b/tools/audita-exports.service new file mode 100644 index 0000000..ce24f2d --- /dev/null +++ b/tools/audita-exports.service @@ -0,0 +1,16 @@ +# /etc/systemd/system/audita-exports.service +[Unit] +Description=Auditoría de manifiestos — ¿siguen los ficheros describiendo el cluster? +Documentation=file:///home/chemavx/k8s-manifests/tools/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=chemavx +WorkingDirectory=/home/chemavx/k8s-manifests +Environment=HOME=/home/chemavx +Environment=KUBECONFIG=/home/chemavx/.kube/config +Environment=PATH=/home/chemavx/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/python3 /home/chemavx/k8s-manifests/tools/audita-semanal.py +TimeoutStartSec=900 diff --git a/tools/audita-exports.timer b/tools/audita-exports.timer new file mode 100644 index 0000000..7764070 --- /dev/null +++ b/tools/audita-exports.timer @@ -0,0 +1,14 @@ +# /etc/systemd/system/audita-exports.timer +[Unit] +Description=Auditoría semanal de manifiestos contra el cluster + +[Timer] +# Sábado 07:00 UTC. Separado a propósito de los otros dos vigilantes +# (hermes-fallback-check domingo 06:00, seo-watch lunes 05:30): si algo falla, +# se sabe cuál sin desenredar tres avisos de la misma mañana. +OnCalendar=Sat *-*-* 07:00:00 UTC +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/tools/audita-semanal.py b/tools/audita-semanal.py new file mode 100755 index 0000000..c333061 --- /dev/null +++ b/tools/audita-semanal.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Pasa la auditoría de exports una vez por semana y avisa por Telegram. + +Callado por diseño: sólo escribe si hay hallazgos NUEVOS y además cambian +respecto a la semana anterior. Si la deriva sigue igual porque aún no se ha +arreglado, no repite el mensaje — un aviso que llega todas las semanas deja de +leerse, y entonces no sirve para nada. + +Avisa también de dos cosas que invalidarían la propia auditoría: + - el repo local por detrás de origin -> compararía ficheros viejos + - excepciones que ya no se disparan -> justificaciones muertas acumulándose + +Correlato de tools/audita-exports.py, que es quien hace el trabajo. Aquí sólo +está la decisión de molestar o no. +""" +import hashlib +import json +import os +import subprocess +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +RAIZ = Path(__file__).resolve().parent.parent +ESTADO = Path(os.path.expanduser('~/.local/state/audita-exports/last.json')) +# Mismo secret que usan seo-watch y hermes-bot. Al vivir en Infisical, una +# rotación llega sola a los tres; no hay copia del token en disco. +TG_SECRET = ('monitoring', 'grafana-telegram-infisical') + + +def sh(cmd, cwd=None): + return subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd) + + +def telegram(texto): + def leer(clave): + r = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} " + f"-o jsonpath='{{.data.{clave}}}'") + if r.returncode != 0 or not r.stdout: + raise RuntimeError(f'no pude leer {clave} del secret: {r.stderr.strip()[:120]}') + import base64 + return base64.b64decode(r.stdout).decode() + + datos = urllib.parse.urlencode({'chat_id': leer('TELEGRAM_CHAT_ID'), + 'text': texto[:3900]}).encode() + pet = urllib.request.Request( + f"https://api.telegram.org/bot{leer('TELEGRAM_BOT_TOKEN')}/sendMessage", data=datos) + with urllib.request.urlopen(pet, timeout=30) as resp: + return json.loads(resp.read()).get('ok', False) + + +def estado_del_repo(): + """Devuelve (motivos_para_avisar, contexto). + + Ir por detrás de origin SÍ invalida la auditoría: se estarían comparando + ficheros viejos contra el cluster. Tener cambios sin commitear NO — es + estado de trabajo normal, la copia local es la intención actual y es + exactamente lo que hay que comparar. Avisar de eso convertiría el vigilante + en ruido semanal, que es como muere un vigilante. + """ + motivos, contexto = [], [] + sh('git fetch --quiet', cwd=RAIZ) + detras = sh('git rev-list --count HEAD..@{u}', cwd=RAIZ).stdout.strip() + if detras.isdigit() and int(detras) > 0: + motivos.append(f'el repo local va {detras} commit(s) por detrás de origin: ' + 'la comparación se ha hecho con ficheros viejos') + if sh('git status --porcelain', cwd=RAIZ).stdout.strip(): + contexto.append('(el repo tiene cambios sin commitear)') + return motivos, contexto + + +def main(): + r = sh(f'{sys.executable} {RAIZ}/tools/audita-exports.py', cwd=RAIZ) + salida = (r.stdout or '') + (r.stderr or '') + + # El auditor se rompió: eso también es noticia, y no debe pasar por "sin deriva". + # No basta el código de salida: un error de sintaxis en Python también sale con + # 1, que es justo el código de "hay deriva". Se exige además que el informe + # tenga su línea de resumen; si no está, es que no llegó a terminar. + if r.returncode not in (0, 1) or 'hallazgos NUEVOS:' not in salida: + telegram(f'🔴 Auditoría de manifiestos: el propio auditor ha fallado ' + f'(código {r.returncode}).\n\n{salida[-1500:]}') + return 2 + + hay_deriva = r.returncode == 1 + sobrantes = 'excepciones que ya no hacen falta' in salida + motivos_repo, contexto_repo = estado_del_repo() + + if not (hay_deriva or sobrantes or motivos_repo): + print('sin novedad: nada que avisar') + ESTADO.parent.mkdir(parents=True, exist_ok=True) + ESTADO.write_text(json.dumps({'huella': None})) + return 0 + + cuerpo = salida.strip() + huella = hashlib.sha256((cuerpo + '|'.join(motivos_repo)).encode()).hexdigest() + try: + previo = json.loads(ESTADO.read_text()).get('huella') + except (FileNotFoundError, ValueError): + previo = None + + if huella == previo: + print('mismos hallazgos que la semana pasada: no repito el aviso') + return 0 + + cabecera = '🟠 Auditoría de manifiestos: los ficheros y el cluster no coinciden.' + if not hay_deriva: + cabecera = '🟡 Auditoría de manifiestos: sin deriva, pero hay algo que mirar.' + extra = ''.join(f'\n⚠️ {m}' for m in motivos_repo) + ''.join(f'\n{c}' for c in contexto_repo) + recordatorio = ('\n\nRecuerda: en estos directorios un commit NO despliega. ' + 'Hay que editar el fichero Y hacer kubectl apply.') + telegram(f'{cabecera}{extra}\n\n{cuerpo}{recordatorio}') + + ESTADO.parent.mkdir(parents=True, exist_ok=True) + ESTADO.write_text(json.dumps({'huella': huella})) + print('aviso enviado') + return 0 + + +if __name__ == '__main__': + sys.exit(main())