319 lines
8.3 KiB
Bash
Executable File
319 lines
8.3 KiB
Bash
Executable File
#!/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
|