mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 03:15:56 +00:00
Four probes returned the number of failures as the process exit code. An exit status is 8 bits wide, so exactly 256 or 512 failures arrive as 0 and the probe reports success. sys-service installs these as Type=oneshot units (roles/sys-service/vars/main.yml:28) and sys-service-terminator starts them with until: result is not failed, so that exit code is the whole health verdict. The count is not lost: each of the four prints it to stdout a line earlier. Only the exit status stops carrying it, because it cannot. This makes no CI job green and is not meant to. For any count other than 256 or 512 nothing changes; at those two it turns a false success into an honest failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
5.8 KiB
Python
195 lines
5.8 KiB
Python
import importlib.util
|
|
import io
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
def load_module_from_path(mod_name: str, path: str):
|
|
"""Dynamically load a module from a filesystem path."""
|
|
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
|
return module
|
|
|
|
|
|
class TestStandaloneCheckerScript(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
from . import PROJECT_ROOT
|
|
|
|
cls.script_path = str(
|
|
PROJECT_ROOT
|
|
/ "roles"
|
|
/ "sys-ctl-hlth-webserver"
|
|
/ "files"
|
|
/ "python"
|
|
/ "script.py"
|
|
)
|
|
if not Path(cls.script_path).is_file():
|
|
raise FileNotFoundError(f"Cannot find script.py at {cls.script_path}")
|
|
cls.script = load_module_from_path("health_script", cls.script_path)
|
|
|
|
# ------------- JSON parsing ------------------
|
|
|
|
def test_rejects_invalid_json(self):
|
|
with self.assertRaises(SystemExit):
|
|
self.script.main(
|
|
[
|
|
"--expectations",
|
|
'{"bad json": [200, 301]',
|
|
]
|
|
)
|
|
|
|
def test_rejects_non_mapping_json(self):
|
|
with self.assertRaises(SystemExit):
|
|
self.script.main(
|
|
[
|
|
"--expectations",
|
|
'["not", "a", "mapping"]',
|
|
]
|
|
)
|
|
|
|
# ------------- Happy path / mismatches -------
|
|
|
|
@patch("requests.head")
|
|
def test_all_ok_returns_zero(self, mock_head):
|
|
def head_side_effect(url, allow_redirects=False, timeout=10, verify=True):
|
|
class R:
|
|
pass
|
|
|
|
r = R()
|
|
domain = url.split("://", 1)[1]
|
|
mapping = {"ok1.example.org": 200, "ok2.example.org": 301}
|
|
r.status_code = mapping.get(domain, 200)
|
|
return r
|
|
|
|
mock_head.side_effect = head_side_effect
|
|
|
|
exp = {
|
|
"ok1.example.org": [200, 302, 301],
|
|
"ok2.example.org": [301],
|
|
}
|
|
exit_code, output = self._run_main(
|
|
[
|
|
"--web-protocol",
|
|
"https",
|
|
"--expectations",
|
|
self._to_json(exp),
|
|
]
|
|
)
|
|
self.assertEqual(exit_code, 0)
|
|
self.assertIn("ok1.example.org: OK", output)
|
|
self.assertIn("ok2.example.org: OK", output)
|
|
|
|
@patch("requests.head")
|
|
def test_mismatches_counted(self, mock_head):
|
|
def head_side_effect(url, allow_redirects=False, timeout=10, verify=True):
|
|
class R:
|
|
pass
|
|
|
|
r = R()
|
|
domain = url.split("://", 1)[1]
|
|
mapping = {"bad.example.org": 200, "ok301.example.org": 301}
|
|
r.status_code = mapping.get(domain, 200)
|
|
return r
|
|
|
|
mock_head.side_effect = head_side_effect
|
|
|
|
exp = {
|
|
"bad.example.org": [404],
|
|
"ok301.example.org": [301],
|
|
"never.example.org": [200],
|
|
}
|
|
exit_code, output = self._run_main(
|
|
[
|
|
"--expectations",
|
|
self._to_json(exp),
|
|
]
|
|
)
|
|
self.assertEqual(exit_code, 1)
|
|
self.assertIn("bad.example.org: ERROR: Expected [404]. Got 200.", output)
|
|
|
|
@patch("requests.head")
|
|
def test_non_list_values_sanitize_to_empty_and_fail(self, mock_head):
|
|
# If a domain maps to a non-list, it becomes [] and is treated as a failure
|
|
def head_side_effect(url, allow_redirects=False, timeout=10, verify=True):
|
|
class R:
|
|
pass
|
|
|
|
r = R()
|
|
r.status_code = 200
|
|
return r
|
|
|
|
mock_head.side_effect = head_side_effect
|
|
|
|
exp_json = '{"foo.example.org": "not-a-list", "bar.example.org": 200}'
|
|
exit_code, output = self._run_main(
|
|
[
|
|
"--expectations",
|
|
exp_json,
|
|
]
|
|
)
|
|
self.assertEqual(exit_code, 1)
|
|
self.assertIn(
|
|
"foo.example.org: ERROR: No expectations provided. Got 200.", output
|
|
)
|
|
self.assertIn(
|
|
"bar.example.org: ERROR: No expectations provided. Got 200.", output
|
|
)
|
|
|
|
@patch("requests.head")
|
|
def test_onion_domains_probed_over_http_with_longer_timeout(self, mock_head):
|
|
seen = {}
|
|
|
|
def head_side_effect(url, allow_redirects=False, timeout=10, verify=True):
|
|
class R:
|
|
pass
|
|
|
|
r = R()
|
|
domain = url.split("://", 1)[1]
|
|
seen[domain] = {"url": url, "timeout": timeout}
|
|
r.status_code = 200
|
|
return r
|
|
|
|
mock_head.side_effect = head_side_effect
|
|
|
|
exp = {
|
|
"clear.example.org": [200],
|
|
"svc.abcd1234efgh.onion": [200],
|
|
}
|
|
exit_code, _ = self._run_main(
|
|
[
|
|
"--web-protocol",
|
|
"https",
|
|
"--expectations",
|
|
self._to_json(exp),
|
|
]
|
|
)
|
|
self.assertEqual(exit_code, 0)
|
|
self.assertEqual(
|
|
seen["svc.abcd1234efgh.onion"]["url"], "http://svc.abcd1234efgh.onion"
|
|
)
|
|
self.assertEqual(seen["svc.abcd1234efgh.onion"]["timeout"], 30)
|
|
self.assertEqual(seen["clear.example.org"]["url"], "https://clear.example.org")
|
|
self.assertEqual(seen["clear.example.org"]["timeout"], 10)
|
|
|
|
# ------------- Helpers -----------------------
|
|
|
|
def _run_main(self, argv):
|
|
buffer = io.StringIO()
|
|
with redirect_stdout(buffer):
|
|
exit_code = self.script.main(argv)
|
|
return exit_code, buffer.getvalue()
|
|
|
|
@staticmethod
|
|
def _to_json(obj) -> str:
|
|
import json
|
|
|
|
return json.dumps(obj, separators=(",", ":"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|