#!/usr/bin/env python3 """Prometheus exporter for the labsim connectivity matrix. Runs the same sweep as labsim-matrix.py on an interval and exposes it as metrics, so Grafana can show the mesh as a heatmap and — more usefully — a history of exactly when a cell flipped after a firewall change. labsim_reachable{src,dst,proto} 1 = reachable, 0 = blocked labsim_sweep_seconds how long the last sweep took labsim_sweep_total sweeps completed since start labsim_up 1 while the exporter is alive Deliberately stdlib-only (http.server + threads): this runs on the workstation next to libvirt, and adding a dependency to watch a lab network is silly. ./labsim-exporter.py --port 9101 --interval 15 """ from __future__ import annotations import argparse import http.server import threading import time import labsim_matrix_lib as m # thin import shim, see below class Collector: def __init__(self, interval: int, timeout: int) -> None: self.interval = interval self.timeout = timeout self.vlans = m.load_vlans() self.lock = threading.Lock() self.results: dict = {} self.duration = 0.0 self.sweeps = 0 def loop(self) -> None: while True: started = time.time() try: results = m.sweep(self.vlans, self.timeout) with self.lock: self.results = results self.duration = time.time() - started self.sweeps += 1 except Exception: # noqa: BLE001 - never let the loop die pass time.sleep(max(1.0, self.interval - (time.time() - started))) def render(self) -> str: with self.lock: results, duration, sweeps = dict(self.results), self.duration, self.sweeps out = [ "# HELP labsim_reachable 1 if dst is reachable from src over proto", "# TYPE labsim_reachable gauge", ] for src, data in results.items(): if "__error__" in data: continue for dst, protos in data.items(): for proto, ok in protos.items(): out.append( f'labsim_reachable{{src="{src}",dst="{dst}",proto="{proto}"}} {1 if ok else 0}') out += [ "# HELP labsim_sweep_seconds duration of the last sweep", "# TYPE labsim_sweep_seconds gauge", f"labsim_sweep_seconds {duration:.3f}", "# HELP labsim_sweep_total sweeps completed", "# TYPE labsim_sweep_total counter", f"labsim_sweep_total {sweeps}", "# HELP labsim_up exporter liveness", "# TYPE labsim_up gauge", "labsim_up 1", ] return "\n".join(out) + "\n" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=9101) ap.add_argument("--interval", type=int, default=15) ap.add_argument("--timeout", type=int, default=30) args = ap.parse_args() collector = Collector(args.interval, args.timeout) threading.Thread(target=collector.loop, daemon=True).start() class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 - stdlib API if self.path.rstrip("/") not in ("", "/metrics"): self.send_error(404) return body = collector.render().encode() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *_args) -> None: # keep the console quiet return srv = http.server.ThreadingHTTPServer(("0.0.0.0", args.port), Handler) print(f"labsim exporter on :{args.port}/metrics (sweep every {args.interval}s)") srv.serve_forever() return 0 if __name__ == "__main__": raise SystemExit(main())