feat(cert-checker): add Kubernetes resources for certificate checking functionality
- Implemented a new module for cert-checker with Kubernetes resources including ServiceAccount, Role, RoleBinding, ConfigMap, and CronJob. - Added Python script to check certificate expiry and restart deployments if necessary. - Configured environment variables for deployment name, namespace, and service details. - Included requirements for the Python environment. feat(devolo-exporter): introduce Devolo exporter for Prometheus metrics - Created a new module for Devolo exporter with Kubernetes resources including ServiceAccount, ConfigMap, Deployment, and Service. - Developed Python scripts to scrape metrics from Devolo devices and expose them to Prometheus. - Configured environment variables for Devolo device IP, model, and ports. - Added requirements for the Python environment.
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
|
||||
import requests
|
||||
import websocket
|
||||
from prometheus_client import (
|
||||
CollectorRegistry,
|
||||
Gauge,
|
||||
Info,
|
||||
CONTENT_TYPE_LATEST,
|
||||
generate_latest,
|
||||
)
|
||||
|
||||
|
||||
# Configuration via environment
|
||||
DEVOLO_IP = os.getenv("DEVOLO_IP")
|
||||
RD_PORT = int(os.getenv("RD_PORT", "9222"))
|
||||
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9100"))
|
||||
|
||||
if not DEVOLO_IP:
|
||||
print("DEVOLO_IP env var is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class DevoloScraper:
|
||||
def __init__(self, devolo_ip: str, rd_port: int) -> None:
|
||||
self.devolo_ip = devolo_ip
|
||||
self.rd_port = rd_port
|
||||
self.ws = None
|
||||
self.msg_id = 0
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _next_id(self) -> int:
|
||||
with self.lock:
|
||||
self.msg_id += 1
|
||||
return self.msg_id
|
||||
|
||||
def _connect(self) -> None:
|
||||
# Find Chrome DevTools WebSocket endpoint
|
||||
targets = requests.get(f"http://localhost:{self.rd_port}/json", timeout=5).json()
|
||||
if not targets:
|
||||
raise RuntimeError("No DevTools targets found")
|
||||
ws_url = targets[0]["webSocketDebuggerUrl"]
|
||||
self.ws = websocket.create_connection(ws_url, timeout=10)
|
||||
|
||||
# Enable Runtime and Page domains
|
||||
self._send({"method": "Runtime.enable"})
|
||||
self._send({"method": "Page.enable"})
|
||||
|
||||
def _send(self, payload: dict) -> dict:
|
||||
if self.ws is None:
|
||||
self._connect()
|
||||
msg_id = self._next_id()
|
||||
payload = {"id": msg_id, **payload}
|
||||
self.ws.send(json.dumps(payload))
|
||||
return self._recv_until_id(msg_id)
|
||||
|
||||
def _recv_until_id(self, expected_id: int, timeout: int = 10) -> dict:
|
||||
self.ws.settimeout(timeout)
|
||||
while True:
|
||||
msg = json.loads(self.ws.recv())
|
||||
if "id" not in msg:
|
||||
continue
|
||||
if msg["id"] == expected_id:
|
||||
return msg
|
||||
|
||||
def scrape_overview(self) -> dict:
|
||||
try:
|
||||
# Navigate to the Overview page (no hash for 326 model)
|
||||
self._send(
|
||||
{
|
||||
"method": "Page.navigate",
|
||||
"params": {"url": f"http://{self.devolo_ip}/overview"},
|
||||
}
|
||||
)
|
||||
time.sleep(2)
|
||||
|
||||
overview_js = """
|
||||
(() => {
|
||||
const val = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return (el && (el.innerText || el.textContent) ? (el.innerText || el.textContent).trim() : "");
|
||||
};
|
||||
return {
|
||||
name: val('device-name'),
|
||||
serial: val('serial-number'),
|
||||
firmware: val('firmware-version'),
|
||||
mac: val('mac-address'),
|
||||
uptime: val('system-uptime'),
|
||||
};
|
||||
})()
|
||||
"""
|
||||
resp = self._send(
|
||||
{
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": overview_js,
|
||||
"returnByValue": True,
|
||||
"awaitPromise": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
value = resp.get("result", {}).get("result", {}).get("value")
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Overview scrape error: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
def scrape_links(self) -> list[dict]:
|
||||
try:
|
||||
# Ensure we are on the overview page where the connections table lives
|
||||
self._send(
|
||||
{
|
||||
"method": "Page.navigate",
|
||||
"params": {"url": f"http://{self.devolo_ip}/overview"},
|
||||
}
|
||||
)
|
||||
time.sleep(2)
|
||||
|
||||
js = r"""
|
||||
(() => {
|
||||
const cells = Array.from(document.querySelectorAll('td[id^="devices-id-"]:not([id$="-label"]):not([id$="-this"])'));
|
||||
const isDigits = (s) => /^[0-9]+$/.test(s);
|
||||
return cells.map((el) => {
|
||||
const idx = el.id.substring('devices-id-'.length);
|
||||
if (!isDigits(idx)) return null;
|
||||
const id = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const mac = (document.getElementById(`devices-mac-${idx}`)?.textContent || '').trim();
|
||||
const tx = (document.getElementById(`devices-tx-${idx}`)?.textContent || '').trim();
|
||||
const rx = (document.getElementById(`devices-rx-${idx}`)?.textContent || '').trim();
|
||||
return { id, mac, tx, rx };
|
||||
}).filter(Boolean);
|
||||
})()
|
||||
"""
|
||||
resp = self._send(
|
||||
{
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": js,
|
||||
"returnByValue": True,
|
||||
"awaitPromise": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
value = resp.get("result", {}).get("result", {}).get("value")
|
||||
if isinstance(value, list):
|
||||
out = []
|
||||
for it in value:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
dev_id = (it.get("id") or "").replace("\xa0", " ").strip()
|
||||
mac = (it.get("mac") or "").strip()
|
||||
# Skip header or self row or empty rows
|
||||
if (
|
||||
not dev_id
|
||||
or "(this" in dev_id.lower()
|
||||
or dev_id.lower() == "device"
|
||||
or mac.lower() == "mac address"
|
||||
):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": dev_id,
|
||||
"mac": mac,
|
||||
"tx": _to_float(it.get("tx")),
|
||||
"rx": _to_float(it.get("rx")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
return []
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Links scrape error: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
def close(self) -> None:
|
||||
if self.ws is not None:
|
||||
try:
|
||||
self.ws.close()
|
||||
finally:
|
||||
self.ws = None
|
||||
|
||||
|
||||
def _to_float(v):
|
||||
try:
|
||||
if v is None:
|
||||
return float("nan")
|
||||
s = str(v).strip().replace(",", ".")
|
||||
# Normalize non-breaking spaces and dashes
|
||||
s = s.replace("\xa0", " ").replace("–", "-")
|
||||
s = "".join(ch for ch in s if (ch.isdigit() or ch in ".-"))
|
||||
if s == "" or s == "-" or s == "---":
|
||||
return float("nan")
|
||||
return float(s)
|
||||
except Exception: # noqa: BLE001
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _parse_uptime_seconds(s: str) -> float:
|
||||
try:
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return float("nan")
|
||||
# Format examples: "1 days, 03:49:56" or "03:49:56"
|
||||
days = 0
|
||||
time_part = s
|
||||
if "," in s:
|
||||
day_part, time_part = [p.strip() for p in s.split(",", 1)]
|
||||
if "day" in day_part: # matches 'day' or 'days'
|
||||
try:
|
||||
days = int(day_part.split()[0])
|
||||
except Exception: # noqa: BLE001
|
||||
days = 0
|
||||
h, m, sec = map(int, time_part.split(":"))
|
||||
return float(days * 86400 + h * 3600 + m * 60 + sec)
|
||||
except Exception: # noqa: BLE001
|
||||
return float("nan")
|
||||
|
||||
|
||||
class MetricsHandler(BaseHTTPRequestHandler):
|
||||
registry = CollectorRegistry()
|
||||
dev_tx = Gauge(
|
||||
"devolo_tx_mbps",
|
||||
"Devolo powerline TX throughput in Mbps",
|
||||
["device_id", "mac"],
|
||||
registry=registry,
|
||||
)
|
||||
dev_rx = Gauge(
|
||||
"devolo_rx_mbps",
|
||||
"Devolo powerline RX throughput in Mbps",
|
||||
["device_id", "mac"],
|
||||
registry=registry,
|
||||
)
|
||||
device_info = Info(
|
||||
"devolo_device_info",
|
||||
"Devolo device information",
|
||||
registry=registry,
|
||||
)
|
||||
uptime_seconds = Gauge(
|
||||
"devolo_uptime_seconds",
|
||||
"Devolo device uptime in seconds",
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
scraper: DevoloScraper | None = None
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/healthz":
|
||||
ok, msg = _health_check()
|
||||
status = 200 if ok else 503
|
||||
body = ("ok" if ok else msg).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
if self.path != "/metrics":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if MetricsHandler.scraper is not None:
|
||||
# Overview
|
||||
ov = MetricsHandler.scraper.scrape_overview()
|
||||
if ov:
|
||||
MetricsHandler.device_info.info(
|
||||
{
|
||||
"name": ov.get("name", ""),
|
||||
"serial": ov.get("serial", ""),
|
||||
"firmware": ov.get("firmware", ""),
|
||||
"mac": ov.get("mac", ""),
|
||||
}
|
||||
)
|
||||
MetricsHandler.uptime_seconds.set(
|
||||
_parse_uptime_seconds(ov.get("uptime", ""))
|
||||
)
|
||||
|
||||
# Links
|
||||
links = MetricsHandler.scraper.scrape_links()
|
||||
for row in links:
|
||||
did = row.get("id", "")
|
||||
mac = row.get("mac", "")
|
||||
tx = row.get("tx")
|
||||
rx = row.get("rx")
|
||||
if tx is not None:
|
||||
MetricsHandler.dev_tx.labels(device_id=did, mac=mac).set(tx)
|
||||
if rx is not None:
|
||||
MetricsHandler.dev_rx.labels(device_id=did, mac=mac).set(rx)
|
||||
|
||||
output = generate_latest(MetricsHandler.registry)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", CONTENT_TYPE_LATEST)
|
||||
self.send_header("Content-Length", str(len(output)))
|
||||
self.end_headers()
|
||||
self.wfile.write(output)
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
def run_server():
|
||||
scraper = DevoloScraper(DEVOLO_IP, RD_PORT)
|
||||
MetricsHandler.scraper = scraper
|
||||
|
||||
httpd = ThreadingHTTPServer(("0.0.0.0", EXPORTER_PORT), MetricsHandler)
|
||||
stop_event = threading.Event()
|
||||
|
||||
def handle_term(signum, frame): # noqa: ANN001, ARG001
|
||||
print("Shutting down...")
|
||||
try:
|
||||
threading.Thread(
|
||||
target=httpd.shutdown, name="httpd-shutdown", daemon=True
|
||||
).start()
|
||||
except Exception:
|
||||
pass
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_term)
|
||||
signal.signal(signal.SIGINT, handle_term)
|
||||
|
||||
print(
|
||||
f"Exporter listening on :{EXPORTER_PORT}, scraping Devolo(326) at {DEVOLO_IP}, DevTools port {RD_PORT}"
|
||||
)
|
||||
server_thread = threading.Thread(
|
||||
target=httpd.serve_forever, name="httpd-serve", daemon=True
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
stop_event.wait()
|
||||
server_thread.join(timeout=5)
|
||||
try:
|
||||
httpd.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
scraper.close()
|
||||
|
||||
|
||||
def _health_check() -> tuple[bool, str]:
|
||||
"""Quick health check for liveness/readiness.
|
||||
|
||||
Returns (ok, message). ok=False with a short message describing the first failure.
|
||||
Criteria:
|
||||
- Chromium DevTools endpoint reachable on localhost:RD_PORT
|
||||
- Device HTTP reachable on http://DEVOLO_IP
|
||||
"""
|
||||
try:
|
||||
r = requests.get(f"http://localhost:{RD_PORT}/json", timeout=2)
|
||||
if r.status_code >= 400:
|
||||
return False, f"devtools http {r.status_code}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"devtools error: {e}"
|
||||
|
||||
try:
|
||||
r = requests.get(f"http://{DEVOLO_IP}", timeout=3)
|
||||
if r.status_code >= 400:
|
||||
return False, f"device http {r.status_code}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"device error: {e}"
|
||||
|
||||
return True, "ok"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server()
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from prometheus_client import Info
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
|
||||
import requests
|
||||
import websocket
|
||||
from prometheus_client import CollectorRegistry, Gauge, generate_latest, CONTENT_TYPE_LATEST
|
||||
|
||||
|
||||
# Configuration via environment
|
||||
DEVOLO_IP = os.getenv("DEVOLO_IP")
|
||||
RD_PORT = int(os.getenv("RD_PORT", "9222"))
|
||||
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9100"))
|
||||
|
||||
if not DEVOLO_IP:
|
||||
print("DEVOLO_IP env var is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class DevoloScraper:
|
||||
def __init__(self, devolo_ip: str, rd_port: int) -> None:
|
||||
self.devolo_ip = devolo_ip
|
||||
self.rd_port = rd_port
|
||||
self.ws = None
|
||||
self.msg_id = 0
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _next_id(self) -> int:
|
||||
with self.lock:
|
||||
self.msg_id += 1
|
||||
return self.msg_id
|
||||
|
||||
def _connect(self) -> None:
|
||||
# Find Chrome DevTools WebSocket endpoint
|
||||
targets = requests.get(f"http://localhost:{self.rd_port}/json", timeout=5).json()
|
||||
if not targets:
|
||||
raise RuntimeError("No DevTools targets found")
|
||||
ws_url = targets[0]["webSocketDebuggerUrl"]
|
||||
self.ws = websocket.create_connection(ws_url, timeout=10)
|
||||
|
||||
# Enable Runtime domain
|
||||
self._send({"method": "Runtime.enable"})
|
||||
# Enable Page domain for navigation
|
||||
self._send({"method": "Page.enable"})
|
||||
|
||||
def _send(self, payload: dict) -> dict:
|
||||
if self.ws is None:
|
||||
self._connect()
|
||||
msg_id = self._next_id()
|
||||
payload = {"id": msg_id, **payload}
|
||||
self.ws.send(json.dumps(payload))
|
||||
return self._recv_until_id(msg_id)
|
||||
|
||||
def _recv_until_id(self, expected_id: int, timeout: int = 10) -> dict:
|
||||
self.ws.settimeout(timeout)
|
||||
while True:
|
||||
msg = json.loads(self.ws.recv())
|
||||
if "id" not in msg:
|
||||
# Log events but continue
|
||||
continue
|
||||
if msg["id"] == expected_id:
|
||||
return msg
|
||||
|
||||
def scrape(self) -> list[dict]:
|
||||
try:
|
||||
# Navigate to the Devolo page
|
||||
self._send({
|
||||
"method": "Page.navigate",
|
||||
"params": {"url": f"http://{self.devolo_ip}/#/powerline"},
|
||||
})
|
||||
time.sleep(2)
|
||||
|
||||
# Evaluate JavaScript to extract device data and return by value
|
||||
script = """
|
||||
Array.from(document.querySelectorAll('tr[id^="plc-"]')).map(tr => ({
|
||||
id: tr.querySelector('[id^="plc-id-"]')?.innerText?.trim(),
|
||||
mac: tr.querySelector('[id^="plc-mac-"]')?.innerText?.trim(),
|
||||
tx: tr.querySelector('[id^="plc-tx-"]')?.innerText?.trim(),
|
||||
rx: tr.querySelector('[id^="plc-rx-"]')?.innerText?.trim()
|
||||
}))
|
||||
"""
|
||||
resp = self._send({
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": script,
|
||||
"returnByValue": True,
|
||||
"awaitPromise": True,
|
||||
},
|
||||
})
|
||||
value = resp.get("result", {}).get("result", {}).get("value")
|
||||
if isinstance(value, list):
|
||||
# Normalize values
|
||||
out = []
|
||||
for it in value:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
dev_id = (it.get("id") or "").strip()
|
||||
if not dev_id or "(this device)" in dev_id.lower():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": (it.get("id") or "").strip(),
|
||||
"mac": (it.get("mac") or "").strip(),
|
||||
"tx": _to_float(it.get("tx")),
|
||||
"rx": _to_float(it.get("rx")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
return []
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Reset connection next time
|
||||
try:
|
||||
if self.ws is not None:
|
||||
self.ws.close()
|
||||
finally:
|
||||
self.ws = None
|
||||
print(f"Scrape error: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
def scrape_overview(self) -> dict:
|
||||
try:
|
||||
# Navigate to the Overview page
|
||||
self._send({
|
||||
"method": "Page.navigate",
|
||||
"params": {"url": f"http://{self.devolo_ip}/#/overview"},
|
||||
})
|
||||
time.sleep(2)
|
||||
|
||||
overview_js = """
|
||||
(() => {
|
||||
const byId = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return (el && (el.innerText || el.textContent) ? (el.innerText || el.textContent).trim() : "");
|
||||
};
|
||||
return {
|
||||
name: byId('system-board-name'),
|
||||
serial: byId('system-serial-number'),
|
||||
firmware: byId('system-firmware-version'),
|
||||
mac: byId('brlan-macaddress'),
|
||||
uptime: byId('system-timezone'),
|
||||
};
|
||||
})()
|
||||
"""
|
||||
resp = self._send({
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": overview_js,
|
||||
"returnByValue": True,
|
||||
"awaitPromise": True,
|
||||
},
|
||||
})
|
||||
value = resp.get("result", {}).get("result", {}).get("value")
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Overview scrape error: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
def close(self) -> None:
|
||||
if self.ws is not None:
|
||||
try:
|
||||
self.ws.close()
|
||||
finally:
|
||||
self.ws = None
|
||||
|
||||
|
||||
def _to_float(v):
|
||||
try:
|
||||
if v is None:
|
||||
return float("nan")
|
||||
s = str(v).strip().replace(",", ".")
|
||||
if s == "---" or s == "":
|
||||
return float("nan")
|
||||
return float(s)
|
||||
except Exception: # noqa: BLE001
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _parse_uptime_seconds(s: str) -> float:
|
||||
try:
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return float("nan")
|
||||
# Format examples: "66 days, 16:51:36" or "16:51:36"
|
||||
days = 0
|
||||
time_part = s
|
||||
if "," in s:
|
||||
day_part, time_part = [p.strip() for p in s.split(",", 1)]
|
||||
if "day" in day_part:
|
||||
try:
|
||||
days = int(day_part.split()[0])
|
||||
except Exception: # noqa: BLE001
|
||||
days = 0
|
||||
hms = time_part.split(":")
|
||||
if len(hms) != 3:
|
||||
return float("nan")
|
||||
h, m, sec = map(int, hms)
|
||||
return float(days * 86400 + h * 3600 + m * 60 + sec)
|
||||
except Exception: # noqa: BLE001
|
||||
return float("nan")
|
||||
|
||||
|
||||
|
||||
def _health_check() -> tuple[bool, str]:
|
||||
"""Quick health check for liveness/readiness.
|
||||
|
||||
Returns (ok, message). ok=False with a short message describing the first failure.
|
||||
Criteria:
|
||||
- Chromium DevTools endpoint reachable on localhost:RD_PORT
|
||||
- Device HTTP reachable on http://DEVOLO_IP
|
||||
"""
|
||||
# 1) Check Chromium DevTools
|
||||
try:
|
||||
r = requests.get(f"http://localhost:{RD_PORT}/json", timeout=2)
|
||||
if r.status_code >= 400:
|
||||
return False, f"devtools http {r.status_code}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"devtools error: {e}"
|
||||
|
||||
# 2) Check device reachability
|
||||
try:
|
||||
r = requests.get(f"http://{DEVOLO_IP}", timeout=3)
|
||||
if r.status_code >= 400:
|
||||
return False, f"device http {r.status_code}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"device error: {e}"
|
||||
|
||||
return True, "ok"
|
||||
class MetricsHandler(BaseHTTPRequestHandler):
|
||||
registry = CollectorRegistry()
|
||||
dev_tx = Gauge(
|
||||
"devolo_tx_mbps",
|
||||
"Devolo powerline TX throughput in Mbps",
|
||||
["device_id", "mac"],
|
||||
registry=registry,
|
||||
)
|
||||
dev_rx = Gauge(
|
||||
"devolo_rx_mbps",
|
||||
"Devolo powerline RX throughput in Mbps",
|
||||
["device_id", "mac"],
|
||||
registry=registry,
|
||||
)
|
||||
device_info = Info(
|
||||
"devolo_device_info",
|
||||
"Devolo device information",
|
||||
registry=registry,
|
||||
)
|
||||
uptime_seconds = Gauge(
|
||||
"devolo_uptime_seconds",
|
||||
"Devolo device uptime in seconds",
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
scraper: DevoloScraper | None = None
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/healthz":
|
||||
ok, msg = _health_check()
|
||||
status = 200 if ok else 503
|
||||
body = ("ok" if ok else msg).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
if self.path != "/metrics":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# Scrape fresh values on each scrape
|
||||
if MetricsHandler.scraper is not None:
|
||||
# Overview
|
||||
ov = MetricsHandler.scraper.scrape_overview()
|
||||
if ov:
|
||||
MetricsHandler.device_info.info(
|
||||
{
|
||||
"name": ov.get("name", ""),
|
||||
"serial": ov.get("serial", ""),
|
||||
"firmware": ov.get("firmware", ""),
|
||||
"mac": ov.get("mac", ""),
|
||||
}
|
||||
)
|
||||
MetricsHandler.uptime_seconds.set(_parse_uptime_seconds(ov.get("uptime", "")))
|
||||
|
||||
data = MetricsHandler.scraper.scrape()
|
||||
# Clear previous values by resetting series to NaN, then set current
|
||||
# Prometheus client does not provide a direct clear; we'll set current samples.
|
||||
for row in data:
|
||||
did = row.get("id", "")
|
||||
mac = row.get("mac", "")
|
||||
tx = row.get("tx")
|
||||
rx = row.get("rx")
|
||||
if tx is not None:
|
||||
MetricsHandler.dev_tx.labels(device_id=did, mac=mac).set(tx)
|
||||
if rx is not None:
|
||||
MetricsHandler.dev_rx.labels(device_id=did, mac=mac).set(rx)
|
||||
|
||||
output = generate_latest(MetricsHandler.registry)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", CONTENT_TYPE_LATEST)
|
||||
self.send_header("Content-Length", str(len(output)))
|
||||
self.end_headers()
|
||||
self.wfile.write(output)
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def run_server():
|
||||
scraper = DevoloScraper(DEVOLO_IP, RD_PORT)
|
||||
MetricsHandler.scraper = scraper
|
||||
|
||||
httpd = ThreadingHTTPServer(("0.0.0.0", EXPORTER_PORT), MetricsHandler)
|
||||
stop_event = threading.Event()
|
||||
|
||||
def handle_term(signum, frame): # noqa: ANN001, ARG001
|
||||
print("Shutting down...")
|
||||
# Call shutdown from another thread to avoid deadlock with serve_forever loop
|
||||
try:
|
||||
threading.Thread(target=httpd.shutdown, name="httpd-shutdown", daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_term)
|
||||
signal.signal(signal.SIGINT, handle_term)
|
||||
|
||||
print(f"Exporter listening on :{EXPORTER_PORT}, scraping Devolo at {DEVOLO_IP}, DevTools port {RD_PORT}")
|
||||
# Run server in background so shutdown() is invoked from another thread safely
|
||||
server_thread = threading.Thread(target=httpd.serve_forever, name="httpd-serve", daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Wait until a termination signal triggers shutdown
|
||||
stop_event.wait()
|
||||
|
||||
# Give the server thread a brief moment to exit
|
||||
server_thread.join(timeout=5)
|
||||
try:
|
||||
httpd.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
scraper.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
prometheus_client==0.23.1
|
||||
requests==2.32.3
|
||||
websocket-client==1.8.0
|
||||
Reference in New Issue
Block a user