tools: auditor de exports contra el cluster

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:37:05 +00:00
co-authored by Claude Opus 4.8
parent 55d43f1565
commit 07c872e847
4 changed files with 350 additions and 0 deletions
+2
View File
@@ -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.**
+63
View File
@@ -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 <ref>`.
+254
View File
@@ -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, '<ausente en el cluster>'))
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', '<ausente en el cluster>'))
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: '<redactado>' 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())
+31
View File
@@ -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.