Files
ServerMonitor/monitor.py
T
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

297 lines
9.6 KiB
Python

"""ServerMonitor main loop.
Runs periodic health checks and sends Telegram alerts on failures, reminders
while services stay down, and recovery messages when they come back.
"""
import argparse
import json
import logging
import os
import signal
import sys
import threading
import time
from urllib.parse import urlparse, urlunparse
from checks import http_check, netdata_alarms, netdata_metrics, ping_check, tcp_check
from notify import AlertEngine, StateStore, escape_html, send_telegram
logger = logging.getLogger("servermonitor.monitor")
shutdown_event = threading.Event()
def load_config(path):
"""Load the JSON configuration file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _replace_host_in_url(url, new_host):
"""Replace the netloc host in a URL without changing the port or path."""
parsed = urlparse(url)
# Preserve user:pass and port; replace only the hostname.
netloc = new_host
if parsed.port is not None:
netloc = f"{new_host}:{parsed.port}"
if parsed.username is not None:
prefix = f"{parsed.username}"
if parsed.password is not None:
prefix += f":{parsed.password}"
netloc = f"{prefix}@{netloc}"
return urlunparse(parsed._replace(netloc=netloc))
def apply_env_overrides(config):
"""Override config values from environment variables."""
new_host = None
if "HOST" in os.environ:
new_host = os.environ["HOST"]
config["host"] = new_host
if "CHECK_INTERVAL_SEC" in os.environ:
try:
config["check_interval_sec"] = int(os.environ["CHECK_INTERVAL_SEC"])
except ValueError:
logger.warning("Invalid CHECK_INTERVAL_SEC, using config value")
if "TELEGRAM_BOT_TOKEN" in os.environ:
config["telegram"]["bot_token"] = os.environ["TELEGRAM_BOT_TOKEN"]
if "TELEGRAM_CHAT_ID" in os.environ:
config["telegram"]["chat_id"] = os.environ["TELEGRAM_CHAT_ID"]
if "STATE_FILE" in os.environ:
config["state_file"] = os.environ["STATE_FILE"]
if new_host:
for check in config.get("http_checks", []):
check["url"] = _replace_host_in_url(check.get("url", ""), new_host)
for check in config.get("tcp_checks", []):
check["host"] = new_host
netdata_cfg = config.get("netdata", {})
if netdata_cfg.get("base_url"):
netdata_cfg["base_url"] = _replace_host_in_url(netdata_cfg["base_url"], new_host)
def setup_logging():
"""Configure logging to stdout."""
logging.basicConfig(
stream=sys.stdout,
format="%(asctime)s %(levelname)s %(message)s",
level=logging.INFO,
)
def shutdown_handler(signum, frame):
"""Handle SIGTERM/SIGINT gracefully."""
logger.info("shutting down")
shutdown_event.set()
def run_checks(config):
"""Run every configured check and return a list of results.
Each result is a tuple ``(name, ok, message)``.
"""
host = config["host"]
timeout = config.get("timeout_sec", 5)
results = []
if config.get("ping", {}).get("enabled", True):
ok, msg = ping_check(host, timeout)
results.append(("ping", ok, msg))
for check in config.get("http_checks", []):
ok, msg = http_check(
check["name"],
check["url"],
check.get("expected", [200]),
timeout,
)
results.append((check["name"], ok, msg))
for check in config.get("tcp_checks", []):
ok, msg = tcp_check(
check["name"],
check.get("host", host),
check["port"],
timeout,
)
results.append((check["name"], ok, msg))
netdata_cfg = config.get("netdata", {})
base_url = netdata_cfg.get("base_url", f"http://{host}:19999")
if netdata_cfg.get("check_alarms", True):
ok, msg = netdata_alarms(base_url, timeout)
results.append(("netdata_alarms", ok, msg))
if netdata_cfg.get("check_metrics", True):
metrics = netdata_metrics(base_url, timeout)
thresholds = config.get("thresholds", {})
cpu = metrics.get("cpu_percent")
cpu_threshold = thresholds.get("cpu_percent", 90)
if cpu is not None and cpu > cpu_threshold:
results.append((
"CPU",
False,
f"CPU {cpu:.1f}% (threshold {cpu_threshold}%)",
))
elif cpu is not None:
results.append(("CPU", True, f"CPU {cpu:.1f}%"))
ram_used = metrics.get("ram_used_mb")
ram_total = metrics.get("ram_total_mb")
ram_threshold = thresholds.get("ram_avail_mb", 2048)
if ram_used is not None and ram_total is not None:
ram_avail = ram_total - ram_used
if ram_avail < ram_threshold:
results.append((
"RAM",
False,
f"RAM available {ram_avail:.0f}MB (threshold {ram_threshold}MB)",
))
else:
results.append(("RAM", True, f"RAM available {ram_avail:.0f}MB"))
disk = metrics.get("disk_used_percent")
disk_threshold = thresholds.get("disk_percent", 90)
if disk is not None and disk > disk_threshold:
results.append((
"Disk",
False,
f"Disk {disk:.1f}% used (threshold {disk_threshold}%)",
))
elif disk is not None:
results.append(("Disk", True, f"Disk {disk:.1f}% used"))
return results
def format_message(host, name, ok, message, is_recovery, alert_count):
"""Format a Telegram HTML alert message."""
safe_name = escape_html(name)
safe_message = escape_html(message)
safe_host = escape_html(host)
if is_recovery:
header = f"✅ <b>Recovered</b> · {safe_host}"
body = f"🟢 <b>{safe_name}</b>: {safe_message}"
return f"{header}\n\n{body}"
if ok:
# Should not normally alert on OK, but handle it gracefully.
header = f"✅ <b>Recovered</b> · {safe_host}"
body = f"🟢 <b>{safe_name}</b>: {safe_message}"
return f"{header}\n\n{body}"
if name == "netdata_alarms":
header = f"⚠️ <b>Server alert</b> · {safe_host}"
body = f"🔴 <b>Netdata alarms</b>: {safe_message}"
return f"{header}\n\n{body}"
if alert_count > 1:
header = f"⏰ <b>Still down</b> · {safe_host}"
body = f"🔴 <b>{safe_name}</b>: {safe_message}"
return f"{header}\n\n{body}"
header = f"⚠️ <b>Server alert</b> · {safe_host}"
body = f"🔴 <b>{safe_name}</b>: {safe_message}"
return f"{header}\n\n{body}"
def main_loop(config, state_store, alert_engine, once=False):
"""Run the monitoring loop until shutdown or a single cycle if ``once``."""
reminder_interval = config.get("reminder_interval_sec", 1800)
host = config["host"]
bot_token = config["telegram"].get("bot_token", "")
chat_id = config["telegram"].get("chat_id", "")
timeout = config.get("timeout_sec", 5)
baseline = not os.path.exists(state_store.path)
while not shutdown_event.is_set():
now = time.time()
results = run_checks(config)
for name, ok, message in results:
logger.info("%s %s (%s)", name, "OK" if ok else "FAIL", message)
current_state = "OK" if ok else "CRIT"
alert_engine.set_state(name, current_state)
should_send, is_recovery = alert_engine.should_alert(
name, now, state_store, reminder_interval
)
if baseline:
# First run with no state file: establish baseline, record state,
# do not alert. alert_count stays 0 so the next real failure is
# treated as the first alert.
state_store.set(name, current_state, 0.0, 0)
continue
if should_send:
state_entry = state_store.get(name)
alert_count = state_entry["alert_count"]
last_alert_ts = state_entry["last_alert_ts"]
text = format_message(
host,
name,
ok,
message,
is_recovery,
alert_count,
)
logger.warning("Alert: %s", text.replace("\n", " "))
send_telegram(bot_token, chat_id, text, timeout)
baseline = False
state_store.save()
if once:
break
try:
shutdown_event.wait(config.get("check_interval_sec", 60))
except Exception: # noqa: BLE001
break
def main():
parser = argparse.ArgumentParser(description="ServerMonitor")
parser.add_argument("--once", action="store_true", help="Run one cycle and exit")
args = parser.parse_args()
setup_logging()
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.environ.get("CONFIG_FILE", os.path.join(script_dir, "config.json"))
try:
config = load_config(config_path)
except Exception as exc: # noqa: BLE001
logger.error("Failed to load config from %s: %s", config_path, exc)
sys.exit(1)
apply_env_overrides(config)
state_store = StateStore(config.get("state_file", "/data/state.json"))
state_store.load()
alert_engine = AlertEngine()
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
try:
main_loop(config, state_store, alert_engine, once=args.once)
except Exception as exc: # noqa: BLE001
logger.exception("Unexpected error in main loop: %s", exc)
finally:
state_store.save()
logger.info("shutting down")
if __name__ == "__main__":
main()