Files
aipackage/autoinstall/offline/activation-health-window.py

248 lines
12 KiB
Python

#!/usr/bin/env python3
"""Evaluate a bounded, semantic, loopback-only activation health window."""
from __future__ import annotations
import argparse
import hashlib
import ipaddress
import json
import os
import re
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Callable
SCHEMA = "com.cezen.nexus-one.activation-health-evidence.v1"
MAX_RESPONSE_BYTES = 65536
UNIT_NAME = re.compile(r"^[A-Za-z0-9_.@-]+\.service$")
class HealthConfigError(ValueError):
pass
def canonical(value: object) -> bytes:
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
def validate_config(config: dict) -> list[dict]:
if set(config) != {"schema", "components"} or config["schema"] != \
"com.cezen.nexus-one.activation-health-profile.v1":
raise HealthConfigError("HEALTH_PROFILE_INVALID")
components = config["components"]
if not isinstance(components, list) or not components:
raise HealthConfigError("HEALTH_COMPONENTS_INVALID")
names = set()
result = []
for component in components:
if not isinstance(component, dict) or set(component) != {
"name", "url", "required", "expected_service", "systemd_unit", "max_restarts"}:
raise HealthConfigError("HEALTH_COMPONENT_INVALID")
name = component["name"]
if not isinstance(name, str) or not name or name in names:
raise HealthConfigError("HEALTH_COMPONENT_NAME_INVALID")
names.add(name)
if not isinstance(component["required"], bool):
raise HealthConfigError("HEALTH_COMPONENT_REQUIRED_INVALID")
expected = component["expected_service"]
if not isinstance(expected, str) or not expected:
raise HealthConfigError("HEALTH_COMPONENT_EXPECTED_SERVICE_INVALID")
unit = component["systemd_unit"]
if unit is not None and (not isinstance(unit, str) or not UNIT_NAME.fullmatch(unit)):
raise HealthConfigError("HEALTH_COMPONENT_SYSTEMD_UNIT_INVALID")
maximum = component["max_restarts"]
if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < 0 or maximum > 1000:
raise HealthConfigError("HEALTH_COMPONENT_MAX_RESTARTS_INVALID")
parsed = urllib.parse.urlparse(component["url"])
if parsed.scheme != "http" or parsed.username or parsed.password or parsed.query \
or parsed.fragment or not parsed.port or parsed.path == "":
raise HealthConfigError("HEALTH_URL_INVALID")
try:
address = ipaddress.ip_address(parsed.hostname or "")
except ValueError as error:
raise HealthConfigError("HEALTH_URL_NOT_LITERAL_LOOPBACK") from error
if not address.is_loopback:
raise HealthConfigError("HEALTH_URL_NOT_LOOPBACK")
result.append(dict(component))
return result
def fetch(component: dict, timeout_seconds: float) -> dict:
request = urllib.request.Request(component["url"], headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
if response.status != 200:
return {"ok": False, "code": f"HTTP_{response.status}"}
content_type = response.headers.get_content_type()
if content_type != "application/json":
return {"ok": False, "code": "CONTENT_TYPE_INVALID"}
payload = response.read(MAX_RESPONSE_BYTES + 1)
if len(payload) > MAX_RESPONSE_BYTES:
return {"ok": False, "code": "RESPONSE_TOO_LARGE"}
except urllib.error.HTTPError as error:
return {"ok": False, "code": f"HTTP_{error.code}"}
except TimeoutError:
return {"ok": False, "code": "TIMEOUT"}
except (OSError, urllib.error.URLError):
return {"ok": False, "code": "UNREACHABLE"}
try:
decoded = json.loads(payload)
except (UnicodeDecodeError, json.JSONDecodeError):
return {"ok": False, "code": "MALFORMED_JSON"}
if not isinstance(decoded, dict) or set(decoded) != {"service", "status"}:
return {"ok": False, "code": "RESPONSE_SHAPE_INVALID"}
if decoded["service"] != component["expected_service"]:
return {"ok": False, "code": "SERVICE_IDENTITY_MISMATCH"}
if decoded["status"] not in {"ready", "degraded", "failed"}:
return {"ok": False, "code": "STATUS_INVALID"}
return {"ok": decoded["status"] == "ready", "code": decoded["status"].upper(),
"status": decoded["status"]}
def restart_state(component: dict) -> dict:
unit = component["systemd_unit"]
if unit is None:
return {"ok": True, "code": "RESTART_CHECK_NOT_APPLICABLE", "restarts": 0}
try:
process = subprocess.run(
["systemctl", "show", unit, "--property=NRestarts", "--property=ActiveState",
"--property=SubState", "--no-pager"], check=True, capture_output=True, text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return {"ok": False, "code": "UNIT_STATE_UNAVAILABLE", "restarts": None}
values = {}
for line in process.stdout.splitlines():
key, separator, value = line.partition("=")
if not separator or key in values:
return {"ok": False, "code": "UNIT_STATE_INVALID", "restarts": None}
values[key] = value
if set(values) != {"NRestarts", "ActiveState", "SubState"} or not values["NRestarts"].isdigit():
return {"ok": False, "code": "UNIT_STATE_INVALID", "restarts": None}
restarts = int(values["NRestarts"])
if restarts > component["max_restarts"]:
return {"ok": False, "code": "RESTART_STORM", "restarts": restarts}
if values["ActiveState"] != "active":
return {"ok": False, "code": "UNIT_NOT_ACTIVE", "restarts": restarts}
return {"ok": True, "code": "RESTARTS_OK", "restarts": restarts}
def atomic_evidence(path: Path, evidence: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(canonical(evidence))
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
directory_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def evaluate(config: dict, operation_id: str, samples: int, interval_seconds: float,
timeout_seconds: float, window_timeout_seconds: float, allow_degraded: bool,
fetcher: Callable = fetch, restart_checker: Callable = restart_state,
sleeper: Callable = time.sleep, clock: Callable = time.monotonic) -> dict:
components = validate_config(config)
if not operation_id or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_." for character in operation_id):
raise HealthConfigError("OPERATION_ID_INVALID")
if samples < 1 or samples > 100 or interval_seconds < 0 or timeout_seconds <= 0 \
or window_timeout_seconds <= 0 or timeout_seconds > window_timeout_seconds:
raise HealthConfigError("HEALTH_WINDOW_BOUNDS_INVALID")
records = []
outcome = "ready"
deadline = clock() + window_timeout_seconds
for sample in range(1, samples + 1):
for component in components:
remaining = deadline - clock()
if remaining <= 0:
result = {"ok": False, "code": "WINDOW_TIMEOUT"}
restart = {"code": "NOT_CHECKED", "restarts": None}
else:
restart = restart_checker(component)
if restart.get("ok") is not True:
result = restart
else:
remaining = deadline - clock()
result = {"ok": False, "code": "WINDOW_TIMEOUT"} if remaining <= 0 else \
fetcher(component, min(timeout_seconds, remaining))
if clock() > deadline:
result = {"ok": False, "code": "WINDOW_TIMEOUT"}
status = result.get("status")
accepted_degraded = status == "degraded" and not component["required"] and allow_degraded
accepted = result.get("ok") is True or accepted_degraded
if accepted_degraded:
outcome = "degraded"
records.append({"sample": sample, "component": component["name"],
"required": component["required"], "accepted": accepted,
"code": result.get("code", "PROBE_RESULT_INVALID"),
"restart_code": restart.get("code"),
"restart_count": restart.get("restarts")})
if not accepted:
outcome = "rejected"
return {"schema": SCHEMA, "operation_id": operation_id,
"profile_sha256": hashlib.sha256(canonical(config)).hexdigest(),
"samples_required": samples, "samples_completed": sample,
"allow_degraded": allow_degraded, "outcome": outcome, "checks": records}
if sample != samples:
if clock() + interval_seconds > deadline:
records.append({"sample": sample, "component": "window", "required": True,
"accepted": False, "code": "WINDOW_TIMEOUT",
"restart_code": "NOT_APPLICABLE", "restart_count": None})
return {"schema": SCHEMA, "operation_id": operation_id,
"profile_sha256": hashlib.sha256(canonical(config)).hexdigest(),
"samples_required": samples, "samples_completed": sample,
"allow_degraded": allow_degraded, "outcome": "rejected",
"checks": records}
sleeper(interval_seconds)
return {"schema": SCHEMA, "operation_id": operation_id,
"profile_sha256": hashlib.sha256(canonical(config)).hexdigest(),
"samples_required": samples, "samples_completed": samples,
"allow_degraded": allow_degraded, "outcome": outcome, "checks": records}
def main(argv: list[str] | None = None, fetcher: Callable = fetch,
restart_checker: Callable = restart_state, sleeper: Callable = time.sleep,
clock: Callable = time.monotonic) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--operation-id", required=True)
parser.add_argument("--profile-config", type=Path, required=True)
parser.add_argument("--samples", type=int, required=True)
parser.add_argument("--interval-seconds", type=float, required=True)
parser.add_argument("--timeout-seconds", type=float, required=True)
parser.add_argument("--window-timeout-seconds", type=float, required=True)
parser.add_argument("--evidence", type=Path, required=True)
parser.add_argument("--allow-degraded", action="store_true")
args = parser.parse_args(argv)
try:
config = json.loads(args.profile_config.read_text(encoding="utf-8"))
evidence = evaluate(config, args.operation_id, args.samples, args.interval_seconds,
args.timeout_seconds, args.window_timeout_seconds, args.allow_degraded,
fetcher, restart_checker, sleeper, clock)
atomic_evidence(args.evidence, evidence)
except (HealthConfigError, OSError, json.JSONDecodeError) as error:
print(json.dumps({"accepted": False, "code": str(error)}, sort_keys=True))
return 2
print(json.dumps({"accepted": evidence["outcome"] in {"ready", "degraded"},
"outcome": evidence["outcome"]}, sort_keys=True))
return 0 if evidence["outcome"] in {"ready", "degraded"} else 23
if __name__ == "__main__":
raise SystemExit(main())