Created
August 17, 2026 19:35
-
-
Save wasatch-dev/5c84d0557cbab760eca022fad7468493 to your computer and use it in GitHub Desktop.
ppdm textfile exporter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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