steam-backlog-enforcer/steam_backlog_enforcer/_web_process.py
2026-10-10 16:07:42 +02:00

117 lines
3.8 KiB
Python

"""The server process itself: when it started, what code it runs, stand-down.
``ServerHealth`` (``GET /api/server``) and ``POST /api/server/restart`` live
here, next to the retirement switch the stale-code check
(:mod:`steam_backlog_enforcer._serve_stale`) already used: a restart is the
same stand-down, asked for instead of detected. Exiting is enough because
``steam-backlog-enforcer-web.service`` has ``Restart=always``; jobs survive
it, being their own processes in their own systemd scopes.
"""
from __future__ import annotations
from datetime import UTC, datetime
from functools import cache
from http import HTTPStatus
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._serve_stale import newest_py_after, outdated_source
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer._web_io import Reply, ok
if TYPE_CHECKING:
from socketserver import BaseServer
from steam_backlog_enforcer._web_io import Request
# Epoch seconds this process began, captured at import. Anything in the
# package newer than this is code we did not load.
STARTED_AT: Final = time.time()
# Set once the server has begun standing down, so concurrent requests do
# not each spawn a shutdown thread (and SSE streams know to end).
RETIRING: Final = threading.Event()
# Long enough for the 202 to leave before the process goes.
_RESTART_DELAY_SECONDS: Final = 0.5
_REPO_ROOT: Final = Path(__file__).resolve().parent.parent
_server: list[BaseServer] = []
def register(server: BaseServer) -> None:
"""Remember the running server so :func:`retire` can stop it."""
_server[:] = [server]
def retire(delay: float = 0.0) -> None:
"""Stop serving (once) so the supervisor starts current code.
``shutdown`` blocks until ``serve_forever`` returns and cannot run on a
request thread, so it gets its own; *delay* lets the reply that asked
for it reach the client first.
"""
if RETIRING.is_set() or not _server:
return
RETIRING.set()
timer = threading.Timer(delay, _server[0].shutdown)
timer.daemon = True
timer.start()
def _iso(epoch: float) -> str:
"""Epoch seconds as ISO-8601 UTC with a ``Z`` suffix."""
return datetime.fromtimestamp(epoch, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
@cache
def version() -> str:
"""The git short sha of the checkout, else the newest source mtime."""
git = shutil.which("git")
if git is not None:
try:
out = subprocess.run(
[git, "-C", str(_REPO_ROOT), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except OSError, subprocess.SubprocessError:
pass
else:
return out.stdout.strip()
newest = newest_py_after(0.0)
return _iso(newest.stat().st_mtime) if newest else "unknown"
def health() -> dict[str, object]:
"""The contract ``ServerHealth``."""
return {
"stale": outdated_source(STARTED_AT) is not None,
"started_at": _iso(STARTED_AT),
"version": version(),
}
def health_view(_request: Request) -> Reply:
"""``GET /api/server``."""
return ok(health())
def restart_view(_request: Request) -> Reply:
"""``POST /api/server/restart`` — answer 202, then exit for systemd.
Raises:
ApiError: ``unsupported`` when no supervisor would bring the server
back (started by hand, the restart would be a stop).
"""
if not os.environ.get("INVOCATION_ID"):
msg = "This server is not running under systemd; a restart would stop it."
raise ApiError(msg, code="unsupported")
retire(_RESTART_DELAY_SECONDS)
return Reply(HTTPStatus.ACCEPTED)