249 lines
8.9 KiB
Python
249 lines
8.9 KiB
Python
"""Finite operator automation using the existing setup CLI entry points."""
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import importlib
|
|
import io
|
|
import os
|
|
import signal
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
from . import manifest
|
|
from .policy import DeliveryError
|
|
|
|
_ENTRIES = {
|
|
"scripts.release": "scripts.release.__main__",
|
|
**{
|
|
"scripts.release." + name: "scripts.release." + name
|
|
for name in (
|
|
"journal",
|
|
"dependencies",
|
|
"dependency_install",
|
|
"restore",
|
|
"services",
|
|
"service_install",
|
|
"factory_service",
|
|
"proxy_install",
|
|
)
|
|
},
|
|
}
|
|
_DELIVERY_COMMANDS = {
|
|
"verify",
|
|
"preflight",
|
|
"install-files",
|
|
"generate-configuration",
|
|
"install-configuration",
|
|
"activate",
|
|
"generate-proxy",
|
|
}
|
|
|
|
|
|
def _require_operator() -> None:
|
|
if (os.getuid(), os.geteuid(), os.getgid(), os.getegid()) != (
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
) or not set(os.getgroups()) <= {0}:
|
|
raise DeliveryError("setup session requires the trusted root operator")
|
|
|
|
|
|
class _Output(io.StringIO):
|
|
def __init__(self, maximum: int):
|
|
super().__init__()
|
|
self.maximum = maximum
|
|
self.bytes_written = 0
|
|
|
|
def write(self, value: str) -> int:
|
|
size = len(value.encode("utf-8"))
|
|
if self.bytes_written + size > self.maximum:
|
|
raise DeliveryError("setup CLI output exceeds bound")
|
|
self.bytes_written += size
|
|
return super().write(value)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CommandResult:
|
|
exit_code: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
class SetupSession:
|
|
"""Run at most 32 setup calls in one synchronous, bounded operator operation.
|
|
|
|
Individual CLI results are provisional until the context exits successfully.
|
|
This does not roll back completed installation steps or certify execution.
|
|
"""
|
|
|
|
def __init__(self, *, maximum_seconds: float = 1800):
|
|
if (
|
|
isinstance(maximum_seconds, bool)
|
|
or not isinstance(maximum_seconds, (int, float))
|
|
or not 0 < maximum_seconds <= 2400
|
|
):
|
|
raise DeliveryError("finite setup deadline up to 2400 seconds required")
|
|
self.maximum_seconds = maximum_seconds
|
|
self.completed = False
|
|
self.records: list[dict] = []
|
|
self._entered = False
|
|
self._used = False
|
|
self._failed = False
|
|
self._owns_alarm = False
|
|
|
|
def _owner(self) -> None:
|
|
_require_operator()
|
|
if (
|
|
os.getpid() != self._pid
|
|
or threading.get_ident() != self._thread
|
|
or threading.current_thread() is not threading.main_thread()
|
|
or threading.active_count() != 1
|
|
):
|
|
raise DeliveryError("same single-threaded setup caller required")
|
|
|
|
def _deadline(self) -> None:
|
|
if time.monotonic() >= self._deadline_at:
|
|
self._failed = True
|
|
raise DeliveryError("setup operation deadline exceeded")
|
|
|
|
def _expired(self, _signum, _frame) -> None:
|
|
self._failed = True
|
|
raise DeliveryError("setup operation deadline exceeded")
|
|
|
|
def __enter__(self):
|
|
if self._used:
|
|
raise DeliveryError("setup session is single-use")
|
|
self._pid, self._thread = os.getpid(), threading.get_ident()
|
|
self._owner()
|
|
self._used = True
|
|
self._started = time.monotonic()
|
|
self._deadline_at = self._started + self.maximum_seconds
|
|
self._old_handler = signal.getsignal(signal.SIGALRM)
|
|
self._old_timer = signal.getitimer(signal.ITIMER_REAL)
|
|
try:
|
|
# An earlier caller deadline retains its original handler and timer.
|
|
if not self._old_timer[0] or self._old_timer[0] > self.maximum_seconds:
|
|
self._owns_alarm = True
|
|
signal.signal(signal.SIGALRM, self._expired)
|
|
signal.setitimer(signal.ITIMER_REAL, self.maximum_seconds)
|
|
self._scope = manifest.verification_scope()
|
|
self._scope.__enter__()
|
|
self._entered = True
|
|
except BaseException:
|
|
self._restore_alarm()
|
|
raise
|
|
return self
|
|
|
|
def _restore_alarm(self) -> None:
|
|
if self._owns_alarm:
|
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
signal.signal(signal.SIGALRM, self._old_handler)
|
|
if self._old_timer[0]:
|
|
remaining = self._old_timer[0] - (time.monotonic() - self._started)
|
|
signal.setitimer(signal.ITIMER_REAL, max(0.000001, remaining), self._old_timer[1])
|
|
self._owns_alarm = False
|
|
|
|
def __exit__(self, kind, value, traceback):
|
|
try:
|
|
if kind is None:
|
|
try:
|
|
self._owner()
|
|
self._deadline()
|
|
if self._failed:
|
|
raise DeliveryError("failed setup session cannot complete")
|
|
except BaseException as error:
|
|
self._scope.__exit__(type(error), error, error.__traceback__)
|
|
raise
|
|
self._scope.__exit__(kind, value, traceback)
|
|
self.completed = kind is None and not self._failed
|
|
finally:
|
|
self._entered = False
|
|
self._restore_alarm()
|
|
return False
|
|
|
|
def invoke(self, module: str, argv: list[str]) -> CommandResult:
|
|
if not self._entered or self._failed:
|
|
raise DeliveryError("active successful setup session required")
|
|
try:
|
|
self._owner()
|
|
self._deadline()
|
|
if (
|
|
not isinstance(module, str)
|
|
or module not in _ENTRIES
|
|
or not isinstance(argv, list)
|
|
or len(argv) > 64
|
|
or any(
|
|
not isinstance(arg, str)
|
|
or not 0 < len(arg.encode("utf-8")) <= 4096
|
|
or "\x00" in arg
|
|
or arg.startswith("-h")
|
|
or (
|
|
arg.startswith("--")
|
|
and any(
|
|
private.startswith(arg.split("=", 1)[0])
|
|
for private in ("--help", "--inside")
|
|
)
|
|
)
|
|
for arg in argv
|
|
)
|
|
or len(self.records) >= 32
|
|
):
|
|
raise DeliveryError("unknown or oversized setup CLI call")
|
|
if module == "scripts.release" and (not argv or argv[0] not in _DELIVERY_COMMANDS):
|
|
raise DeliveryError("command is outside offline setup")
|
|
if module == "scripts.release.services" and (not argv or argv[0] != "generate"):
|
|
raise DeliveryError("only service composition belongs to setup")
|
|
if module == "scripts.release.journal" and argv:
|
|
raise DeliveryError("fixed journal setup takes no overrides")
|
|
runtime_option = {
|
|
"scripts.release.dependencies": "--check-service",
|
|
"scripts.release.proxy_install": "--check-binding",
|
|
}.get(module)
|
|
if runtime_option and any(
|
|
arg.startswith("--") and runtime_option.startswith(arg.split("=", 1)[0])
|
|
for arg in argv
|
|
):
|
|
raise DeliveryError("runtime checks are outside setup")
|
|
entry = importlib.import_module(_ENTRIES[module]).main
|
|
stdout, stderr = _Output(32768), _Output(8192)
|
|
started = time.monotonic()
|
|
record = {"module": module, "actual_exit_code": None}
|
|
self.records.append(record)
|
|
try:
|
|
with (
|
|
contextlib.redirect_stdout(stdout),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
try:
|
|
entry(list(argv))
|
|
except SystemExit as error:
|
|
code = (
|
|
error.code
|
|
if type(error.code) is int
|
|
else (0 if error.code is None else 1)
|
|
)
|
|
else:
|
|
code = 0
|
|
record["actual_exit_code"] = code
|
|
except BaseException as error:
|
|
record["actual_exception_type"] = type(error).__name__
|
|
raise
|
|
finally:
|
|
record.update(
|
|
seconds=time.monotonic() - started,
|
|
stdout_bytes=stdout.bytes_written,
|
|
stdout_sha256=hashlib.sha256(stdout.getvalue().encode()).hexdigest(),
|
|
stderr_bytes=stderr.bytes_written,
|
|
stderr_sha256=hashlib.sha256(stderr.getvalue().encode()).hexdigest(),
|
|
)
|
|
result = CommandResult(code, stdout.getvalue(), stderr.getvalue())
|
|
if code:
|
|
self._failed = True
|
|
self._deadline()
|
|
return result
|
|
except BaseException:
|
|
self._failed = True
|
|
raise
|