ops(ucepsa): add controlled Grafana dashboard cleanup

This commit is contained in:
Victor Fraile Garcia 2026-07-20 12:09:39 +02:00
parent dbc45bccd4
commit 2afc19105b
2 changed files with 398 additions and 0 deletions

View File

@ -0,0 +1,80 @@
# UCEPSA — Limpieza controlada de dashboards Grafana v0.3.6.1
## Objetivo
Conservar exclusivamente:
```text
ucepsa-shopfloor-context-health
ucepsa-edge-oee-machine
```
El script no decide por título. Usa UID exacto para distinguir los dos
dashboards que actualmente comparten el título `MESAVAULT Edge-OEE — Máquina`.
## Seguridad
- `plan` es el modo predeterminado y no modifica Grafana.
- `apply` comprueba que los dos UIDs canónicos existen.
- Antes de borrar, exporta todos los dashboards y sus metadatos.
- La copia incluye manifiesto y SHA-256.
- La operación requiere una confirmación explícita.
- No borra carpetas automáticamente.
- No toca datasources, usuarios, alertas ni configuración de Grafana.
## Autenticación
```bash
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
```
## Plan
```bash
SCRIPT=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
"$SCRIPT" plan
```
Revisar que solo aparezcan como `KEEP`:
```text
ucepsa-shopfloor-context-health
ucepsa-edge-oee-machine
```
## Aplicación
```bash
export GRAFANA_CLEANUP_CONFIRM='DELETE_NON_CANONICAL_DASHBOARDS'
"$SCRIPT" apply
unset GRAFANA_CLEANUP_CONFIRM
```
Las copias quedan bajo:
```text
/srv/mesavault/backups/ucepsa-grafana-cleanup/<timestamp>/
```
## Verificación
```bash
"$SCRIPT" verify
unset GRAFANA_API_TOKEN
```
## Carpetas vacías
El script devuelve `empty_folders_not_deleted`. Borrarlas después desde la
interfaz, únicamente cuando estén vacías:
```text
Dashboards → seleccionar carpeta vacía → Delete
```
No borrar una carpeta que contenga uno de los dos dashboards canónicos.

View File

@ -0,0 +1,318 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-plan}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
BACKUP_BASE="${GRAFANA_CLEANUP_BACKUP_BASE:-/srv/mesavault/backups/ucepsa-grafana-cleanup}"
KEEP_UIDS=(
"ucepsa-shopfloor-context-health"
"ucepsa-edge-oee-machine"
)
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver la URL de Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
Cárguelo sin mostrarlo ni guardarlo en Git:
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
export BACKUP_BASE
export KEEP_UIDS_JSON
KEEP_UIDS_JSON="$(
printf '%s\n' "${KEEP_UIDS[@]}" \
| python3 -c 'import json,sys; print(json.dumps([x.strip() for x in sys.stdin if x.strip()]))'
)"
python3 - "$ACTION" <<'PY'
import datetime as dt
import hashlib
import json
import os
import pathlib
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
action = sys.argv[1]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
backup_base = pathlib.Path(os.environ["BACKUP_BASE"])
keep_uids = set(json.loads(os.environ["KEEP_UIDS_JSON"]))
confirm_value = os.environ.get("GRAFANA_CLEANUP_CONFIRM", "")
required_confirm = "DELETE_NON_CANONICAL_DASHBOARDS"
def request(method, path, payload=None, allow_404=False):
url = base_url + path
data = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
url,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=20) as response:
raw = response.read()
return json.loads(raw.decode("utf-8")) if raw else None
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
if allow_404 and exc.code == 404:
return None
raise RuntimeError(
f"Grafana API {method} {path}: HTTP {exc.code}: {detail}"
) from exc
def search(kind):
encoded = urllib.parse.urlencode({
"type": kind,
"limit": 5000,
})
return request("GET", f"/api/search?{encoded}") or []
def dashboard_row(item):
return {
"uid": item.get("uid"),
"title": item.get("title"),
"folder_uid": item.get("folderUid"),
"folder_title": item.get("folderTitle") or "General",
"url": item.get("url"),
"tags": item.get("tags") or [],
}
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(f"Grafana no está saludable: {health!r}")
dashboards = [dashboard_row(x) for x in search("dash-db")]
folders = search("dash-folder")
found_uids = {row["uid"] for row in dashboards}
missing = sorted(keep_uids - found_uids)
if missing:
raise RuntimeError(
"Faltan dashboards canónicos; se aborta para no borrar nada: "
+ ", ".join(missing)
)
rows = []
for row in sorted(
dashboards,
key=lambda x: (
x["folder_title"].lower(),
x["title"].lower(),
x["uid"],
),
):
rows.append({
**row,
"decision": "KEEP" if row["uid"] in keep_uids else "DELETE",
})
print("=== PLAN DE LIMPIEZA ===")
print(json.dumps(rows, ensure_ascii=False, indent=2))
print()
print(json.dumps({
"grafana_url": base_url,
"dashboard_count": len(rows),
"keep_count": sum(r["decision"] == "KEEP" for r in rows),
"delete_count": sum(r["decision"] == "DELETE" for r in rows),
"keep_uids": sorted(keep_uids),
}, ensure_ascii=False, indent=2))
if action == "plan":
print()
print("PLAN ONLY: no se ha modificado Grafana.")
raise SystemExit(0)
if action == "apply":
if confirm_value != required_confirm:
raise RuntimeError(
"Confirmación ausente. Ejecute:\n"
f" export GRAFANA_CLEANUP_CONFIRM='{required_confirm}'"
)
timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = backup_base / timestamp
dashboard_dir = backup_dir / "dashboards"
dashboard_dir.mkdir(parents=True, exist_ok=False)
manifest = {
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"grafana_url": base_url,
"keep_uids": sorted(keep_uids),
"dashboards_before": rows,
"folders_before": folders,
}
(backup_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
for row in rows:
uid = row["uid"]
payload = request(
"GET",
f"/api/dashboards/uid/{urllib.parse.quote(uid)}",
)
safe_uid = re.sub(r"[^A-Za-z0-9._-]+", "_", uid)
(dashboard_dir / f"{safe_uid}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
checksums = []
for path in sorted(backup_dir.rglob("*.json")):
digest = hashlib.sha256(path.read_bytes()).hexdigest()
checksums.append(
f"{digest} {path.relative_to(backup_dir)}"
)
(backup_dir / "SHA256SUMS").write_text(
"\n".join(checksums) + "\n",
encoding="utf-8",
)
deleted = []
for row in rows:
if row["decision"] != "DELETE":
continue
uid = row["uid"]
result = request(
"DELETE",
f"/api/dashboards/uid/{urllib.parse.quote(uid)}",
)
deleted.append({
"uid": uid,
"title": row["title"],
"folder_title": row["folder_title"],
"response": result,
})
remaining = [dashboard_row(x) for x in search("dash-db")]
remaining_uids = {x["uid"] for x in remaining}
if remaining_uids != keep_uids:
raise RuntimeError(
"La limpieza terminó con un conjunto inesperado.\n"
f"Esperado: {sorted(keep_uids)}\n"
f"Actual: {sorted(remaining_uids)}"
)
used_folder_uids = {
x["folder_uid"]
for x in remaining
if x.get("folder_uid")
}
empty_folders = [
{
"uid": folder.get("uid"),
"title": folder.get("title"),
}
for folder in folders
if folder.get("uid")
and folder.get("uid") not in used_folder_uids
]
result = {
"status": "ok",
"backup_dir": str(backup_dir),
"deleted_count": len(deleted),
"deleted": deleted,
"remaining": remaining,
"empty_folders_not_deleted": empty_folders,
}
(backup_dir / "result.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print("\n=== RESULTADO ===")
print(json.dumps(result, ensure_ascii=False, indent=2))
raise SystemExit(0)
if action == "verify":
remaining_uids = {row["uid"] for row in dashboards}
errors = []
if remaining_uids != keep_uids:
errors.append({
"expected": sorted(keep_uids),
"actual": sorted(remaining_uids),
})
print(json.dumps({
"status": "ok" if not errors else "error",
"dashboard_count": len(dashboards),
"dashboards": dashboards,
"errors": errors,
}, ensure_ascii=False, indent=2))
raise SystemExit(1 if errors else 0)
raise SystemExit(
"Uso: cleanup_grafana_dashboards_v0361.sh {plan|apply|verify}"
)
PY