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:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Manifests de Kubernetes gestionados por ArgoCD para el cluster chemavx.
|
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
|
## Regla crítica: secrets en git
|
||||||
|
|
||||||
**Los secrets con datos sensibles NO se guardan en este repo.**
|
**Los secrets con datos sensibles NO se guardan en este repo.**
|
||||||
|
|||||||
@@ -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>`.
|
||||||
Executable
+254
@@ -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())
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user