From 5ad60024c03620dc05a7a11c41a0ffd3882ce01e Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 28 Aug 2025 10:33:55 -0400 Subject: [PATCH 1/7] Add health check API blueprint --- opentakserver/blueprints/ots_api/__init__.py | 2 ++ .../blueprints/ots_api/health_api.py | 26 +++++++++++++++++++ tests/test_health_api.py | 10 +++++++ 3 files changed, 38 insertions(+) create mode 100644 opentakserver/blueprints/ots_api/health_api.py create mode 100644 tests/test_health_api.py diff --git a/opentakserver/blueprints/ots_api/__init__.py b/opentakserver/blueprints/ots_api/__init__.py index d142d7fd..44dc6ed9 100644 --- a/opentakserver/blueprints/ots_api/__init__.py +++ b/opentakserver/blueprints/ots_api/__init__.py @@ -15,6 +15,7 @@ from opentakserver.blueprints.ots_api.group_api import group_api from opentakserver.blueprints.ots_api.eud_stats_api import eud_stats_blueprint from opentakserver.blueprints.ots_api.plugin_api import plugin_blueprint +from opentakserver.blueprints.ots_api.health_api import health_api ots_api = Blueprint("ots_api", __name__) ots_api.register_blueprint(api_blueprint) @@ -33,3 +34,4 @@ ots_api.register_blueprint(eud_stats_blueprint) ots_api.register_blueprint(plugin_blueprint) ots_api.register_blueprint(token_api_blueprint) +ots_api.register_blueprint(health_api) diff --git a/opentakserver/blueprints/ots_api/health_api.py b/opentakserver/blueprints/ots_api/health_api.py new file mode 100644 index 00000000..af124c3a --- /dev/null +++ b/opentakserver/blueprints/ots_api/health_api.py @@ -0,0 +1,26 @@ +from flask import Blueprint, jsonify +from flask_security import auth_required + +# Blueprint for health endpoints +health_api = Blueprint("health_api", __name__) + + +@health_api.route("/api/health/ots") +@auth_required() +def health_ots(): + """Placeholder health check for OTS.""" + return jsonify({"status": "ok"}) + + +@health_api.route("/api/health/cot") +@auth_required() +def health_cot(): + """Placeholder health check for CoT.""" + return jsonify({"status": "ok"}) + + +@health_api.route("/api/health/eud") +@auth_required() +def health_eud(): + """Placeholder health check for EUD.""" + return jsonify({"status": "ok"}) diff --git a/tests/test_health_api.py b/tests/test_health_api.py new file mode 100644 index 00000000..a5583407 --- /dev/null +++ b/tests/test_health_api.py @@ -0,0 +1,10 @@ +def test_health_endpoints(auth): + for endpoint in ("ots", "cot", "eud"): + response = auth.get(f"/api/health/{endpoint}") + assert response.status_code == 200 + + +def test_health_requires_auth(client): + for endpoint in ("ots", "cot", "eud"): + response = client.get(f"/api/health/{endpoint}") + assert response.status_code in (401, 302) From 8866c8a7d3551208c2d693d9a34510f0b7c13d3c Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 28 Aug 2025 10:57:13 -0400 Subject: [PATCH 2/7] Add CoT parser health check --- .../blueprints/ots_api/health_api.py | 29 +++++- opentakserver/health/__init__.py | 2 + opentakserver/health/cot_parser.py | 98 +++++++++++++++++++ tests/test_health_api.py | 30 +++++- 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 opentakserver/health/__init__.py create mode 100644 opentakserver/health/cot_parser.py diff --git a/opentakserver/blueprints/ots_api/health_api.py b/opentakserver/blueprints/ots_api/health_api.py index af124c3a..9738dcbf 100644 --- a/opentakserver/blueprints/ots_api/health_api.py +++ b/opentakserver/blueprints/ots_api/health_api.py @@ -1,6 +1,15 @@ -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, request from flask_security import auth_required +from opentakserver.health.cot_parser import ( + compute_status, + find_errors, + query_systemd, + rabbitmq_check, + tail_log, + current_timestamp, +) + # Blueprint for health endpoints health_api = Blueprint("health_api", __name__) @@ -15,8 +24,21 @@ def health_ots(): @health_api.route("/api/health/cot") @auth_required() def health_cot(): - """Placeholder health check for CoT.""" - return jsonify({"status": "ok"}) + """Health check for the CoT parser service.""" + service_state = query_systemd() + log_lines = tail_log() + log_errors = find_errors(log_lines) + rabbit_ok = rabbitmq_check() + + status = compute_status(service_state, log_errors, rabbit_ok) + status["timestamp"] = current_timestamp() + + strict = request.args.get("strict", "false").lower() == "true" + code = 200 + if strict and status["overall"] != "healthy": + code = 503 + + return jsonify(status), code @health_api.route("/api/health/eud") @@ -24,3 +46,4 @@ def health_cot(): def health_eud(): """Placeholder health check for EUD.""" return jsonify({"status": "ok"}) + diff --git a/opentakserver/health/__init__.py b/opentakserver/health/__init__.py new file mode 100644 index 00000000..b48ae5e7 --- /dev/null +++ b/opentakserver/health/__init__.py @@ -0,0 +1,2 @@ +"""Health utilities for OpenTAKServer.""" + diff --git a/opentakserver/health/cot_parser.py b/opentakserver/health/cot_parser.py new file mode 100644 index 00000000..3cb23832 --- /dev/null +++ b/opentakserver/health/cot_parser.py @@ -0,0 +1,98 @@ +import os +import re +import socket +import subprocess +from datetime import datetime, timezone +from typing import Iterable, List + +COT_PARSER_SERVICE = os.getenv("COT_PARSER_SERVICE", "cot-parser.service") +COT_PARSER_LOG = os.getenv("COT_PARSER_LOG", "/var/log/cot-parser.log") +RABBIT_HOST = os.getenv("RABBIT_HOST", "localhost") +RABBIT_PORT = int(os.getenv("RABBIT_PORT", "5672")) +ERROR_PATTERN = os.getenv("COT_PARSER_ERROR_REGEX", r"(ERROR|Exception|Traceback)") +ERROR_REGEX = re.compile(ERROR_PATTERN, re.IGNORECASE) + + +def query_systemd(service: str = COT_PARSER_SERVICE) -> str: + """Return the ActiveState for a systemd service. + + If systemctl is unavailable or an error occurs, a description of the + error is returned instead of raising an exception. + """ + try: + completed = subprocess.run( + [ + "systemctl", + "show", + service, + "--property=ActiveState", + "--value", + ], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + except Exception as exc: # pragma: no cover - exercised via tests + return f"error: {exc}" # noqa: TRY002 + + +def tail_log(path: str = COT_PARSER_LOG, lines: int = 100) -> List[str]: + """Return the last ``lines`` from the log file.""" + try: + with open(path, "rb") as fh: + fh.seek(0, os.SEEK_END) + size = fh.tell() + block = 1024 + data = bytearray() + while size > 0 and data.count(b"\n") <= lines: + step = min(block, size) + size -= step + fh.seek(size) + data = fh.read(step) + data + return data.decode(errors="ignore").splitlines()[-lines:] + except OSError as exc: # pragma: no cover - exercised via tests + return [f"error: {exc}"] + + +def find_errors(lines: Iterable[str]) -> List[str]: + """Filter log lines that match ``ERROR_REGEX``.""" + return [line for line in lines if ERROR_REGEX.search(line)] + + +def rabbitmq_check(host: str = RABBIT_HOST, port: int = RABBIT_PORT, timeout: float = 1.0) -> bool: + """Attempt a TCP connection to RabbitMQ and return whether it succeeded.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: # pragma: no cover - exercised via tests + return False + + +def compute_status(service_state: str, log_errors: List[str], rabbitmq_ok: bool) -> dict: + """Compute component and overall health status.""" + components = { + "service": service_state, + "logs": "errors" if log_errors else "ok", + "rabbitmq": "up" if rabbitmq_ok else "down", + } + problems: List[str] = [] + if service_state != "active": + problems.append("cot-parser service inactive") + if log_errors: + problems.append("errors detected in log") + if not rabbitmq_ok: + problems.append("rabbitmq unreachable") + + overall = "healthy" if not problems else "unhealthy" + return { + "overall": overall, + "components": components, + "problems": problems, + } + + +def current_timestamp() -> str: + """Return an ISO 8601 UTC timestamp.""" + return datetime.now(timezone.utc).isoformat() + diff --git a/tests/test_health_api.py b/tests/test_health_api.py index a5583407..1347f3ed 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -1,9 +1,37 @@ +from unittest.mock import patch + + def test_health_endpoints(auth): - for endpoint in ("ots", "cot", "eud"): + for endpoint in ("ots", "eud"): response = auth.get(f"/api/health/{endpoint}") assert response.status_code == 200 +def test_health_cot_healthy(auth): + with patch("opentakserver.health.cot_parser.query_systemd", return_value="active"), \ + patch("opentakserver.health.cot_parser.tail_log", return_value=["all good"]), \ + patch("opentakserver.health.cot_parser.find_errors", return_value=[]), \ + patch("opentakserver.health.cot_parser.rabbitmq_check", return_value=True): + response = auth.get("/api/health/cot") + assert response.status_code == 200 + data = response.json + assert data["overall"] == "healthy" + assert data["problems"] == [] + assert "timestamp" in data + + +def test_health_cot_unhealthy_strict(auth): + with patch("opentakserver.health.cot_parser.query_systemd", return_value="inactive"), \ + patch("opentakserver.health.cot_parser.tail_log", return_value=["error"]), \ + patch("opentakserver.health.cot_parser.find_errors", return_value=["error"]), \ + patch("opentakserver.health.cot_parser.rabbitmq_check", return_value=False): + response = auth.get("/api/health/cot?strict=true") + assert response.status_code == 503 + data = response.json + assert data["overall"] == "unhealthy" + assert data["problems"] + + def test_health_requires_auth(client): for endpoint in ("ots", "cot", "eud"): response = client.get(f"/api/health/{endpoint}") From 82c0eb60c73b6ed43ba457bcfa3cea0de341cd60 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 28 Aug 2025 12:25:39 -0400 Subject: [PATCH 3/7] Tail cot_parser entries from OTS log --- opentakserver/blueprints/ots_api/health_api.py | 4 ++-- opentakserver/health/cot_parser.py | 17 +++++++++++++---- tests/test_health_api.py | 10 ++++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/opentakserver/blueprints/ots_api/health_api.py b/opentakserver/blueprints/ots_api/health_api.py index 9738dcbf..7795a758 100644 --- a/opentakserver/blueprints/ots_api/health_api.py +++ b/opentakserver/blueprints/ots_api/health_api.py @@ -6,7 +6,7 @@ find_errors, query_systemd, rabbitmq_check, - tail_log, + tail_ots_log_for_cot_parser_entries, current_timestamp, ) @@ -26,7 +26,7 @@ def health_ots(): def health_cot(): """Health check for the CoT parser service.""" service_state = query_systemd() - log_lines = tail_log() + log_lines = tail_ots_log_for_cot_parser_entries() log_errors = find_errors(log_lines) rabbit_ok = rabbitmq_check() diff --git a/opentakserver/health/cot_parser.py b/opentakserver/health/cot_parser.py index 3cb23832..8c44a4fa 100644 --- a/opentakserver/health/cot_parser.py +++ b/opentakserver/health/cot_parser.py @@ -3,14 +3,20 @@ import socket import subprocess from datetime import datetime, timezone +from pathlib import Path from typing import Iterable, List COT_PARSER_SERVICE = os.getenv("COT_PARSER_SERVICE", "cot-parser.service") -COT_PARSER_LOG = os.getenv("COT_PARSER_LOG", "/var/log/cot-parser.log") +OTS_DATA_FOLDER = os.getenv("OTS_DATA_FOLDER", os.path.join(Path.home(), "ots")) +COT_PARSER_LOG = os.getenv( + "COT_PARSER_LOG", + os.path.join(OTS_DATA_FOLDER, "logs", "opentakserver.log"), +) RABBIT_HOST = os.getenv("RABBIT_HOST", "localhost") RABBIT_PORT = int(os.getenv("RABBIT_PORT", "5672")) ERROR_PATTERN = os.getenv("COT_PARSER_ERROR_REGEX", r"(ERROR|Exception|Traceback)") ERROR_REGEX = re.compile(ERROR_PATTERN, re.IGNORECASE) +LOG_TAG = "cot_parser" def query_systemd(service: str = COT_PARSER_SERVICE) -> str: @@ -37,8 +43,10 @@ def query_systemd(service: str = COT_PARSER_SERVICE) -> str: return f"error: {exc}" # noqa: TRY002 -def tail_log(path: str = COT_PARSER_LOG, lines: int = 100) -> List[str]: - """Return the last ``lines`` from the log file.""" +def tail_ots_log_for_cot_parser_entries( + path: str = COT_PARSER_LOG, lines: int = 100, tag: str = LOG_TAG +) -> List[str]: + """Return the last ``lines`` from the OTS log produced by ``cot_parser``.""" try: with open(path, "rb") as fh: fh.seek(0, os.SEEK_END) @@ -50,7 +58,8 @@ def tail_log(path: str = COT_PARSER_LOG, lines: int = 100) -> List[str]: size -= step fh.seek(size) data = fh.read(step) + data - return data.decode(errors="ignore").splitlines()[-lines:] + log_lines = data.decode(errors="ignore").splitlines() + return [line for line in log_lines if tag in line][-lines:] except OSError as exc: # pragma: no cover - exercised via tests return [f"error: {exc}"] diff --git a/tests/test_health_api.py b/tests/test_health_api.py index 1347f3ed..a4c40bbf 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -9,7 +9,10 @@ def test_health_endpoints(auth): def test_health_cot_healthy(auth): with patch("opentakserver.health.cot_parser.query_systemd", return_value="active"), \ - patch("opentakserver.health.cot_parser.tail_log", return_value=["all good"]), \ + patch( + "opentakserver.health.cot_parser.tail_ots_log_for_cot_parser_entries", + return_value=["all good"], + ), \ patch("opentakserver.health.cot_parser.find_errors", return_value=[]), \ patch("opentakserver.health.cot_parser.rabbitmq_check", return_value=True): response = auth.get("/api/health/cot") @@ -22,7 +25,10 @@ def test_health_cot_healthy(auth): def test_health_cot_unhealthy_strict(auth): with patch("opentakserver.health.cot_parser.query_systemd", return_value="inactive"), \ - patch("opentakserver.health.cot_parser.tail_log", return_value=["error"]), \ + patch( + "opentakserver.health.cot_parser.tail_ots_log_for_cot_parser_entries", + return_value=["error"], + ), \ patch("opentakserver.health.cot_parser.find_errors", return_value=["error"]), \ patch("opentakserver.health.cot_parser.rabbitmq_check", return_value=False): response = auth.get("/api/health/cot?strict=true") From 99dbdb0222fdc389dc1034a42f4da7b790497f9f Mon Sep 17 00:00:00 2001 From: Dan Shevenell Date: Thu, 28 Aug 2025 12:29:15 -0400 Subject: [PATCH 4/7] fix service definition --- opentakserver/health/cot_parser.py | 35 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/opentakserver/health/cot_parser.py b/opentakserver/health/cot_parser.py index 8c44a4fa..fab234ed 100644 --- a/opentakserver/health/cot_parser.py +++ b/opentakserver/health/cot_parser.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Iterable, List -COT_PARSER_SERVICE = os.getenv("COT_PARSER_SERVICE", "cot-parser.service") +COT_PARSER_SERVICE = os.getenv("COT_PARSER_SERVICE", "cot_parser.service") OTS_DATA_FOLDER = os.getenv("OTS_DATA_FOLDER", os.path.join(Path.home(), "ots")) COT_PARSER_LOG = os.getenv( "COT_PARSER_LOG", @@ -20,27 +20,34 @@ def query_systemd(service: str = COT_PARSER_SERVICE) -> str: - """Return the ActiveState for a systemd service. - - If systemctl is unavailable or an error occurs, a description of the - error is returned instead of raising an exception. """ + Returns one of: active, inactive, failed, activating, deactivating, reloading, unknown + """ + # First try: is-active (simplest, stable output) + try: + completed = subprocess.run( + ["systemctl", "is-active", service], + check=False, + capture_output=True, + text=True, + ) + state = completed.stdout.strip() + if state: # active/inactive/failed/... + return state + except Exception: + pass + + # Fallback: show ActiveState try: completed = subprocess.run( - [ - "systemctl", - "show", - service, - "--property=ActiveState", - "--value", - ], + ["systemctl", "show", service, "--property=ActiveState", "--value"], check=True, capture_output=True, text=True, ) return completed.stdout.strip() - except Exception as exc: # pragma: no cover - exercised via tests - return f"error: {exc}" # noqa: TRY002 + except Exception as exc: + return f"error: {exc}" def tail_ots_log_for_cot_parser_entries( From a79d2e8c41419288581e4e0826e4da0214f5ed3b Mon Sep 17 00:00:00 2001 From: Dan Shevenell Date: Thu, 28 Aug 2025 12:38:05 -0400 Subject: [PATCH 5/7] improve user-facing status terminology --- opentakserver/health/cot_parser.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/opentakserver/health/cot_parser.py b/opentakserver/health/cot_parser.py index fab234ed..ff0a0ec2 100644 --- a/opentakserver/health/cot_parser.py +++ b/opentakserver/health/cot_parser.py @@ -89,18 +89,26 @@ def compute_status(service_state: str, log_errors: List[str], rabbitmq_ok: bool) """Compute component and overall health status.""" components = { "service": service_state, - "logs": "errors" if log_errors else "ok", + "logs": "errors" if log_errors else "error-free", "rabbitmq": "up" if rabbitmq_ok else "down", } problems: List[str] = [] if service_state != "active": - problems.append("cot-parser service inactive") + problems.append("cot_parser service inactive") if log_errors: problems.append("errors detected in log") if not rabbitmq_ok: problems.append("rabbitmq unreachable") - overall = "healthy" if not problems else "unhealthy" + # Determine operational status + overall = "non-operational" + if rabbitmq_ok and service_state == "active": + overall = "operational-" + if log_errors: + overall += "errors" + else: + overall += "healthy" + return { "overall": overall, "components": components, From 35ab039d74a90a842e25a02bc5eaa0556d3d76b0 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 28 Aug 2025 13:01:13 -0400 Subject: [PATCH 6/7] Add EUD health handler and API endpoint tests --- .../blueprints/ots_api/health_api.py | 38 +++--- opentakserver/health/eud_handler.py | 119 ++++++++++++++++++ tests/test_health_api.py | 31 +++++ 3 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 opentakserver/health/eud_handler.py diff --git a/opentakserver/blueprints/ots_api/health_api.py b/opentakserver/blueprints/ots_api/health_api.py index 7795a758..2c69b7cc 100644 --- a/opentakserver/blueprints/ots_api/health_api.py +++ b/opentakserver/blueprints/ots_api/health_api.py @@ -1,14 +1,7 @@ from flask import Blueprint, jsonify, request from flask_security import auth_required -from opentakserver.health.cot_parser import ( - compute_status, - find_errors, - query_systemd, - rabbitmq_check, - tail_ots_log_for_cot_parser_entries, - current_timestamp, -) +from opentakserver.health import cot_parser, eud_handler # Blueprint for health endpoints health_api = Blueprint("health_api", __name__) @@ -25,13 +18,13 @@ def health_ots(): @auth_required() def health_cot(): """Health check for the CoT parser service.""" - service_state = query_systemd() - log_lines = tail_ots_log_for_cot_parser_entries() - log_errors = find_errors(log_lines) - rabbit_ok = rabbitmq_check() + service_state = cot_parser.query_systemd() + log_lines = cot_parser.tail_ots_log_for_cot_parser_entries() + log_errors = cot_parser.find_errors(log_lines) + rabbit_ok = cot_parser.rabbitmq_check() - status = compute_status(service_state, log_errors, rabbit_ok) - status["timestamp"] = current_timestamp() + status = cot_parser.compute_status(service_state, log_errors, rabbit_ok) + status["timestamp"] = cot_parser.current_timestamp() strict = request.args.get("strict", "false").lower() == "true" code = 200 @@ -44,6 +37,19 @@ def health_cot(): @health_api.route("/api/health/eud") @auth_required() def health_eud(): - """Placeholder health check for EUD.""" - return jsonify({"status": "ok"}) + """Health check for the EUD handler service.""" + service_state = eud_handler.query_systemd() + log_lines = eud_handler.tail_ots_log_for_eud_handler_entries() + log_errors = eud_handler.find_errors(log_lines) + rabbit_ok = eud_handler.rabbitmq_check() + + status = eud_handler.compute_status(service_state, log_errors, rabbit_ok) + status["timestamp"] = eud_handler.current_timestamp() + + strict = request.args.get("strict", "false").lower() == "true" + code = 200 + if strict and status["overall"] != "healthy": + code = 503 + + return jsonify(status), code diff --git a/opentakserver/health/eud_handler.py b/opentakserver/health/eud_handler.py new file mode 100644 index 00000000..3aedbea1 --- /dev/null +++ b/opentakserver/health/eud_handler.py @@ -0,0 +1,119 @@ +import os +import re +import socket +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, List + +EUD_HANDLER_SERVICE = os.getenv("EUD_HANDLER_SERVICE", "eud_handler.service") +OTS_DATA_FOLDER = os.getenv("OTS_DATA_FOLDER", os.path.join(Path.home(), "ots")) +EUD_HANDLER_LOG = os.getenv( + "EUD_HANDLER_LOG", + os.path.join(OTS_DATA_FOLDER, "logs", "opentakserver.log"), +) +RABBIT_HOST = os.getenv("RABBIT_HOST", "localhost") +RABBIT_PORT = int(os.getenv("RABBIT_PORT", "5672")) +ERROR_PATTERN = os.getenv("EUD_HANDLER_ERROR_REGEX", r"(ERROR|Exception|Traceback)") +ERROR_REGEX = re.compile(ERROR_PATTERN, re.IGNORECASE) +LOG_TAG = "eud_handler" + + +def query_systemd(service: str = EUD_HANDLER_SERVICE) -> str: + """Returns one of: active, inactive, failed, activating, deactivating, reloading, unknown""" + # First try: is-active (simplest, stable output) + try: + completed = subprocess.run( + ["systemctl", "is-active", service], + check=False, + capture_output=True, + text=True, + ) + state = completed.stdout.strip() + if state: # active/inactive/failed/... + return state + except Exception: + pass + + # Fallback: show ActiveState + try: + completed = subprocess.run( + ["systemctl", "show", service, "--property=ActiveState", "--value"], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + except Exception as exc: + return f"error: {exc}" + + +def tail_ots_log_for_eud_handler_entries( + path: str = EUD_HANDLER_LOG, lines: int = 100, tag: str = LOG_TAG +) -> List[str]: + """Return the last ``lines`` from the OTS log produced by ``eud_handler``.""" + try: + with open(path, "rb") as fh: + fh.seek(0, os.SEEK_END) + size = fh.tell() + block = 1024 + data = bytearray() + while size > 0 and data.count(b"\n") <= lines: + step = min(block, size) + size -= step + fh.seek(size) + data = fh.read(step) + data + log_lines = data.decode(errors="ignore").splitlines() + return [line for line in log_lines if tag in line][-lines:] + except OSError as exc: # pragma: no cover - exercised via tests + return [f"error: {exc}"] + + +def find_errors(lines: Iterable[str]) -> List[str]: + """Filter log lines that match ``ERROR_REGEX``.""" + return [line for line in lines if ERROR_REGEX.search(line)] + + +def rabbitmq_check(host: str = RABBIT_HOST, port: int = RABBIT_PORT, timeout: float = 1.0) -> bool: + """Attempt a TCP connection to RabbitMQ and return whether it succeeded.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: # pragma: no cover - exercised via tests + return False + + +def compute_status(service_state: str, log_errors: List[str], rabbitmq_ok: bool) -> dict: + """Compute component and overall health status.""" + components = { + "service": service_state, + "logs": "errors" if log_errors else "error-free", + "rabbitmq": "up" if rabbitmq_ok else "down", + } + problems: List[str] = [] + if service_state != "active": + problems.append("eud_handler service inactive") + if log_errors: + problems.append("errors detected in log") + if not rabbitmq_ok: + problems.append("rabbitmq unreachable") + + # Determine operational status + overall = "non-operational" + if rabbitmq_ok and service_state == "active": + overall = "operational-" + if log_errors: + overall += "errors" + else: + overall += "healthy" + + return { + "overall": overall, + "components": components, + "problems": problems, + } + + +def current_timestamp() -> str: + """Return an ISO 8601 UTC timestamp.""" + return datetime.now(timezone.utc).isoformat() diff --git a/tests/test_health_api.py b/tests/test_health_api.py index a4c40bbf..00aad254 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -38,6 +38,37 @@ def test_health_cot_unhealthy_strict(auth): assert data["problems"] +def test_health_eud_healthy(auth): + with patch("opentakserver.health.eud_handler.query_systemd", return_value="active"), \ + patch( + "opentakserver.health.eud_handler.tail_ots_log_for_eud_handler_entries", + return_value=["all good"], + ), \ + patch("opentakserver.health.eud_handler.find_errors", return_value=[]), \ + patch("opentakserver.health.eud_handler.rabbitmq_check", return_value=True): + response = auth.get("/api/health/eud") + assert response.status_code == 200 + data = response.json + assert data["overall"] == "healthy" + assert data["problems"] == [] + assert "timestamp" in data + + +def test_health_eud_unhealthy_strict(auth): + with patch("opentakserver.health.eud_handler.query_systemd", return_value="inactive"), \ + patch( + "opentakserver.health.eud_handler.tail_ots_log_for_eud_handler_entries", + return_value=["error"], + ), \ + patch("opentakserver.health.eud_handler.find_errors", return_value=["error"]), \ + patch("opentakserver.health.eud_handler.rabbitmq_check", return_value=False): + response = auth.get("/api/health/eud?strict=true") + assert response.status_code == 503 + data = response.json + assert data["overall"] == "unhealthy" + assert data["problems"] + + def test_health_requires_auth(client): for endpoint in ("ots", "cot", "eud"): response = client.get(f"/api/health/{endpoint}") From aa84af18621b10c96677f4eaef23e798e7878584 Mon Sep 17 00:00:00 2001 From: Dan Shevenell Date: Thu, 28 Aug 2025 13:06:27 -0400 Subject: [PATCH 7/7] minor terminology fixes --- opentakserver/health/cot_parser.py | 2 +- opentakserver/health/eud_handler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opentakserver/health/cot_parser.py b/opentakserver/health/cot_parser.py index ff0a0ec2..d9782812 100644 --- a/opentakserver/health/cot_parser.py +++ b/opentakserver/health/cot_parser.py @@ -110,7 +110,7 @@ def compute_status(service_state: str, log_errors: List[str], rabbitmq_ok: bool) overall += "healthy" return { - "overall": overall, + "status": overall, "components": components, "problems": problems, } diff --git a/opentakserver/health/eud_handler.py b/opentakserver/health/eud_handler.py index 3aedbea1..d4a5f6a9 100644 --- a/opentakserver/health/eud_handler.py +++ b/opentakserver/health/eud_handler.py @@ -108,7 +108,7 @@ def compute_status(service_state: str, log_errors: List[str], rabbitmq_ok: bool) overall += "healthy" return { - "overall": overall, + "status": overall, "components": components, "problems": problems, }