generar_panel.py

#!/usr/bin/env python3
"""Genera grafana-actualizaciones.yaml (ConfigMap con el panel de Grafana).

El sidecar de Grafana carga cualquier ConfigMap con la etiqueta grafana_dashboard=1.
Uso: python3 ansible/kubernetes/generar_panel.py
"""
import json
import os

DS = {"type": "prometheus", "uid": "${datasource}"}


def q(expr, ref, fmt="table", instant=True, legend=None):
    t = {"datasource": DS, "expr": expr, "refId": ref, "format": fmt, "instant": instant, "range": not instant}
    if legend:
        t["legendFormat"] = legend
    return t


def stat(id_, titulo, expr, x, pasos, unidad="none", descripcion=""):
    return {
        "id": id_, "type": "stat", "title": titulo, "description": descripcion, "datasource": DS,
        "gridPos": {"h": 4, "w": 6, "x": x, "y": 0},
        "targets": [q(expr, "A", fmt="time_series")],
        "options": {"colorMode": "background", "graphMode": "none", "reduceOptions": {"calcs": ["lastNotNull"]}},
        "fieldConfig": {"defaults": {"unit": unidad, "noValue": "sin datos", "thresholds": {
            "mode": "absolute", "steps": pasos}}, "overrides": []},
    }


def version(componente, ref):
    return q(f'max by (nodo, {componente}) (label_replace(nodo_version_info{{componente="{componente}"}}, '
             f'"{componente}", "$1", "version", "(.*)"))', ref)


VERDE, AMARILLO, ROJO = "green", "yellow", "red"
panel = {
    "uid": "actualizaciones-nodos",
    "title": "Actualizaciones de los nodos",
    "tags": ["mantenimiento", "seguridad"],
    "timezone": "browser",
    "refresh": "5m",
    "time": {"from": "now-30d", "to": "now"},
    "schemaVersion": 39,
    "templating": {"list": [{"name": "datasource", "type": "datasource", "query": "prometheus",
                             "label": "Origen", "hide": 0}]},
    "links": [
        {"title": "Portal de nodos", "type": "link", "url": "http://203.0.113.121:3010",
         "icon": "external link", "targetBlank": True, "tooltip": "Semaphore: lanzar actualizaciones"},
        {"title": "Vulnerabilidades (Trivy)", "type": "link", "url": "/d/vulnerabilidades-imagenes",
         "icon": "dashboard"},
    ],
    "panels": [
        stat(1, "De seguridad", 'sum(nodo_actualizaciones_pendientes{tipo="seguridad"})', 0,
             [{"color": VERDE, "value": None}, {"color": ROJO, "value": 1}],
             descripcion="Paquetes con actualización de seguridad pendiente, sumando todos los nodos"),
        stat(2, "Normales", 'sum(nodo_actualizaciones_pendientes{tipo="normal"})', 6,
             [{"color": VERDE, "value": None}, {"color": AMARILLO, "value": 1}]),
        stat(3, "Piden reinicio", "sum(nodo_reinicio_pendiente)", 12,
             [{"color": VERDE, "value": None}, {"color": AMARILLO, "value": 1}],
             descripcion="Nodos con kernel o librerías nuevas que necesitan reiniciar"),
        stat(4, "Revisión más antigua", "time() - min(nodo_actualizaciones_revisado_timestamp_seconds)", 18,
             [{"color": VERDE, "value": None}, {"color": AMARILLO, "value": 8 * 3600}, {"color": ROJO, "value": 24 * 3600}],
             unidad="s", descripcion="Cada nodo se revisa solo cada 6 h; si pasa de 24 h, algo falla"),
        {
            "id": 5, "type": "table", "title": "Nodos", "datasource": DS,
            "gridPos": {"h": 9, "w": 24, "x": 0, "y": 4},
            "targets": [
                q('max by (nodo) (nodo_actualizaciones_pendientes{tipo="seguridad"})', "A"),
                q('max by (nodo) (nodo_actualizaciones_pendientes{tipo="normal"})', "B"),
                q("max by (nodo) (nodo_reinicio_pendiente)", "C"),
                q("max by (nodo) (nodo_actualizaciones_automaticas)", "D"),
                q("time() - max by (nodo) (nodo_actualizaciones_revisado_timestamp_seconds)", "E"),
                version("tailscale", "F"), version("k3s", "G"), version("kernel", "H"), version("sistema", "I"),
            ],
            "transformations": [
                {"id": "merge", "options": {}},
                {"id": "organize", "options": {
                    "excludeByName": {"Time": True, "Value #F": True, "Value #G": True, "Value #H": True, "Value #I": True},
                    "indexByName": {"nodo": 0, "Value #A": 1, "Value #B": 2, "Value #C": 3, "tailscale": 4,
                                    "k3s": 5, "kernel": 6, "sistema": 7, "Value #D": 8, "Value #E": 9},
                    "renameByName": {"nodo": "Nodo", "Value #A": "Seguridad", "Value #B": "Normales",
                                     "Value #C": "Reinicio", "Value #D": "Automáticas", "Value #E": "Revisado hace",
                                     "tailscale": "Tailscale", "k3s": "k3s", "kernel": "Kernel", "sistema": "Sistema"}}},
                {"id": "sortBy", "options": {"sort": [{"field": "Nodo"}]}},
            ],
            "fieldConfig": {"defaults": {"custom": {"align": "auto"}}, "overrides": [
                {"matcher": {"id": "byName", "options": "Seguridad"}, "properties": [
                    {"id": "custom.cellOptions", "value": {"type": "color-background"}},
                    {"id": "thresholds", "value": {"mode": "absolute", "steps": [
                        {"color": VERDE, "value": None}, {"color": ROJO, "value": 1}]}}]},
                {"matcher": {"id": "byName", "options": "Normales"}, "properties": [
                    {"id": "custom.cellOptions", "value": {"type": "color-background"}},
                    {"id": "thresholds", "value": {"mode": "absolute", "steps": [
                        {"color": VERDE, "value": None}, {"color": AMARILLO, "value": 1}]}}]},
                {"matcher": {"id": "byName", "options": "Reinicio"}, "properties": [
                    {"id": "mappings", "value": [{"type": "value", "options": {
                        "0": {"text": "no", "color": VERDE}, "1": {"text": "SÍ", "color": AMARILLO}}}]},
                    {"id": "custom.cellOptions", "value": {"type": "color-text"}}]},
                {"matcher": {"id": "byName", "options": "Automáticas"}, "properties": [
                    {"id": "mappings", "value": [{"type": "value", "options": {
                        "0": {"text": "no", "color": AMARILLO}, "1": {"text": "sí", "color": VERDE}}}]},
                    {"id": "custom.cellOptions", "value": {"type": "color-text"}}]},
                {"matcher": {"id": "byName", "options": "Revisado hace"}, "properties": [
                    {"id": "unit", "value": "s"}, {"id": "decimals", "value": 0}]},
            ]},
            "options": {"showHeader": True, "cellHeight": "sm"},
        },
        {
            "id": 6, "type": "table", "title": "Paquetes pendientes", "datasource": DS,
            "gridPos": {"h": 10, "w": 14, "x": 0, "y": 13},
            "targets": [q("max by (nodo, paquete, version, seguridad) (nodo_actualizacion_paquete)", "A")],
            "transformations": [
                {"id": "organize", "options": {
                    "excludeByName": {"Time": True, "Value": True},
                    "indexByName": {"nodo": 0, "paquete": 1, "version": 2, "seguridad": 3},
                    "renameByName": {"nodo": "Nodo", "paquete": "Paquete", "version": "Versión nueva",
                                     "seguridad": "Seguridad"}}},
                {"id": "sortBy", "options": {"sort": [{"field": "Seguridad", "desc": True}]}},
            ],
            "fieldConfig": {"defaults": {}, "overrides": [
                {"matcher": {"id": "byName", "options": "Seguridad"}, "properties": [
                    {"id": "mappings", "value": [{"type": "value", "options": {
                        "0": {"text": "no", "color": "text"}, "1": {"text": "SÍ", "color": ROJO}}}]},
                    {"id": "custom.cellOptions", "value": {"type": "color-text"}}]}]},
            "options": {"showHeader": True, "cellHeight": "sm"},
        },
        {
            "id": 7, "type": "text", "title": "Cómo lanzar las actualizaciones",
            "gridPos": {"h": 10, "w": 10, "x": 14, "y": 13},
            "options": {"mode": "markdown", "content": (
                "**[Portal de nodos](http://203.0.113.121:3010)** (Semaphore, solo por Tailscale, con 2FA) → "
                "*Nodos del clúster* → *Task Templates* → ▶ y elegir los **nodos** "
                "(`all`, `workers`, `masters` o uno solo):\n\n"
                "| Botón | Qué hace |\n|---|---|\n"
                "| 1 · Revisar ahora | Refresca este panel. No instala nada |\n"
                "| 2 · Simular actualización | Qué se instalaría. No instala ni reinicia |\n"
                "| 3 · Actualizar sistema | Instala, nodo a nodo; reinicia con drain si hace falta |\n"
                "| 4 · Actualizar Tailscale | Solo Tailscale |\n"
                "| 5 · Reiniciar los que lo piden | Con drain, de uno en uno |\n"
                "| 6 · Actualizar k3s | Pide la versión (`v1.X.Y+k3sN`): masters primero, luego workers |\n\n"
                "El master-1 no se reinicia solo. Desde la terminal del master-1: "
                "`./scripts/mantenimiento.sh <revisar|actualizar|tailscale|reiniciar|k3s>`.")},
        },
        {
            "id": 8, "type": "timeseries", "title": "Pendientes por nodo", "datasource": DS,
            "gridPos": {"h": 8, "w": 24, "x": 0, "y": 23},
            "targets": [q("sum by (nodo) (nodo_actualizaciones_pendientes)", "A", fmt="time_series",
                          instant=False, legend="{{nodo}}")],
            "fieldConfig": {"defaults": {"custom": {"drawStyle": "line", "lineInterpolation": "stepAfter",
                                                    "fillOpacity": 10}, "decimals": 0}, "overrides": []},
            "options": {"legend": {"displayMode": "list", "placement": "bottom"}},
        },
    ],
}

aqui = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(aqui, "grafana-actualizaciones.yaml"), "w") as f:
    f.write("# Generado por generar_panel.py: no editar a mano.\n")
    f.write("apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: panel-actualizaciones-nodos\n"
            "  namespace: monitoring\n  labels:\n    grafana_dashboard: \"1\"\ndata:\n")
    f.write("  actualizaciones-nodos.json: |-\n")
    for linea in json.dumps(panel, indent=2, ensure_ascii=False).splitlines():
        f.write(f"    {linea}\n")
print("✓ grafana-actualizaciones.yaml")