Files
kevinveenbirkenbachandClaude Opus 5 74afec37c3 fix(health): exit with a status, not a failure count
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>
2026-09-04 04:41:04 +02:00

102 lines
3.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Ultra-thin checker: consume a JSON mapping of {domain: [expected_status_codes]}
and verify HTTP HEAD responses. All mapping logic is done in the filter
`web_health_expectations`.
"""
import argparse
import json
import os
import sys
from pathlib import Path
import requests
def parse_args(argv=None):
p = argparse.ArgumentParser(
description="Web health checker (expects precomputed domain→codes mapping)."
)
p.add_argument(
"--web-protocol",
default="https",
choices=["http", "https"],
help="Protocol to use",
)
p.add_argument(
"--expectations", required=True, help='JSON STRING: {"domain": [codes], ...}'
)
return p.parse_args(argv)
def _protocol_for(domain: str, default_protocol: str) -> str:
"""Onion services are plaintext-only (no TLS): probe them over http
regardless of the clearnet default. Everything else uses the default."""
return "http" if domain.endswith(".onion") else default_protocol
def _parse_json_mapping(name: str, value: str) -> dict[str, list[int]]:
try:
obj = json.loads(value)
except json.JSONDecodeError as e:
raise SystemExit(f"--{name} must be a valid JSON string: {e}") from e
if not isinstance(obj, dict):
raise SystemExit(f"--{name} must be a JSON object (mapping)")
clean = {}
for k, v in obj.items():
if isinstance(v, list):
try:
clean[k] = [int(x) for x in v]
except Exception:
clean[k] = []
else:
clean[k] = []
return clean
def main(argv=None) -> int:
args = parse_args(argv)
expectations = _parse_json_mapping("expectations", args.expectations)
verify = True
ca_trust_cert_host = os.environ.get("CA_TRUST_CERT_HOST", "").strip()
if ca_trust_cert_host:
if not Path(ca_trust_cert_host).is_file():
print(
f"CA_TRUST_CERT_HOST points to a missing certificate: {ca_trust_cert_host}"
)
return 1
verify = ca_trust_cert_host
errors = 0
for domain in sorted(expectations.keys()):
expected = expectations[domain] or []
is_onion = domain.endswith(".onion")
url = f"{_protocol_for(domain, args.web_protocol)}://{domain}"
timeout = 30 if is_onion else 10
try:
r = requests.head(
url, allow_redirects=False, timeout=timeout, verify=verify
)
if expected and r.status_code in expected:
print(f"{domain}: OK")
elif not expected:
print(
f"{domain}: ERROR: No expectations provided. Got {r.status_code}."
)
errors += 1
else:
print(f"{domain}: ERROR: Expected {expected}. Got {r.status_code}.")
errors += 1
except requests.RequestException as e:
print(f"{domain}: error due to {e}")
errors += 1
if errors:
print(f"Warning: {errors} domains responded with an unexpected status code.")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())