GuestOps/deploy/monitor_status.py
2026-09-30 21:03:33 +01:00

190 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Write non-sensitive GuestOps host status for a read-only monitoring agent."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import socket
import ssl
import stat
import subprocess
import tempfile
import urllib.request
from urllib.parse import urlparse
BACKUP_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg")
MARKER_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg\.transferred\.json")
AUTH = ('const c=new Mongo("mongodb://127.0.0.1");'
'c.getDB("admin").auth(process.env.MONGO_INITDB_ROOT_USERNAME,'
'process.env.MONGO_INITDB_ROOT_PASSWORD);')
HEARTBEAT = ('const x=c.getDB("guestops").workerheartbeat.findOne({_id:"worker"});'
'print(JSON.stringify(x&&x.At?x.At:null));')
def require(condition: bool, message: str) -> None:
if not condition:
raise RuntimeError(message)
def run(args: list[str], root: Path, timeout: int = 30) -> bytes:
result = subprocess.run(args, cwd=root, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
require(result.returncode == 0, f"{Path(args[0]).name} probe failed.")
return result.stdout
def utc_now() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def age_seconds(path: Path, pattern: re.Pattern[str], now: dt.datetime) -> int | None:
if not path.is_dir() or path.is_symlink():
return None
times = [item.stat().st_mtime for item in path.iterdir()
if item.is_file() and not item.is_symlink() and pattern.fullmatch(item.name)]
return max(0, int(now.timestamp() - max(times))) if times else None
def https_status(origin: str, now: dt.datetime) -> dict[str, object]:
parsed = urlparse(origin)
require(parsed.scheme == "https" and parsed.hostname and parsed.port in (None, 443)
and parsed.path in ("", "/") and not parsed.query and not parsed.fragment
and parsed.username is None and parsed.password is None,
"GUESTOPS_ORIGIN must be an HTTPS origin without credentials or a path.")
context = ssl.create_default_context()
with socket.create_connection((parsed.hostname, 443), timeout=10) as connection:
with context.wrap_socket(connection, server_hostname=parsed.hostname) as secured:
certificate = secured.getpeercert()
expires = dt.datetime.strptime(certificate["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(
tzinfo=dt.timezone.utc)
with urllib.request.urlopen(origin.rstrip("/") + "/health/ready", timeout=15,
context=context) as response:
ready = response.status == 200 and json.load(response) == {"status": "ready"}
return {"ready": ready, "certificateDaysRemaining": max(0, int((expires - now).total_seconds() // 86400))}
def parse_compose(value: bytes) -> dict[str, dict[str, str]]:
text = value.decode("utf-8", "strict").strip()
if not text:
return {}
try:
parsed = json.loads(text)
rows = parsed if isinstance(parsed, list) else [parsed]
except json.JSONDecodeError:
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
result = {}
for row in rows:
if not isinstance(row, dict):
continue
service = str(row.get("Service", ""))
if service in {"api", "worker", "mongo"}:
result[service] = {
"state": str(row.get("State", "unknown")).lower(),
"health": str(row.get("Health", "none") or "none").lower(),
}
return result
def worker_heartbeat_age(root: Path, now: dt.datetime) -> int | None:
output = run(["docker", "compose", "exec", "-T", "mongo", "mongosh", "--quiet",
"--nodb", "--eval", AUTH + HEARTBEAT], root).decode("utf-8", "strict").strip()
value = json.loads(output)
if value is None:
return None
require(isinstance(value, str), "Worker heartbeat response was invalid.")
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
require(parsed.tzinfo is not None, "Worker heartbeat must contain a timezone.")
return max(0, int((now - parsed.astimezone(dt.timezone.utc)).total_seconds()))
def build_status(root: Path, backup_directory: Path, origin: str,
now: dt.datetime | None = None) -> tuple[dict[str, object], list[str]]:
moment = now or utc_now()
errors: list[str] = []
try:
https = https_status(origin, moment)
except Exception:
https = {"ready": False, "certificateDaysRemaining": None}
errors.append("https-probe-failed")
try:
containers = parse_compose(run(["docker", "compose", "ps", "--format", "json"], root))
if set(containers) != {"api", "worker", "mongo"}:
errors.append("container-set-incomplete")
except Exception:
containers = {}
errors.append("container-probe-failed")
try:
heartbeat_age = worker_heartbeat_age(root, moment)
if heartbeat_age is None:
errors.append("worker-heartbeat-missing")
except Exception:
heartbeat_age = None
errors.append("worker-heartbeat-probe-failed")
disk = shutil.disk_usage(root)
status = {
"schemaVersion": 1,
"generatedAt": moment.isoformat().replace("+00:00", "Z"),
"https": https,
"containers": containers,
"workerHeartbeatAgeSeconds": heartbeat_age,
"backupAgeSeconds": age_seconds(backup_directory, BACKUP_NAME, moment),
"verifiedTransferAgeSeconds": age_seconds(backup_directory, MARKER_NAME, moment),
"diskFreePercent": round(disk.free * 100 / disk.total, 2),
"persistentJournal": Path("/var/log/journal").is_dir(),
"errors": sorted(set(errors)),
}
return status, errors
def write_status(path: Path, status: dict[str, object]) -> None:
require(path.is_absolute() and not path.is_symlink(),
"Status output must be an absolute, non-symlink path.")
parent = path.parent.resolve()
require(parent.is_dir() and not path.parent.is_symlink(), "Status output directory must exist.")
fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(status, stream, sort_keys=True, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--backup-directory", type=Path, default=Path("/var/backups/guestops"))
parser.add_argument("--origin", default="https://sandbox-guestops.futuresens.co.uk")
parser.add_argument("--output", type=Path, default=Path("/run/guestops-monitor/status.json"))
args = parser.parse_args()
root = args.root.resolve()
require(root.is_dir(), "GuestOps root must exist.")
backup_directory = args.backup_directory.resolve()
status, errors = build_status(root, backup_directory, args.origin)
write_status(args.output, status)
print("GuestOps monitoring status updated." if not errors
else "GuestOps monitoring status updated with failed probes.")
raise SystemExit(1 if errors else 0)
if __name__ == "__main__":
try:
main()
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as error:
print(f"GuestOps monitoring probe failed: {error}", file=__import__("sys").stderr)
raise SystemExit(1)