#!/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