From 07c872e8477075b36bd7534c9ecee2ec9f44891f Mon Sep 17 00:00:00 2001 From: chemavx Date: Wed, 22 Jul 2026 09:37:05 +0000 Subject: [PATCH] tools: auditor de exports contra el cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deja en el repo lo que hoy destapó que argocd-redis, el daemon.json de gitea y siete imágenes llevaban meses diciendo una cosa mientras el cluster hacía otra. Compara cada manifiesto con el objeto vivo, en las dos direcciones (el fichero afirma algo falso / hay algo desplegado que el fichero no menciona / el objeto no existe). Sólo mira los directorios fuera de ArgoCD, que es donde no hay nadie comprobando: en los demás ArgoCD ya marca OutOfSync cada 3 minutos. La lista de directorios se deduce preguntando a ArgoCD por los paths de sus Applications, así que si mañana un namespace entra en GitOps el script se ajusta solo. Las excepciones conocidas van en excepciones-auditoria.yaml CON su motivo, para que lo normal sea "hallazgos NUEVOS: 0" y cualquier cosa que asome merezca una mirada. Un informe que siempre saca ruido deja de leerse — que es justo el fallo que se ha estado corrigiendo estos días. El script también avisa cuando una excepción ya no se dispara: se arregló y la entrada sobra. Probado viéndolo fallar, no sólo pasar: inyectadas a propósito una avería de cada tipo (imagen cambiada, env borrada del fichero, objeto inexistente) y las caza las tres saliendo con código 1. Al probarlo apareció un fallo propio: con -d marcaba como sobrantes las excepciones de los directorios no barridos; ahora sólo lo afirma tras un barrido completo. No toca nada (sólo lectura), nunca compara valores de Secret —sólo nombres de clave— y se salta lo que ignora .gitignore. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 + tools/README.md | 63 ++++++++ tools/audita-exports.py | 254 +++++++++++++++++++++++++++++++ tools/excepciones-auditoria.yaml | 31 ++++ 4 files changed, 350 insertions(+) create mode 100644 tools/README.md create mode 100755 tools/audita-exports.py create mode 100644 tools/excepciones-auditoria.yaml diff --git a/README.md b/README.md index 01d93a0..d5212c3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Manifests de Kubernetes gestionados por ArgoCD para el cluster chemavx. +**Ojo: no todo lo de aquí lo aplica ArgoCD.** Unos 11 directorios (`monitoring`, `argocd`, `authentik`, `gitea`, `homarr`, `vaultwarden`, `cloudflare-ddns`, `cluster-wide`…) se gestionan a mano: ahí un commit **no despliega nada**, hay que editar el fichero *y* hacer `kubectl apply`. Para comprobar que esos ficheros siguen describiendo lo desplegado: `python3 tools/audita-exports.py` (ver [tools/README.md](tools/README.md)). + ## Regla crítica: secrets en git **Los secrets con datos sensibles NO se guardan en este repo.** diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..f092e5c --- /dev/null +++ b/tools/README.md @@ -0,0 +1,63 @@ +# tools/ + +## `audita-exports.py` — ¿miente este repo? + +Compara cada manifiesto con el objeto que hay desplegado de verdad. + +```bash +python3 tools/audita-exports.py # informe (sale 1 si hay algo nuevo) +python3 tools/audita-exports.py -v # incluye las excepciones y su motivo +python3 tools/audita-exports.py -d gitea # sólo esos directorios +``` + +### Por qué existe + +**Sólo mira los directorios que no gestiona ArgoCD.** En los que sí, ArgoCD ya +compara git contra el cluster cada 3 minutos y lo marca OutOfSync; ahí la deriva +no puede esconderse. En los demás —`monitoring`, `argocd`, `authentik`, `gitea`, +`homarr`, `vaultwarden`, `cloudflare-ddns`, `cluster-wide`…— **nadie comprueba +nada**: git es documentación, y una documentación desfasada es peor que no +tenerla, porque se aplica creyendo que restaura y en realidad rompe. + +La primera pasada (2026-07-22) encontró, sobre 175 objetos: + +- `argocd/deployment-argocd-redis.yaml` no llevaba el anclaje al control-plane ni + los límites de recursos. Aplicarlo deshacía el arreglo del Exit Code 255. +- A `gitea/configmap-daemon-json.yaml` le faltaban dos `insecure-registries` que + sí estaban vivos: aplicarlo dejaba a la CI sin poder subir imágenes. +- El ConfigMap `buildkitd-config` llevaba 90 días desplegado sin manifiesto. +- Siete imágenes con tag flotante: git decía `gitea:1.25.5` y el cluster corría + **1.27.0**, subido solo en algún reinicio. + +Recordatorio que explica cómo se llega a eso: **en estos directorios un commit no +despliega nada.** Hay que editar el fichero *y* `kubectl apply`. + +### Cómo leer la salida + +| Tipo | Significa | +|---|---| +| `DIFIERE` | el fichero afirma algo que ya no es cierto | +| `SOLO VIVO` | hay algo desplegado que el fichero no menciona | +| `NO DESPLEGADO` | el fichero describe un objeto que no existe | + +Lo normal es terminar en `hallazgos NUEVOS: 0`. Si sale algo, o el cluster se +tocó a mano sin actualizar el fichero (regenéralo), o el fichero se cambió sin +aplicarlo (aplícalo), o es una excepción legítima (justifícala abajo). + +### `excepciones-auditoria.yaml` + +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. + +### Lo que NO hace + +No compara valores de `Secret` —sólo nombres de clave—, se salta lo que ignora +`.gitignore` (los `kube-root-ca.crt` y ServiceAccounts `default` autogenerados) y +no toca nada: es de sólo lectura. + +Un aviso por experiencia propia: el `imageID` de un pod **no siempre es el digest +del registro**. Si viene como `repo@sha256:…` lo es; si es un `sha256:…` pelado +es el id local de containerd y **no se puede descargar** — fijar una imagen a ese +valor da `ImagePullBackOff`. Para comparar imágenes, `repoDigest` contra +`repoDigest`, con `sudo k3s crictl inspecti `. diff --git a/tools/audita-exports.py b/tools/audita-exports.py new file mode 100755 index 0000000..962b6a1 --- /dev/null +++ b/tools/audita-exports.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Compara los manifiestos de este repo con lo que hay desplegado de verdad. + +Sólo mira los directorios que NO gestiona ArgoCD, que es donde nadie comprueba +nada: allí git es documentación, y una documentación desfasada es peor que no +tenerla — se aplica creyendo que restaura y en realidad rompe. Los directorios +bajo ArgoCD se saltan porque el propio ArgoCD ya los compara cada 3 minutos. + +Mira en las dos direcciones, porque fallan distinto: + + DIFIERE el fichero afirma algo que ya no es cierto + SOLO VIVO hay algo desplegado que el fichero no menciona + NO DESPLEGADO el fichero describe un objeto que no existe + +Las excepciones conocidas van en excepciones-auditoria.yaml, con su motivo, para +que la salida normal sea "0 hallazgos nuevos" y cualquier cosa que aparezca +merezca una mirada. Si una excepción deja de darse, también lo dice: sobra. + +Uso: + python3 tools/audita-exports.py # informe + python3 tools/audita-exports.py -v # incluye las excepciones + python3 tools/audita-exports.py -d gitea # sólo esos directorios + +Sale con 1 si hay hallazgos nuevos, para poder colgarlo de un temporizador. +Nunca imprime valores de Secret: de esos compara sólo los nombres de clave. +""" +import argparse +import fnmatch +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +RAIZ = Path(__file__).resolve().parent.parent + +# Ruido: lo pone el API server o un controlador, no es configuración deseada. +META_FUERA = {'creationTimestamp', 'resourceVersion', 'uid', 'generation', + 'managedFields', 'selfLink', 'ownerReferences', 'finalizers'} +ANOT_FUERA = {'kubectl.kubernetes.io/last-applied-configuration', + 'deployment.kubernetes.io/revision', + 'kubectl.kubernetes.io/restartedAt', + 'deprecated.daemonset.template.generation', + 'endpoints.kubernetes.io/last-change-trigger-time', + 'control-plane.alpha.kubernetes.io/leader'} +# Campos que el API server rellena solo y que nadie escribe a mano. +DEFECTO = {'clusterIP', 'clusterIPs', 'ipFamilies', 'ipFamilyPolicy', + 'internalTrafficPolicy', 'sessionAffinity', 'nodePort', + 'terminationMessagePath', 'terminationMessagePolicy', 'dnsPolicy', + 'schedulerName', 'restartPolicy', 'defaultMode', 'revisionHistoryLimit', + 'progressDeadlineSeconds', 'protocol', 'targetPort'} + + +def limpia(o, en_meta=False): + if isinstance(o, dict): + r = {} + for k, v in o.items(): + if (k == 'status' and not en_meta) or k in META_FUERA or k in DEFECTO: + continue + if k == 'annotations' and isinstance(v, dict): + v = {a: b for a, b in v.items() if a not in ANOT_FUERA} + if not v: + continue + r[k] = limpia(v, en_meta or k == 'metadata') + return r + if isinstance(o, list): + return [limpia(x, en_meta) for x in o] + return o + + +def compara(fich, vivo, ruta=''): + """Todo lo que el fichero afirma, ¿sigue siendo verdad en el cluster?""" + out = [] + if isinstance(fich, dict): + if not isinstance(vivo, dict): + return [(ruta, fich, vivo)] + for k, v in fich.items(): + if k not in vivo: + out.append((f'{ruta}.{k}', v, '')) + else: + out += compara(v, vivo[k], f'{ruta}.{k}') + elif isinstance(fich, list): + if not isinstance(vivo, list): + return [(ruta, fich, vivo)] + # listas de dicts con nombre (containers, env, volumes...): emparejar por + # nombre, no por posición, o reordenar el fichero saldría como deriva + if fich and all(isinstance(x, dict) and 'name' in x for x in fich): + porn = {x['name']: x for x in vivo if isinstance(x, dict) and 'name' in x} + for x in fich: + if x['name'] not in porn: + out.append((f"{ruta}[{x['name']}]", 'presente', '')) + else: + out += compara(x, porn[x['name']], f"{ruta}[{x['name']}]") + elif fich != vivo: + out.append((ruta, fich, vivo)) + elif fich != vivo: + out.append((ruta, fich, vivo)) + return out + + +def perfil(o): + """Lo esencial de una carga de trabajo, para ver qué hay vivo y sin escribir.""" + spec = (o.get('spec') or {}).get('template', {}).get('spec') + if not spec: + return None + p = {'volumenes': sorted(v.get('name', '?') for v in spec.get('volumes') or [])} + for c in (spec.get('containers') or []) + (spec.get('initContainers') or []): + p[f"contenedor:{c['name']}"] = { + 'env': sorted(e.get('name', '?') for e in c.get('env') or []), + 'montajes': sorted(m.get('name', '?') for m in c.get('volumeMounts') or []), + } + return p + + +def dirs_de_argocd(): + r = subprocess.run(['kubectl', 'get', 'app', '-n', 'argocd', '-o', + 'jsonpath={range .items[*]}{.spec.source.path}{"\\n"}{end}'], + capture_output=True, text=True) + return {p.strip().strip('/') for p in r.stdout.split('\n') if p.strip() not in ('', '.')} + + +def ignorados_por_git(): + r = subprocess.run(['git', 'ls-files', '--others', '--ignored', '--exclude-standard'], + cwd=RAIZ, capture_output=True, text=True) + return {l.strip() for l in r.stdout.split('\n') if l.strip()} + + +def vivo_de(kind, nombre, ns): + orden = ['kubectl', 'get', kind, nombre, '-o', 'json'] + (['-n', ns] if ns else []) + r = subprocess.run(orden, capture_output=True, text=True) + return json.loads(r.stdout) if r.returncode == 0 else None + + +def carga_excepciones(): + f = RAIZ / 'tools' / 'excepciones-auditoria.yaml' + if not f.exists(): + return [] + return (yaml.safe_load(open(f)) or {}).get('excepciones') or [] + + +def es_esperado(exc, fichero, tipo, que): + for e in exc: + if not fnmatch.fnmatch(fichero, e['fichero']): + continue + if e.get('tipo') and e['tipo'] != tipo: + continue + if e.get('contiene') and e['contiene'] not in que: + continue + return e + return None + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('-d', '--dirs', nargs='*', help='limitar a estos directorios') + ap.add_argument('-v', '--verbose', action='store_true', help='mostrar también las excepciones') + a = ap.parse_args() + + bajo_argocd = dirs_de_argocd() + if a.dirs: + objetivo = [d for d in a.dirs] + else: + objetivo = sorted(p.name for p in RAIZ.iterdir() + if p.is_dir() and not p.name.startswith('.') + and p.name not in bajo_argocd and p.name != 'tools') + ignorados = ignorados_por_git() + exc = carga_excepciones() + + nuevos, esperados, revisados = [], [], 0 + for d in objetivo: + for f in sorted((RAIZ / d).glob('*.yaml')): + rel = str(f.relative_to(RAIZ)) + if rel in ignorados: + continue + try: + docs = [x for x in yaml.safe_load_all(open(f)) if x] + except Exception as e: + nuevos.append((rel, 'ILEGIBLE', str(e), '')) + continue + planos = [] + for doc in docs: + planos += doc.get('items', []) if doc.get('kind') == 'List' else [doc] + for doc in planos: + kind, meta = doc.get('kind'), doc.get('metadata') or {} + nombre = meta.get('name') + if not kind or not nombre: + continue + ns = meta.get('namespace') or (None if kind in ('Namespace',) else d) + revisados += 1 + vivo = vivo_de(kind, nombre, ns) + hall = [] + if vivo is None: + hall.append(('NO DESPLEGADO', f'{kind}/{nombre}', '')) + else: + if kind == 'Secret': # jamás comparar valores + for o in (doc, vivo): + for campo in ('data', 'stringData'): + if campo in o: + o[campo] = {k: '' for k in o[campo]} + A, B = limpia(doc), limpia(vivo) + for ruta, vf, vv in compara(A, B): + hall.append(('DIFIERE', f'{kind}/{nombre}{ruta}', f'git={vf!r} vivo={vv!r}')) + pf, pv = perfil(A), perfil(B) + if pf and pv: + for clave, val in pv.items(): + if clave not in pf: + hall.append(('SOLO VIVO', f'{kind}/{nombre} {clave}', '')) + elif isinstance(val, dict): + for sub in ('env', 'montajes'): + extra = set(val[sub]) - set(pf[clave].get(sub) or []) + if extra: + hall.append(('SOLO VIVO', f'{kind}/{nombre} {clave}.{sub}', + ', '.join(sorted(extra)))) + else: + extra = set(val) - set(pf.get(clave) or []) + if extra: + hall.append(('SOLO VIVO', f'{kind}/{nombre} {clave}', + ', '.join(sorted(extra)))) + for tipo, que, det in hall: + e = es_esperado(exc, rel, tipo, que) + (esperados if e else nuevos).append((rel, tipo, que, det, e)) + + print(f'objetos revisados: {revisados} directorios: {len(objetivo)} ' + f'(fuera de ArgoCD) excepciones conocidas: {len(exc)}\n') + for h in nuevos: + rel, tipo, que, det = h[0], h[1], h[2], h[3] + print(f'{tipo:<14} {rel}\n{"":<14} {que}') + if det: + print(f'{"":<14} {det[:220]}') + if a.verbose and esperados: + print('\n--- esperados (justificados en tools/excepciones-auditoria.yaml) ---') + for rel, tipo, que, det, e in esperados: + print(f'{tipo:<14} {rel} [{e["motivo"]}]') + + # Una excepción que ya no se dispara es una excepción que sobra — pero eso + # sólo se puede afirmar tras un barrido completo: con -d quedan fuera + # directorios enteros y todas sus excepciones parecerían muertas. + usadas = {id(e) for *_, e in esperados} + muertas = [] if a.dirs else [e for e in exc if id(e) not in usadas] + + print(f'\nhallazgos NUEVOS: {len(nuevos)} esperados: {len(esperados)}') + if muertas: + print(f'excepciones que ya no hacen falta ({len(muertas)}), bórralas:') + for e in muertas: + print(f' - {e["fichero"]} [{e["motivo"]}]') + if not nuevos: + print('sin deriva: los manifiestos describen lo desplegado') + return 1 if nuevos else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/excepciones-auditoria.yaml b/tools/excepciones-auditoria.yaml new file mode 100644 index 0000000..1434791 --- /dev/null +++ b/tools/excepciones-auditoria.yaml @@ -0,0 +1,31 @@ +# Hallazgos que la auditoría debe dar por buenos, cada uno con su motivo. +# +# La idea es que `audita-exports.py` termine normalmente en "hallazgos NUEVOS: 0". +# Un informe que siempre saca ruido deja de leerse, y entonces no sirve de nada. +# Por eso todo lo que se calla se justifica aquí, y el script avisa cuando una +# excepción ya no se dispara: eso significa que se arregló y sobra la entrada. +# +# Campos: fichero (patrón glob, obligatorio), tipo y contiene (opcionales, para +# afinar), motivo (obligatorio, se muestra con -v). + +excepciones: + - fichero: reddit-intel/* + tipo: NO DESPLEGADO + motivo: >- + Inactivo a propósito. reddit-intel corre en n97 con Docker + (/opt/reddit-intel); estos manifiestos existen sólo en git a la espera de + promoción. Ver reddit-intel/PROMOTION.md. + + - fichero: argocd-patches/* + motivo: >- + No son manifiestos completos sino parches parciales que se vuelven a + aplicar sobre los manifiestos oficiales de ArgoCD tras cada actualización. + Les faltan campos a propósito, así que comparar con el cluster no procede. + El estado real está en argocd/deployment-argocd-redis.yaml. + + - fichero: argocd/application-infisical-operator-rbac.yaml + tipo: DIFIERE + contiene: ignoreDifferences + motivo: >- + `group: ""` en un ignoreDifferences es el grupo core; el API server lo + omite por ser cadena vacía. Equivalente a estar ausente, no es deriva.