- Pure stdlib Python (3.12), runs on python:3.12-alpine as non-root - Checks: ping, HTTP (expected codes), TCP ports, Netdata alarms + metrics - Netdata v2 compatible: system.cpu (no idle dim), system.ram, disk_space.* discovery - AlertEngine dedup: first alert, reminders, recovery (only after real alert) - Baseline first-run (no alert storm on deploy), atomic state file, --once mode - 20 unit tests passing; verified live against 192.168.0.5
174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
"""Notification, persistence, and alert deduplication logic."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
logger = logging.getLogger("servermonitor.notify")
|
|
|
|
|
|
def escape_html(text):
|
|
"""Escape ``&``, ``<`` and ``>`` for Telegram ``parse_mode=HTML``."""
|
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def send_telegram(bot_token, chat_id, text, timeout):
|
|
"""Send an HTML message via Telegram.
|
|
|
|
If ``bot_token`` is empty the function runs in log-only mode: it logs the
|
|
would-be message and returns ``True``.
|
|
"""
|
|
if not bot_token:
|
|
logger.info("[TELEGRAM LOG-ONLY] %s", text)
|
|
return True
|
|
|
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
data = urllib.parse.urlencode(
|
|
{
|
|
"chat_id": chat_id,
|
|
"text": text,
|
|
"parse_mode": "HTML",
|
|
"disable_web_page_preview": "true",
|
|
}
|
|
).encode("utf-8")
|
|
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
method="POST",
|
|
headers={
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "ServerMonitor/1.0",
|
|
},
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
response.read()
|
|
return True
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Failed to send Telegram message: %s", exc)
|
|
return False
|
|
|
|
|
|
class StateStore:
|
|
"""JSON file persistence for check/alert state.
|
|
|
|
The file is written atomically: a temporary file is created in the same
|
|
directory and then moved into place. If the existing file is corrupt, a
|
|
fresh empty state is returned.
|
|
"""
|
|
|
|
def __init__(self, path="/data/state.json"):
|
|
self.path = path
|
|
self._state = {}
|
|
|
|
def load(self):
|
|
"""Load state from disk."""
|
|
if not os.path.exists(self.path):
|
|
self._state = {}
|
|
return
|
|
|
|
try:
|
|
with open(self.path, "r", encoding="utf-8") as f:
|
|
self._state = json.load(f)
|
|
if not isinstance(self._state, dict):
|
|
self._state = {}
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("State file %s corrupt or unreadable: %s. Starting fresh.", self.path, exc)
|
|
self._state = {}
|
|
|
|
def save(self):
|
|
"""Persist state to disk atomically."""
|
|
directory = os.path.dirname(os.path.abspath(self.path)) or "."
|
|
try:
|
|
os.makedirs(directory, exist_ok=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Could not create state directory %s: %s", directory, exc)
|
|
return
|
|
|
|
try:
|
|
fd, tmp_path = tempfile.mkstemp(
|
|
dir=directory, prefix="state", suffix=".json.tmp"
|
|
)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(self._state, f, indent=2)
|
|
f.write("\n")
|
|
os.replace(tmp_path, self.path)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Could not save state file %s: %s", self.path, exc)
|
|
|
|
def get(self, name):
|
|
"""Return the stored state for ``name`` or a default OK state."""
|
|
entry = self._state.get(name)
|
|
if not isinstance(entry, dict):
|
|
return {"state": "OK", "last_alert_ts": 0.0, "alert_count": 0}
|
|
return {
|
|
"state": entry.get("state", "OK"),
|
|
"last_alert_ts": float(entry.get("last_alert_ts", 0.0)),
|
|
"alert_count": int(entry.get("alert_count", 0)),
|
|
}
|
|
|
|
def set(self, name, state, last_alert_ts, alert_count):
|
|
"""Set the stored state for ``name``."""
|
|
self._state[name] = {
|
|
"state": state,
|
|
"last_alert_ts": float(last_alert_ts),
|
|
"alert_count": int(alert_count),
|
|
}
|
|
|
|
|
|
class AlertEngine:
|
|
"""Decide whether a check should trigger a Telegram alert.
|
|
|
|
- First CRIT transition: alert.
|
|
- Repeated CRIT inside the reminder window: suppress.
|
|
- Repeated CRIT after reminder window: reminder alert.
|
|
- OK transition after CRIT: recovery alert (exactly once).
|
|
|
|
The caller should set the current observed state with ``set_state`` before
|
|
calling ``should_alert``.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._current_states = {}
|
|
|
|
def set_state(self, name, state):
|
|
"""Set the freshly observed state for ``name`` (``OK`` or ``CRIT``)."""
|
|
self._current_states[name] = state
|
|
|
|
def should_alert(self, name, now, state_store, reminder_interval):
|
|
"""Return ``(should_send, is_recovery)``.
|
|
|
|
``state_store`` is updated in place when an alert decision is made.
|
|
"""
|
|
current_state = self._current_states.get(name, "OK")
|
|
prev = state_store.get(name)
|
|
prev_state = prev["state"]
|
|
last_alert_ts = prev["last_alert_ts"]
|
|
alert_count = prev["alert_count"]
|
|
|
|
if current_state == "CRIT":
|
|
if prev_state != "CRIT":
|
|
state_store.set(name, "CRIT", now, alert_count + 1)
|
|
return True, False
|
|
if now - last_alert_ts >= reminder_interval:
|
|
state_store.set(name, "CRIT", now, alert_count + 1)
|
|
return True, False
|
|
return False, False
|
|
|
|
if current_state == "OK" and prev_state == "CRIT":
|
|
state_store.set(name, "OK", last_alert_ts, alert_count)
|
|
# Only send a recovery message if an alert was actually sent for
|
|
# this check before (e.g. not after a baseline run that observed
|
|
# the failure without alerting).
|
|
if alert_count > 0:
|
|
return True, True
|
|
return False, False
|
|
|
|
return False, False
|