Skip to content

Instantly share code, notes, and snippets.

@wasatch-dev
Created August 17, 2026 19:35
Show Gist options
  • Select an option

  • Save wasatch-dev/5c84d0557cbab760eca022fad7468493 to your computer and use it in GitHub Desktop.

Select an option

Save wasatch-dev/5c84d0557cbab760eca022fad7468493 to your computer and use it in GitHub Desktop.
ppdm textfile exporter
#!/usr/bin/env python3
"""
PPDM -> node_exporter textfile collector.
Polls the PPDM REST API once, writes a .prom file into node_exporter's
textfile directory, then exits. Run it on a schedule (cron or a systemd
timer) instead of running it as a long-lived service.
No third-party dependencies -- uses only Python's standard library
(urllib), so nothing needs to be pip installed.
node_exporter must be started with:
--collector.textfile.directory=/var/lib/node_exporter/textfile_collector
NOTE ON API PATHS: field names below (protectionStatus, severity, the
pagination shape) are correct for the mainstream PPDM v2 REST API but can
drift slightly across 19.x releases. A few endpoints below (/health-metrics,
/health-issues, /log-disk-info) use paths inferred from naming convention,
not confirmed against a live system -- verify each against your instance's
Swagger docs at https://<ppdm-fqdn>:8443/apidocs before trusting this in
production.
"""
import os
import ssl
import json
import time
import tempfile
import urllib.request
import urllib.parse
import urllib.error
# ---------------------------------------------------------------------------
# Config — edit these for your environment
# ---------------------------------------------------------------------------
PPDM_HOST = "ppdm.example.local"
PPDM_PORT = "8443"
PPDM_USER = "monitor-svc"
PPDM_PASS = "change-me"
VERIFY_TLS = False
TEXTFILE_DIR = "/var/lib/node_exporter/textfile_collector"
OUTPUT_NAME = "ppdm.prom"
BASE_URL = f"https://{PPDM_HOST}:{PPDM_PORT}/api/v2"
SSL_CTX = ssl.create_default_context()
if not VERIFY_TLS:
SSL_CTX.check_hostname = False
SSL_CTX.verify_mode = ssl.CERT_NONE
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def escape(value):
return str(value).replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
def metric_line(name, value, labels=None):
if labels:
label_str = ",".join(f'{k}="{escape(v)}"' for k, v in labels.items())
return f"{name}{{{label_str}}} {value}"
return f"{name} {value}"
def write_textfile(directory, filename, content):
"""Atomic write: temp file in the same dir, then os.replace.
Required so node_exporter never reads a half-written file."""
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp_" + filename)
with os.fdopen(fd, "w") as f:
f.write(content)
final_path = os.path.join(directory, filename)
os.replace(tmp_path, final_path)
os.chmod(final_path, 0o644)
def http_request(method, url, headers=None, params=None, json_body=None, timeout=30):
"""Minimal requests-like wrapper around urllib."""
if params:
url = url + "?" + urllib.parse.urlencode(params)
data = None
headers = dict(headers or {})
if json_body is not None:
data = json.dumps(json_body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, context=SSL_CTX, timeout=timeout) as resp:
body = resp.read()
return json.loads(body) if body else {}
def ppdm_login(headers):
resp = http_request(
"POST", f"{BASE_URL}/login",
json_body={"username": PPDM_USER, "password": PPDM_PASS},
)
headers["Authorization"] = f"Bearer {resp['access_token']}"
def ppdm_get_all_pages(headers, path, params=None, page_size=100):
params = dict(params or {})
params["pageSize"] = page_size
items = []
while True:
data = http_request("GET", f"{BASE_URL}{path}", headers=headers, params=params)
items.extend(data.get("content", []))
page_info = data.get("page", {})
if page_info.get("number", 0) + 1 >= page_info.get("totalPages", 1):
break
params["page"] = page_info.get("number", 0) + 1
return items
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
headers = {}
lines = []
up = 1
start = time.time()
try:
ppdm_login(headers)
# --- active alerts by severity ---
lines.append("# HELP ppdm_active_alerts Active alerts by severity")
lines.append("# TYPE ppdm_active_alerts gauge")
counts = {}
for item in ppdm_get_all_pages(headers, "/alerts", {"filter": 'status eq "ACTIVE"'}):
sev = item.get("severity", "INFO")
counts[sev] = counts.get(sev, 0) + 1
for sev in ["CRITICAL", "MAJOR", "MINOR", "WARNING", "INFO"]:
lines.append(metric_line("ppdm_active_alerts", counts.get(sev, 0), {"severity": sev}))
# --- job/activity outcomes, last 24h ---
# NOTE: /activities is deprecated as of PPDM 19.21 and is slated for
# removal in 19.25. It still works today -- check the "API migration
# guide" on your version's developer.dell.com page for its
# replacement before you're on 19.25, and swap the path below.
lines.append("# HELP ppdm_activities_24h Job/activity outcomes in the last 24h")
lines.append("# TYPE ppdm_activities_24h gauge")
since = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(time.time() - 24 * 3600))
counts = {}
for item in ppdm_get_all_pages(headers, "/activities", {"filter": f'startTime gt "{since}"'}):
category = item.get("category", "UNKNOWN")
# result.status is only set once an activity finishes (allowed
# values: OK, CANCELED, FAILED, OK_WITH_ERRORS, UNKNOWN, SKIPPED).
# Fall back to the top-level state (e.g. RUNNING, QUEUED) for
# activities still in flight.
status = (item.get("result") or {}).get("status") or item.get("state", "UNKNOWN")
key = (category, status)
counts[key] = counts.get(key, 0) + 1
for (category, status), n in counts.items():
lines.append(metric_line("ppdm_activities_24h", n, {"category": category, "result": status}))
# --- assets by protection status ---
lines.append("# HELP ppdm_assets_by_protection_status Assets grouped by protection status")
lines.append("# TYPE ppdm_assets_by_protection_status gauge")
counts = {}
for item in ppdm_get_all_pages(headers, "/assets"):
atype = item.get("type", "UNKNOWN")
status = item.get("protectionStatus", "UNKNOWN")
key = (atype, status)
counts[key] = counts.get(key, 0) + 1
for (atype, status), n in counts.items():
lines.append(metric_line("ppdm_assets_by_protection_status", n, {"asset_type": atype, "protection_status": status}))
# --- protection storage capacity / reachability ---
# Endpoint path assumed as /storage-systems -- confirm against the
# path shown in your API Explorer/Swagger page and fix if different.
lines.append("# HELP ppdm_storage_capacity_used_percent Storage system capacity utilization (0-100)")
lines.append("# TYPE ppdm_storage_capacity_used_percent gauge")
lines.append("# HELP ppdm_storage_reachable Whether protection storage is reachable (1=yes, 0=no)")
lines.append("# TYPE ppdm_storage_reachable gauge")
lines.append("# HELP ppdm_storage_last_discovery_ok Whether last discovery of this storage system succeeded (1=yes, 0=no)")
lines.append("# TYPE ppdm_storage_last_discovery_ok gauge")
lines.append("# HELP ppdm_storage_total_bytes Data Domain total logical/physical size")
lines.append("# TYPE ppdm_storage_total_bytes gauge")
lines.append("# HELP ppdm_storage_used_bytes Data Domain total logical/physical used")
lines.append("# TYPE ppdm_storage_used_bytes gauge")
for item in ppdm_get_all_pages(headers, "/storage-systems"):
name = item.get("name", "unknown")
stype = item.get("type", "UNKNOWN")
labels = {"storage_name": name, "storage_type": stype}
pct_used = item.get("capacityUtilization")
if pct_used is not None:
lines.append(metric_line("ppdm_storage_capacity_used_percent", pct_used, labels))
# NOTE: assumed readiness=="READY" means reachable -- verify the
# real values your PPDM returns and adjust if the enum differs.
readiness = item.get("readiness", "UNKNOWN")
reachable = 1 if readiness == "READY" else 0
lines.append(metric_line("ppdm_storage_reachable", reachable, {"storage_name": name, "readiness": readiness}))
discovery_ok = 1 if item.get("lastDiscoveryStatus") in ("OK", "SUCCESS") else 0
lines.append(metric_line("ppdm_storage_last_discovery_ok", discovery_ok, {"storage_name": name}))
dd = item.get("details", {}).get("dataDomain")
if dd:
if dd.get("totalSize") is not None:
lines.append(metric_line("ppdm_storage_total_bytes", dd["totalSize"], {"storage_name": name}))
if dd.get("totalUsed") is not None:
lines.append(metric_line("ppdm_storage_used_bytes", dd["totalUsed"], {"storage_name": name}))
# --- license expiry ---
lines.append("# HELP ppdm_license_days_remaining Minimum days remaining across all licenses")
lines.append("# TYPE ppdm_license_days_remaining gauge")
data = http_request("GET", f"{BASE_URL}/licenses", headers=headers)
items = data.get("content", [data]) if isinstance(data, dict) else data
min_days = None
for item in items:
days = item.get("daysToExpiration")
if days is not None:
min_days = days if min_days is None else min(min_days, days)
if min_days is not None:
lines.append(metric_line("ppdm_license_days_remaining", min_days))
# --- system health metrics (per component score/status) ---
# Endpoint path assumed as /health-metrics -- unconfirmed, check the
# "Requests" section at the top of that API Explorer page.
lines.append("# HELP ppdm_health_score Health score per system component (0-100, higher is healthier)")
lines.append("# TYPE ppdm_health_score gauge")
lines.append("# HELP ppdm_health_issues_count Open issue count per system component")
lines.append("# TYPE ppdm_health_issues_count gauge")
for item in ppdm_get_all_pages(headers, "/health-metrics"):
comp_name = item.get("componentName", "unknown")
comp_type = item.get("componentType", "UNKNOWN")
labels = {"component_name": comp_name, "component_type": comp_type}
if item.get("score") is not None:
lines.append(metric_line("ppdm_health_score", item["score"], labels))
if item.get("issuesCount") is not None:
lines.append(metric_line("ppdm_health_issues_count", item["issuesCount"], labels))
# --- system health issues (open issues by component/category/severity) ---
# Endpoint path assumed as /health-issues -- same caveat as above.
lines.append("# HELP ppdm_health_issues Open system health issues by component, category and severity")
lines.append("# TYPE ppdm_health_issues gauge")
counts = {}
for item in ppdm_get_all_pages(headers, "/health-issues"):
key = (
item.get("componentName", "unknown"),
item.get("healthCategory", "UNKNOWN"),
item.get("severity", "UNKNOWN"),
)
counts[key] = counts.get(key, 0) + 1
for (comp_name, category, severity), n in counts.items():
lines.append(metric_line(
"ppdm_health_issues", n,
{"component_name": comp_name, "health_category": category, "severity": severity},
))
# --- log partition disk usage ---
# Endpoint path assumed as /log-disk-info -- same caveat as above.
lines.append("# HELP ppdm_log_disk_total_bytes Total size of the partition logs are stored on")
lines.append("# TYPE ppdm_log_disk_total_bytes gauge")
lines.append("# HELP ppdm_log_disk_available_bytes Available space on the log partition")
lines.append("# TYPE ppdm_log_disk_available_bytes gauge")
lines.append("# HELP ppdm_log_disk_used_percent Percent used on the log partition (0-100)")
lines.append("# TYPE ppdm_log_disk_used_percent gauge")
data = http_request("GET", f"{BASE_URL}/log-disk-info", headers=headers)
items = data.get("content", [data]) if isinstance(data, dict) else data
if items:
info = items[0]
total = info.get("totalDiskSpaceBytes")
available = info.get("availableDiskSpaceBytes")
if total is not None:
lines.append(metric_line("ppdm_log_disk_total_bytes", total))
if available is not None:
lines.append(metric_line("ppdm_log_disk_available_bytes", available))
if total not in (None, 0) and available is not None:
used_pct = round((1 - available / total) * 100, 2)
lines.append(metric_line("ppdm_log_disk_used_percent", used_pct))
except Exception as e:
print(f"poll failed: {e}")
up = 0
# --- exporter health ---
lines.append("# HELP ppdm_up Whether the PPDM API poll succeeded (1=yes, 0=no)")
lines.append("# TYPE ppdm_up gauge")
lines.append(metric_line("ppdm_up", up))
lines.append("# HELP ppdm_scrape_duration_seconds How long this poll took")
lines.append("# TYPE ppdm_scrape_duration_seconds gauge")
lines.append(metric_line("ppdm_scrape_duration_seconds", round(time.time() - start, 3)))
content = "\n".join(lines) + "\n"
write_textfile(TEXTFILE_DIR, OUTPUT_NAME, content)
print(f"wrote {os.path.join(TEXTFILE_DIR, OUTPUT_NAME)} (up={up})")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment