mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:07:05 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
117 lines
3.8 KiB
Python
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)
|