Fixed orphaned processes and added logging

This commit is contained in:
Malasaur 2025-12-16 23:57:35 +01:00
parent 17fc911290
commit cd2eb8de9d
No known key found for this signature in database
6 changed files with 177 additions and 46 deletions

View file

@ -11,3 +11,4 @@ class Config:
SERVER_RCON_PASSWORD: str | None = data.get("SERVER_RCON_PASSWORD") SERVER_RCON_PASSWORD: str | None = data.get("SERVER_RCON_PASSWORD")
MAINTAINANCE_FILE = data.get("MAINTAINANCE_FILE") or "maintainance.txt" MAINTAINANCE_FILE = data.get("MAINTAINANCE_FILE") or "maintainance.txt"
LOG_FILE = data.get("LOG_FILE") or "logs.txt" LOG_FILE = data.get("LOG_FILE") or "logs.txt"
MINECRAFTD_LOGS = data.get("MINECRAFTD_LOGS") or "debug.log"

View file

@ -2,11 +2,13 @@ import shlex
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
from threading import Lock
from time import sleep from time import sleep
from typing import Literal from typing import Literal
from classes import ProcessStatus, ServerStatus from classes import ProcessStatus, ServerStatus
from config import Config from config import Config
from logs import logger
from mcrcon import MCRcon from mcrcon import MCRcon
from mcstatus import JavaServer from mcstatus import JavaServer
@ -18,38 +20,65 @@ class ProcessController:
self.last_status: Literal[ProcessStatus.STOPPED, ProcessStatus.CRASHED] = ( self.last_status: Literal[ProcessStatus.STOPPED, ProcessStatus.CRASHED] = (
ProcessStatus.STOPPED ProcessStatus.STOPPED
) )
self._lock = Lock()
def start(self) -> None: def start(self) -> None:
"Start the process." "Start the process."
if self.status() == ProcessStatus.RUNNING: logger.debug("ProcessController.start()")
return with self._lock:
if self.status() == ProcessStatus.RUNNING:
if self.process:
logger.debug(
"ProcessController.start() - Process was already running with PID: %s",
self.process.pid,
)
return
self.process = Popen( self.process = Popen(
self.start_command, self.start_command,
stdout=PIPE, stdout=PIPE,
stderr=PIPE, stderr=PIPE,
) )
logger.info(
"ProcessController.start() - Started process with PID: %s",
self.process.pid,
)
def status(self) -> ProcessStatus: def status(self) -> ProcessStatus:
"Check the process' status." "Check the process' status."
logger.debug("ProcessController.status()")
if not self.process: if not self.process:
logger.debug("ProcessController.status() => %s", self.last_status)
return self.last_status return self.last_status
match self.process.poll(): match self.process.poll():
case None: case None:
logger.debug("ProcessController.status() => ProcessStatus.RUNNING")
return ProcessStatus.RUNNING return ProcessStatus.RUNNING
case 0: case 0:
logger.debug("ProcessController.status() => ProcessStatus.STOPPED")
self.last_status = ProcessStatus.STOPPED self.last_status = ProcessStatus.STOPPED
return ProcessStatus.STOPPED return ProcessStatus.STOPPED
case _: case _:
logger.debug("ProcessController.status() => ProcessStatus.CRASHED")
self.last_status = ProcessStatus.CRASHED self.last_status = ProcessStatus.CRASHED
return ProcessStatus.CRASHED return ProcessStatus.CRASHED
def kill(self) -> None: def kill(self) -> None:
"Kill the process." "Kill the process."
if self.process: logger.debug("ProcessController.kill()")
self.process.terminate() with self._lock:
self.process = None if self.process:
self.last_status = ProcessStatus.STOPPED pid = self.process.pid
self.process.kill()
code = self.process.wait()
self.process = None
logger.info(
"ProcessController.kill() - Process with PID %s killed with return code: %s",
pid,
code,
)
self.last_status = ProcessStatus.STOPPED
class ServerController: class ServerController:
@ -65,9 +94,11 @@ class ServerController:
) )
def status(self) -> ServerStatus: def status(self) -> ServerStatus:
logger.debug("ServerController.status()")
try: try:
status = self.server.status() status = self.server.status()
except Exception: except Exception:
logger.debug("ServerController.status() - Server is offline")
return {"online": False} return {"online": False}
players = [] players = []
@ -75,6 +106,7 @@ class ServerController:
for player in status.players.sample: for player in status.players.sample:
players.append(player.name) players.append(player.name)
logger.debug("ServerController.status() - Server is online")
return { return {
"online": True, "online": True,
"icon": status.icon, "icon": status.icon,
@ -87,12 +119,18 @@ class ServerController:
} }
def command(self, command: str) -> str: def command(self, command: str) -> str:
logger.debug('ServerController.command(command="%s")', command)
try: try:
self.rcon.connect() self.rcon.connect()
output = self.rcon.command(command) output = self.rcon.command(command)
self.rcon.disconnect() self.rcon.disconnect()
logger.info('ServerController.command(command="%s") => %s', command, output)
return output return output
except Exception: except Exception:
logger.exception(
'ServerController.command(command="%s") - Command execution failed',
command,
)
return "" return ""
@ -101,19 +139,28 @@ class MaintainanceController:
self.mnt_file = Path(Config.MAINTAINANCE_FILE) self.mnt_file = Path(Config.MAINTAINANCE_FILE)
def set(self, reason: str): def set(self, reason: str):
logger.debug('MaintainanceController.set(reason="%s")', reason)
self.mnt_file.write_text(reason) self.mnt_file.write_text(reason)
def is_set(self) -> bool: def is_set(self) -> bool:
logger.debug("MaintainanceController.is_set() => %s", self.mnt_file.is_file())
return self.mnt_file.is_file() return self.mnt_file.is_file()
def get(self) -> str: def get(self) -> str:
if self.is_set(): if self.is_set():
logger.debug(
"MaintainanceController.get() => %s", self.mnt_file.read_text()
)
return self.mnt_file.read_text() return self.mnt_file.read_text()
logger.debug("MaintainanceController.get() => ")
return "" return ""
def unset(self): def unset(self):
logger.debug("MaintainanceController.unset()")
if self.is_set(): if self.is_set():
self.mnt_file.unlink() self.mnt_file.unlink()
logger.debug("MaintainanceController.unset() - Maintainance file was unset")
class LogsController: class LogsController:
@ -121,16 +168,21 @@ class LogsController:
self.log_file = Path(Config.LOG_FILE) self.log_file = Path(Config.LOG_FILE)
def stream(self): def stream(self):
logger.debug("LogsController.stream()")
i = 0
with self.log_file.open() as f: with self.log_file.open() as f:
f.seek(0, 2) f.seek(0, 2)
while True: while True:
line = f.readline() line = f.readline()
if line: if line:
logger.debug("LogsController.stream() - Yielding line %s", i)
i += 1
yield line yield line
else: else:
sleep(0.1) sleep(0.1)
def tail(self, back: int = 10): def tail(self, back: int = 10):
logger.debug("LogsController.tail(back=%s)", back)
with self.log_file.open() as f: with self.log_file.open() as f:
for line in deque(f, maxlen=back): for line in deque(f, maxlen=back):
yield line yield line

15
logs.py Normal file
View file

@ -0,0 +1,15 @@
from logging import DEBUG, INFO, FileHandler, Formatter, StreamHandler, getLogger
from config import Config
logger = getLogger(__name__)
logger.setLevel(DEBUG)
console = StreamHandler()
console.setLevel(INFO)
file = FileHandler(Config.MINECRAFTD_LOGS)
file.setLevel(DEBUG)
formatter = Formatter("%(asctime)s - %(levelname)s - %(message)s")
console.setFormatter(formatter)
file.setFormatter(formatter)
logger.addHandler(console)
logger.addHandler(file)

97
main.py
View file

@ -1,11 +1,11 @@
from asyncio import create_task
from typing import Annotated, Optional from typing import Annotated, Optional
import uvicorn import uvicorn
from classes import ProcessStatus from classes import ProcessStatus
from controllers import Controllers from controllers import Controllers
from fastapi import FastAPI, Header from fastapi import BackgroundTasks, FastAPI, Header
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from logs import logger
from models import Models from models import Models
from responses import Responses from responses import Responses
from util import check_password, stop_server from util import check_password, stop_server
@ -14,14 +14,17 @@ app = FastAPI()
@app.get("/start") @app.get("/start")
async def start() -> Responses.StartResponse: async def start(tasks: BackgroundTasks) -> Responses.StartResponse:
"""Starts the Server's process if it is not already running. """Starts the Server's process if it is not already running.
Returns: Returns:
status: "started" or "running". status: "started" or "running".
message: The Server's response. message: The Server's response.
""" """
logger.debug("/start")
if Controllers.process.status() == ProcessStatus.RUNNING: if Controllers.process.status() == ProcessStatus.RUNNING:
logger.debug("/start - The Server was already running")
return { return {
"status": "running", "status": "running",
"message": "The Server was already running.", "message": "The Server was already running.",
@ -29,11 +32,13 @@ async def start() -> Responses.StartResponse:
if Controllers.maintainance.is_set(): if Controllers.maintainance.is_set():
Controllers.maintainance.unset() Controllers.maintainance.unset()
logger.debug("/start - Unset maintainance")
Controllers.process.start() tasks.add_task(Controllers.process.start)
logger.info("/start - Starting server")
return { return {
"status": "started", "status": "starting",
"message": "The Server was started.", "message": "The Server is starting.",
} }
@ -52,22 +57,30 @@ async def status() -> Responses.StatusResponse:
list: the list of online players' usernames as strings. list: the list of online players' usernames as strings.
""" """
logger.debug("/status")
process_status = Controllers.process.status() process_status = Controllers.process.status()
if process_status != ProcessStatus.RUNNING: if process_status != ProcessStatus.RUNNING:
# Crashed # Crashed
if process_status == ProcessStatus.CRASHED: if process_status == ProcessStatus.CRASHED:
logger.debug("/status - The Server has crashed")
return { return {
"status": "crashed", "status": "crashed",
"message": "The Server has crashed.", "message": "The Server has crashed.",
} }
# Maintainance # Maintainance
if Controllers.maintainance.is_set(): if Controllers.maintainance.is_set():
reason = Controllers.maintainance.get()
logger.debug(
'/status - The Server is offline due to maintainance ("%s")', reason
)
return { return {
"status": "maintainance", "status": "maintainance",
"message": "The Server is offline due to maintainance.", "message": "The Server is offline due to maintainance.",
"reason": Controllers.maintainance.get(), "reason": reason,
} }
# Offline # Offline
logger.debug("/status - The Server is offline")
return { return {
"status": "offline", "status": "offline",
"message": "The Server is offline.", "message": "The Server is offline.",
@ -77,12 +90,14 @@ async def status() -> Responses.StatusResponse:
# Starting # Starting
if not server_status["online"]: if not server_status["online"]:
logger.debug("/status - The Server is starting")
return { return {
"status": "starting", "status": "starting",
"message": "The Server is starting.", "message": "The Server is starting.",
} }
# Online # Online
logger.debug("/status - The Server is online")
return { return {
"status": "online", "status": "online",
"message": "The Server is online.", "message": "The Server is online.",
@ -101,7 +116,9 @@ async def status() -> Responses.StatusResponse:
@app.get("/stop") @app.get("/stop")
async def stop( async def stop(
data: Models.StopModel, authorization: Annotated[str, Header()] data: Models.StopModel,
authorization: Annotated[str, Header()],
tasks: BackgroundTasks,
) -> Responses.StopResponse: ) -> Responses.StopResponse:
"""Stops the Server. """Stops the Server.
It waits for `countdown` seconds, then runs `/stop` on the Server, and kills it after `timeout` seconds if it's still alive. It waits for `countdown` seconds, then runs `/stop` on the Server, and kills it after `timeout` seconds if it's still alive.
@ -117,8 +134,12 @@ async def stop(
message: The Server's response. message: The Server's response.
""" """
logger.debug("/stop")
check_password(authorization) check_password(authorization)
create_task(stop_server("STOPPING", data.countdown, data.reason, data.timeout)) tasks.add_task(stop_server, "STOPPING", data.countdown, data.reason, data.timeout)
logger.info("/stop - The Server is stopping")
return { return {
"status": "stopping", "status": "stopping",
"message": "The Server is stopping.", "message": "The Server is stopping.",
@ -127,7 +148,9 @@ async def stop(
@app.get("/restart") @app.get("/restart")
async def restart( async def restart(
data: Models.RestartModel, authorization: Annotated[str, Header()] data: Models.RestartModel,
authorization: Annotated[str, Header()],
tasks: BackgroundTasks,
) -> Responses.RestartResponse: ) -> Responses.RestartResponse:
"""Restarts the Server. """Restarts the Server.
It waits for `countdown` seconds, then runs `/stop` on the Server, and kills it after `timeout` seconds if it's still alive. It waits for `countdown` seconds, then runs `/stop` on the Server, and kills it after `timeout` seconds if it's still alive.
@ -145,15 +168,20 @@ async def restart(
""" """
check_password(authorization) check_password(authorization)
create_task(
stop_server( logger.debug("/restart")
"RESTARTING",
data.countdown, tasks.add_task(
data.reason, stop_server,
data.timeout, "RESTARTING",
Controllers.process.start, data.countdown,
) data.reason,
data.timeout,
Controllers.process.start,
) )
logger.info("/restart - The Server is restarting")
return { return {
"status": "restarting", "status": "restarting",
"message": "The Server is restarting.", "message": "The Server is restarting.",
@ -162,7 +190,9 @@ async def restart(
@app.get("/maintainance") @app.get("/maintainance")
async def maintainance( async def maintainance(
data: Models.MaintainanceModel, authorization: Annotated[str, Header()] data: Models.MaintainanceModel,
authorization: Annotated[str, Header()],
tasks: BackgroundTasks,
) -> Responses.MaintainanceResponse: ) -> Responses.MaintainanceResponse:
"""Stops the Server and sets it to maintainance status. """Stops the Server and sets it to maintainance status.
It waits for `countdown` seconds, then runs `/stop` on the Server, and kills it after `timeout` seconds if it's still alive. It waits for `countdown` seconds, then runs `/stop` on the Server, and kills it after `timeout` seconds if it's still alive.
@ -179,15 +209,21 @@ async def maintainance(
""" """
check_password(authorization) check_password(authorization)
create_task(
stop_server( logger.debug("/maintainance")
"STOPPING FOR MAINTAINANCE",
data.countdown, tasks.add_task(
data.reason, stop_server,
data.timeout, "STOPPING FOR MAINTAINANCE",
Controllers.maintainance.set(data.reason), data.countdown,
) data.reason,
data.timeout,
Controllers.maintainance.set,
data.reason,
) )
logger.info("/maintainance - The Server is stopping for maintainance")
return { return {
"status": "stopping", "status": "stopping",
"message": "The Server is stopping for maintainance.", "message": "The Server is stopping for maintainance.",
@ -210,11 +246,14 @@ async def command(
output: The command's output. output: The command's output.
""" """
logger.debug('/command {command: "%s"}', data.command)
check_password(authorization) check_password(authorization)
output = Controllers.server.command(data.command)
return { return {
"status": "executed", "status": "executed",
"message": "The command was executed.", "message": "The command was executed.",
"output": Controllers.server.command(data.command), "output": output,
} }
@ -227,6 +266,7 @@ async def logs_stream(authorization: Annotated[str, Header()]) -> StreamingRespo
Returns: text/event-stream Returns: text/event-stream
""" """
logger.debug("/logs/stream")
check_password(authorization) check_password(authorization)
return StreamingResponse(Controllers.logs.stream(), media_type="text/event-stream") return StreamingResponse(Controllers.logs.stream(), media_type="text/event-stream")
@ -244,6 +284,7 @@ async def logs_tail(
Returns: text/event-stream Returns: text/event-stream
""" """
logger.debug("/logs/tail")
check_password(authorization) check_password(authorization)
return StreamingResponse(Controllers.logs.tail(data.back if data else 10)) return StreamingResponse(Controllers.logs.tail(data.back if data else 10))

View file

@ -4,7 +4,7 @@ from typing_extensions import TypedDict
class StartResponse(TypedDict): class StartResponse(TypedDict):
status: Literal["running", "started"] status: Literal["running", "starting"]
message: str message: str

34
util.py
View file

@ -1,14 +1,21 @@
from asyncio import sleep from time import sleep
from typing import Callable from typing import Callable
from classes import ProcessStatus from classes import ProcessStatus
from config import Config from config import Config
from controllers import Controllers from controllers import Controllers
from fastapi import HTTPException from fastapi import HTTPException
from logs import logger
async def stop_server( def stop_server(
action: str, countdown: int, reason: str, timeout: int, then: Callable | None = None action: str,
countdown: int,
reason: str,
timeout: int,
then: Callable | None = None,
*args,
**kwargs,
): ):
"""Warns the players, stops the Server, and kills its process if its taking too long. """Warns the players, stops the Server, and kills its process if its taking too long.
@ -19,27 +26,42 @@ async def stop_server(
timeout (int): Seconds to wait before killing the process. timeout (int): Seconds to wait before killing the process.
then (Callable | None) (default: None): Function to be called after the Server is stopped. then (Callable | None) (default: None): Function to be called after the Server is stopped.
""" """
logger.debug(
'stop_server(action="%s", countdown=%s, reason="%s", timeout=%s, then=%s, args=%s, kwargs=%s',
action,
countdown,
reason,
timeout,
then,
args,
kwargs,
)
if countdown: if countdown:
logger.debug("stop_server(...) - Starting countdown")
Controllers.server.command(f"say SERVER IS {action} IN {countdown} SECONDS!!!") Controllers.server.command(f"say SERVER IS {action} IN {countdown} SECONDS!!!")
Controllers.server.command(f"say REASON: '{reason}'") Controllers.server.command(f"say REASON: '{reason}'")
while countdown > 0 and Controllers.server.status().get("players", {}).get( while countdown > 0 and Controllers.server.status().get("players", {}).get(
"online", 0 "online", 0
): ):
await sleep(1) sleep(1)
countdown -= 1 countdown -= 1
logger.info("stop_server(...) - Stopping server")
Controllers.server.command("stop") Controllers.server.command("stop")
while timeout > 0 and Controllers.process.status() == ProcessStatus.RUNNING: while timeout > 0 and Controllers.process.status() == ProcessStatus.RUNNING:
await sleep(1) sleep(1)
timeout -= 1 timeout -= 1
if Controllers.process.status() == ProcessStatus.RUNNING: if Controllers.process.status() == ProcessStatus.RUNNING:
logger.warning("stop_server(...) - Killing server")
Controllers.process.kill() Controllers.process.kill()
if then: if then:
then() logger.debug("stop_server(...) - Running callback")
then(*args, **kwargs)
def check_password(password: str): def check_password(password: str):
if password != Config.MINECRAFTD_PASSWORD: if password != Config.MINECRAFTD_PASSWORD:
logger.debug('check_password("%s") - Password is invalid', password)
raise HTTPException(401, "Password is invalid") raise HTTPException(401, "Password is invalid")