ops(ucepsa): add controlled Grafana dashboard cleanup
This commit is contained in:
parent
dbc45bccd4
commit
2afc19105b
@ -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.
|
||||
318
ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
Executable file
318
ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user