153 lines
5.0 KiB
Python
153 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Minimal two-process supervisor with bounded restart and graceful shutdown."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import logging.handlers
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
STOP = threading.Event()
|
|
RUN_DIR = Path("/run/lumaops")
|
|
LOG_DIR = Path(os.environ.get("LOGS_DIR", "/logs"))
|
|
|
|
|
|
def configure_logging() -> None:
|
|
logging.basicConfig(
|
|
level=os.environ.get("LOG_LEVEL", "INFO"),
|
|
format="%(asctime)s %(levelname)s supervisor %(message)s",
|
|
stream=sys.stdout,
|
|
)
|
|
|
|
|
|
def signal_handler(signum: int, _frame: object) -> None:
|
|
logging.info("received signal %s; starting graceful shutdown", signum)
|
|
STOP.set()
|
|
|
|
|
|
class ManagedProcess:
|
|
def __init__(self, name: str, command: list[str], log_name: str) -> None:
|
|
self.name = name
|
|
self.command = command
|
|
self.log_name = log_name
|
|
self.process: subprocess.Popen[str] | None = None
|
|
self.restarts = 0
|
|
self.next_start = 0.0
|
|
|
|
def start(self) -> None:
|
|
if STOP.is_set():
|
|
return
|
|
logging.info("starting %s", self.name)
|
|
self.process = subprocess.Popen( # noqa: S603 - fixed administrator-controlled argv
|
|
self.command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
env=os.environ.copy(),
|
|
)
|
|
(RUN_DIR / f"{self.name}.pid").write_text(str(self.process.pid), encoding="ascii")
|
|
threading.Thread(target=self._pump_output, daemon=True, name=f"{self.name}-logs").start()
|
|
|
|
def _pump_output(self) -> None:
|
|
assert self.process is not None and self.process.stdout is not None
|
|
handler = logging.handlers.RotatingFileHandler(
|
|
LOG_DIR / self.log_name,
|
|
maxBytes=int(os.environ.get("LOG_MAX_BYTES", str(10 * 1024 * 1024))),
|
|
backupCount=int(os.environ.get("LOG_BACKUP_COUNT", "5")),
|
|
encoding="utf-8",
|
|
)
|
|
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
logger = logging.getLogger(f"process.{self.name}")
|
|
logger.handlers = [handler]
|
|
logger.propagate = False
|
|
logger.setLevel(logging.INFO)
|
|
for line in self.process.stdout:
|
|
logger.info("%s", line.rstrip())
|
|
handler.close()
|
|
|
|
def poll_and_restart(self) -> None:
|
|
if self.process is None:
|
|
if time.monotonic() >= self.next_start:
|
|
self.start()
|
|
return
|
|
code = self.process.poll()
|
|
if code is None:
|
|
if time.monotonic() - self.next_start > 60:
|
|
self.restarts = 0
|
|
return
|
|
logging.error("%s exited with code %s", self.name, code)
|
|
(RUN_DIR / f"{self.name}.pid").unlink(missing_ok=True)
|
|
self.process = None
|
|
delay = min(30.0, 0.5 * (2 ** min(self.restarts, 6)))
|
|
self.restarts += 1
|
|
self.next_start = time.monotonic() + delay
|
|
|
|
def terminate(self, timeout: float = 12.0) -> None:
|
|
process = self.process
|
|
if process is None or process.poll() is not None:
|
|
return
|
|
logging.info("stopping %s", self.name)
|
|
process.send_signal(signal.SIGTERM)
|
|
try:
|
|
process.wait(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
logging.warning("%s did not stop in %.1fs; sending SIGKILL", self.name, timeout)
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
(RUN_DIR / f"{self.name}.pid").unlink(missing_ok=True)
|
|
|
|
|
|
def main() -> int:
|
|
configure_logging()
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
signal.signal(sig, signal_handler)
|
|
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
openrgb_config = Path(os.environ.get("OPENRGB_CONFIG_DIR", "/config/openrgb"))
|
|
openrgb_config.mkdir(parents=True, exist_ok=True)
|
|
port = os.environ.get("OPENRGB_PORT", "6742")
|
|
openrgb = ManagedProcess(
|
|
"openrgb",
|
|
[
|
|
"/usr/local/bin/openrgb",
|
|
"--server",
|
|
"--server-host",
|
|
"127.0.0.1",
|
|
"--server-port",
|
|
port,
|
|
"--config",
|
|
str(openrgb_config),
|
|
"--noautoconnect",
|
|
],
|
|
"openrgb.log",
|
|
)
|
|
backend = ManagedProcess(
|
|
"backend",
|
|
["/opt/venv/bin/python", "-m", "lumaops_backend.main"],
|
|
"backend.log",
|
|
)
|
|
processes = [openrgb, backend]
|
|
for process in processes:
|
|
process.start()
|
|
process.next_start = time.monotonic()
|
|
try:
|
|
while not STOP.wait(0.5):
|
|
for process in processes:
|
|
process.poll_and_restart()
|
|
finally:
|
|
backend.terminate()
|
|
openrgb.terminate()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|