#!/usr/bin/env python3 """ Prometheus exporter for Cisco Nexus NX-OS PTP data. This exporter runs on a Linux monitoring host, NOT on the Nexus itself. It connects to NX-OS over SSH and collects: show ptp clock show ptp parent show ptp brief show ptp counters all SSH keys are preferred. Password authentication can be supplied via an environment variable if required. """ from __future__ import annotations import argparse import math import os import re import socket import threading import time from typing import Dict, List, Optional, Tuple import paramiko from prometheus_client import REGISTRY, start_http_server from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily, InfoMetricFamily KV_RE = re.compile(r"^\s*([^:]+?)\s*:\s*(.*?)\s*$") BRIEF_RE = re.compile(r"^\s*(Eth\S+)\s+([A-Za-z_-]+)\s*$") COUNTER_HEADER_RE = re.compile(r"PTP Packet Counters of Interface\s+(\S+):", re.I) COUNTER_ROW_RE = re.compile( r"^\s*(Announce|Sync|Follow\s*Up|FollowUp|Delay Request|Delay Response|" r"PDelay Request|PDelay Response|PDelay Follow\s*Up|PDelay FollowUp|" r"Management)\s+(\d+)\s+(\d+)\s*$", re.I, ) def number(value: str, default: float = math.nan) -> float: value = value.strip() try: if value.lower().startswith("0x"): return float(int(value, 16)) return float(value) except ValueError: return default def parse_clock(text: str) -> Dict[str, str]: result: Dict[str, str] = {} in_quality = False for line in text.splitlines(): stripped = line.strip() if stripped == "Clock Quality:": in_quality = True continue m = KV_RE.match(line) if not m: continue key = m.group(1).strip() value = m.group(2).strip() if in_quality and key in {"Class", "Accuracy", "Offset (log variance)"}: result[f"Clock Quality {key}"] = value continue # Any normal top-level line ends the Clock Quality sub-section. in_quality = False result[key] = value return result def parse_parent(text: str) -> Dict[str, str]: result: Dict[str, str] = {} in_gm_quality = False for line in text.splitlines(): stripped = line.strip() if stripped == "Grandmaster Clock Quality:": in_gm_quality = True continue m = KV_RE.match(line) if not m: continue key = m.group(1).strip() value = m.group(2).strip() if in_gm_quality and key in { "Class", "Accuracy", "Offset (log variance)", "Priority1", "Priority2", }: result[f"GM {key}"] = value continue if stripped in {"Parent Clock:", "Grandmaster Clock:"}: continue # Keep the quality context only while parsing its fields. if key not in { "Class", "Accuracy", "Offset (log variance)", "Priority1", "Priority2", }: in_gm_quality = False result[key] = value return result def parse_brief(text: str) -> List[Tuple[str, str]]: rows: List[Tuple[str, str]] = [] for line in text.splitlines(): m = BRIEF_RE.match(line) if m: rows.append((m.group(1), m.group(2).lower())) return rows def normalize_message_type(name: str) -> str: return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") def parse_counters(text: str) -> List[Tuple[str, str, float, float]]: """ Returns (interface, message_type, tx, rx) tuples. """ rows: List[Tuple[str, str, float, float]] = [] current_interface: Optional[str] = None for line in text.splitlines(): m = COUNTER_HEADER_RE.search(line) if m: current_interface = m.group(1).rstrip(":") continue if not current_interface: continue m = COUNTER_ROW_RE.match(line) if m: rows.append( ( current_interface, normalize_message_type(m.group(1)), float(m.group(2)), float(m.group(3)), ) ) return rows class NXOSClient: def __init__( self, host: str, port: int, username: str, password: Optional[str], key_file: Optional[str], timeout: float, accept_new_host_key: bool, ): self.host = host self.port = port self.username = username self.password = password self.key_file = key_file self.timeout = timeout self.accept_new_host_key = accept_new_host_key def run_commands(self, commands: List[str]) -> Dict[str, str]: client = paramiko.SSHClient() client.load_system_host_keys() if self.accept_new_host_key: client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) else: client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect( hostname=self.host, port=self.port, username=self.username, password=self.password, key_filename=self.key_file, timeout=self.timeout, banner_timeout=self.timeout, auth_timeout=self.timeout, look_for_keys=self.key_file is None and self.password is None, allow_agent=True, ) results: Dict[str, str] = {} try: for command in commands: stdin, stdout, stderr = client.exec_command(command, timeout=self.timeout) output = stdout.read().decode(errors="replace") error = stderr.read().decode(errors="replace").strip() status = stdout.channel.recv_exit_status() if status != 0: raise RuntimeError( f"{command!r} failed with status {status}: {error or output}" ) results[command] = output finally: client.close() return results class NexusPTPCollector: COMMANDS = [ "show ptp clock", "show ptp parent", "show ptp brief", "show ptp counters all", ] def __init__(self, nxos: NXOSClient, target_name: str, cache_seconds: float): self.nxos = nxos self.target_name = target_name self.cache_seconds = cache_seconds self.lock = threading.Lock() self.last_fetch = 0.0 self.cached: Optional[Dict[str, str]] = None self.last_fetch_success = 0.0 self.last_error = "" def _fetch(self) -> Dict[str, str]: now = time.monotonic() with self.lock: if self.cached is not None and (now - self.last_fetch) < self.cache_seconds: return self.cached try: self.cached = self.nxos.run_commands(self.COMMANDS) self.last_fetch_success = 1.0 self.last_error = "" except Exception as exc: self.last_fetch_success = 0.0 self.last_error = type(exc).__name__ if self.cached is None: raise finally: self.last_fetch = time.monotonic() return self.cached or {} def collect(self): start = time.monotonic() try: outputs = self._fetch() success = self.last_fetch_success except Exception as exc: outputs = {} success = 0.0 self.last_error = type(exc).__name__ clock = parse_clock(outputs.get("show ptp clock", "")) parent = parse_parent(outputs.get("show ptp parent", "")) ports = parse_brief(outputs.get("show ptp brief", "")) counters = parse_counters(outputs.get("show ptp counters all", "")) target_info = InfoMetricFamily( "ptp_nexus_target", "Nexus PTP exporter target", labels=["target", "host"], ) target_info.add_metric([self.target_name, self.nxos.host], {}) yield target_info if clock: info = InfoMetricFamily( "ptp_nexus_local_clock", "Nexus local PTP clock identity and source address", labels=["clock_identity", "source_ip", "device_type"], ) info.add_metric( [ clock.get("Clock Identity", ""), clock.get("PTP Source IP Address", ""), clock.get("PTP Device Type", ""), ], {}, ) yield info locked = GaugeMetricFamily( "ptp_nexus_clock_locked", "1 when the Nexus PTP clock state is Locked", ) locked.add_metric([], 1.0 if clock.get("PTP Clock state", "").lower() == "locked" else 0.0) yield locked fields = [ ("ptp_nexus_offset_from_master_nanoseconds", "Offset From Master", "Nexus offset from immediate PTP master"), ("ptp_nexus_mean_path_delay_nanoseconds", "Mean Path Delay", "Nexus mean path delay"), ("ptp_nexus_steps_removed", "Steps removed", "Boundary-clock steps from grandmaster"), ("ptp_nexus_domain_number", "Clock Domain", "PTP domain"), ("ptp_nexus_priority1", "Priority1", "Nexus PTP priority1"), ("ptp_nexus_priority2", "Priority2", "Nexus PTP priority2"), ("ptp_nexus_clock_class", "Clock Quality Class", "Nexus local clockClass"), ("ptp_nexus_clock_accuracy_code", "Clock Quality Accuracy", "Nexus clockAccuracy code"), ("ptp_nexus_offset_scaled_log_variance", "Clock Quality Offset (log variance)", "Nexus offsetScaledLogVariance"), ("ptp_nexus_port_count", "Number of PTP ports", "Number of PTP-enabled ports"), ] for metric_name, field, help_text in fields: if field in clock: g = GaugeMetricFamily(metric_name, help_text) g.add_metric([], number(clock[field])) yield g if parent: info = InfoMetricFamily( "ptp_nexus_parent", "Nexus immediate PTP parent and grandmaster", labels=[ "parent_clock_identity", "parent_port_number", "parent_ip", "grandmaster_clock_identity", ], ) info.add_metric( [ parent.get("Parent Clock Identity", ""), parent.get("Parent Port Number", ""), parent.get("Parent IP", ""), parent.get("Grandmaster Clock Identity", ""), ], {}, ) yield info fields = [ ("ptp_nexus_grandmaster_clock_class", "GM Class", "Selected grandmaster clockClass"), ("ptp_nexus_grandmaster_clock_accuracy_code", "GM Accuracy", "Selected grandmaster clockAccuracy code"), ("ptp_nexus_grandmaster_offset_scaled_log_variance", "GM Offset (log variance)", "Selected grandmaster offsetScaledLogVariance"), ("ptp_nexus_grandmaster_priority1", "GM Priority1", "Selected grandmaster priority1"), ("ptp_nexus_grandmaster_priority2", "GM Priority2", "Selected grandmaster priority2"), ] for metric_name, field, help_text in fields: if field in parent: g = GaugeMetricFamily(metric_name, help_text) g.add_metric([], number(parent[field])) yield g if ports: metric = GaugeMetricFamily( "ptp_nexus_port_state", "Current Nexus PTP port state; one sample with value 1 per port", labels=["interface", "state"], ) for interface, state in ports: metric.add_metric([interface, state], 1.0) yield metric if counters: metric = CounterMetricFamily( "ptp_nexus_port_messages", "PTP packet counters reported by NX-OS", labels=["interface", "direction", "message_type"], ) for interface, message_type, tx, rx in counters: metric.add_metric([interface, "tx", message_type], tx) metric.add_metric([interface, "rx", message_type], rx) yield metric scrape = GaugeMetricFamily( "ptp_nexus_exporter_scrape_success", "1 if the most recent NX-OS SSH collection succeeded", labels=["target", "error"], ) scrape.add_metric([self.target_name, self.last_error or "none"], success) yield scrape duration = GaugeMetricFamily( "ptp_nexus_exporter_scrape_duration_seconds", "Time spent serving this exporter collection", labels=["target"], ) duration.add_metric([self.target_name], time.monotonic() - start) yield duration def main() -> None: p = argparse.ArgumentParser(description="Prometheus exporter for Cisco Nexus PTP") p.add_argument("--host", required=True) p.add_argument("--target-name", default=None) p.add_argument("--ssh-port", type=int, default=22) p.add_argument("--username", required=True) p.add_argument("--key-file", default=None) p.add_argument( "--password-env", default=None, help="Read SSH password from this environment variable", ) p.add_argument("--ssh-timeout", type=float, default=5.0) p.add_argument("--cache-seconds", type=float, default=5.0) p.add_argument("--accept-new-host-key", action="store_true") p.add_argument("--listen", default="0.0.0.0") p.add_argument("--port", type=int, default=9560) args = p.parse_args() password = os.environ.get(args.password_env) if args.password_env else None nxos = NXOSClient( host=args.host, port=args.ssh_port, username=args.username, password=password, key_file=args.key_file, timeout=args.ssh_timeout, accept_new_host_key=args.accept_new_host_key, ) collector = NexusPTPCollector( nxos=nxos, target_name=args.target_name or args.host, cache_seconds=args.cache_seconds, ) REGISTRY.register(collector) start_http_server(args.port, addr=args.listen) print( f"Nexus PTP exporter listening on {args.listen}:{args.port}, target={args.host}", flush=True, ) while True: time.sleep(3600) if __name__ == "__main__": main()