Files
Sanders 465b27d673 ServerMonitor: Dockerized health-check service with Telegram alerts
- 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
2026-08-05 22:28:20 +03:00

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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