From 2afc19105be9bec99c0cc31d786d2f72ba4467d8 Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Mon, 20 Jul 2026 12:09:39 +0200 Subject: [PATCH] ops(ucepsa): add controlled Grafana dashboard cleanup --- .../103_grafana_dashboard_cleanup_v0.3.6.1.md | 80 +++++ .../ops/cleanup_grafana_dashboards_v0361.sh | 318 ++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/103_grafana_dashboard_cleanup_v0.3.6.1.md create mode 100755 ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh diff --git a/ucepsa/edge-oee-demo/docs/runbooks/103_grafana_dashboard_cleanup_v0.3.6.1.md b/ucepsa/edge-oee-demo/docs/runbooks/103_grafana_dashboard_cleanup_v0.3.6.1.md new file mode 100644 index 0000000..5f409da --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/103_grafana_dashboard_cleanup_v0.3.6.1.md @@ -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// +``` + +## 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. diff --git a/ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh b/ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh new file mode 100755 index 0000000..f6eed4f --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh @@ -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