Files
languard-servers-manager/backend/core/servers/service.py
Tran G. (Revernomad) Khoa 6e9a37ef00 fix: use absolute paths in Arma 3 launch args to survive cwd change
Arma 3 changes its own working directory to the exe folder on startup,
so relative paths like -config=server.cfg resolved against A3Master/
instead of the server data dir. Configs were never found, and profile/
battleye dirs pointed at the wrong location (confirmed via RPT location
in A3Master/server/ instead of the data dir).

build_launch_args() now accepts an optional server_dir: Path argument.
When provided, all four path args (-config, -cfg, -profiles, -bepath)
use absolute paths. Service passes server_dir at the call site.
2026-04-20 11:13:18 +07:00

564 lines
23 KiB
Python

"""
ServerService — orchestrates all server lifecycle operations.
Delegates game-specific work to the adapter.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from fastapi import HTTPException, status
from sqlalchemy.engine import Connection
from adapters.registry import GameAdapterRegistry
from core.dal.config_repository import ConfigRepository
from core.dal.event_repository import EventRepository
from core.dal.server_repository import ServerRepository
from core.servers.process_manager import ProcessManager
from core.utils.file_utils import ensure_server_dirs, get_server_dir
logger = logging.getLogger(__name__)
def _ok_response(data):
return {"success": True, "data": data, "error": None}
class ServerService:
def __init__(self, db: Connection):
self._db = db
self._server_repo = ServerRepository(db)
self._config_repo = ConfigRepository(db)
self._event_repo = EventRepository(db)
# ── CRUD ──────────────────────────────────────────────────────────────────
def list_servers(self, game_type: str | None = None) -> list[dict]:
"""Return server list with live metrics merged in."""
servers = self._server_repo.get_all(game_type)
return [self._enrich_server(s) for s in servers]
def get_server(self, server_id: int) -> dict:
server = self._server_repo.get_by_id(server_id)
if server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"},
)
return self._enrich_server(server)
def _enrich_server(self, server: dict) -> dict:
"""Add live CPU/RAM/player count from DB."""
from core.dal.metrics_repository import MetricsRepository
from core.dal.player_repository import PlayerRepository
result = dict(server)
metrics = MetricsRepository(self._db).get_latest(server["id"])
if metrics:
result["cpu_percent"] = metrics["cpu_percent"]
result["ram_mb"] = metrics["ram_mb"]
else:
result["cpu_percent"] = None
result["ram_mb"] = None
result["player_count"] = PlayerRepository(self._db).count(server["id"])
return result
def create_server(
self,
name: str,
game_type: str,
exe_path: str,
game_port: int,
rcon_port: int | None = None,
description: str | None = None,
auto_restart: bool = False,
max_restarts: int = 3,
) -> dict:
# Validate adapter exists
try:
adapter = GameAdapterRegistry.get(game_type)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "GAME_TYPE_NOT_FOUND", "message": f"Unknown game type: {game_type}"},
)
# Validate exe
process_config = adapter.get_process_config()
exe_name = Path(exe_path).name
if exe_name not in process_config.get_allowed_executables():
from adapters.exceptions import ExeNotAllowedError
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "EXE_NOT_ALLOWED",
"message": f"Executable '{exe_name}' not allowed",
"allowed": process_config.get_allowed_executables(),
},
)
# Determine rcon_port if not provided
if rcon_port is None:
rcon_port = process_config.get_default_rcon_port(game_port)
# Check port conflicts against running servers
from core.utils.port_checker import check_ports_against_running_servers
conflicts = check_ports_against_running_servers(game_port, rcon_port, None, self._db)
if conflicts:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "PORT_IN_USE",
"message": f"Ports already in use: {conflicts}",
},
)
# Create DB row
server_id = self._server_repo.create(
name=name,
game_type=game_type,
exe_path=exe_path,
game_port=game_port,
rcon_port=rcon_port,
description=description,
auto_restart=auto_restart,
max_restarts=max_restarts,
)
# Create directory layout with per-directory README files
layout = process_config.get_server_dir_layout()
readme_fn = getattr(process_config, "get_dir_readme", None)
ensure_server_dirs(server_id, layout, readme_provider=readme_fn)
# Seed default config sections
config_gen = adapter.get_config_generator()
schema_version = config_gen.get_config_version()
for section in config_gen.get_sections():
defaults = config_gen.get_defaults(section)
sensitive = config_gen.get_sensitive_fields(section)
self._config_repo.upsert_section(
server_id=server_id,
game_type=game_type,
section=section,
config_data=defaults,
schema_version=schema_version,
sensitive_fields=sensitive,
)
self._event_repo.insert(server_id, "created", actor="admin")
return self.get_server(server_id)
def update_server(self, server_id: int, **updates) -> dict:
self.get_server(server_id) # raises 404 if not found
filtered = {k: v for k, v in updates.items() if v is not None}
if filtered:
self._server_repo.update(server_id, **filtered)
return self.get_server(server_id)
def delete_server(self, server_id: int) -> None:
server = self.get_server(server_id)
if server["status"] not in ("stopped", "crashed", "error"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "SERVER_NOT_STOPPED",
"message": "Server must be stopped before deletion",
},
)
self._server_repo.delete(server_id)
# Delete server directory
server_dir = get_server_dir(server_id)
if server_dir.exists():
shutil.rmtree(str(server_dir), ignore_errors=True)
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self, server_id: int) -> dict:
"""
Full start sequence:
1. Load server + adapter
2. Validate exe
3. Check ports
4. Write config files (atomic)
5. Build launch args
6. Start process
7. Start monitoring threads
8. Return status
"""
from adapters.exceptions import (
ConfigWriteError, ExeNotAllowedError,
LaunchArgsError, ConfigValidationError,
)
from core.utils.port_checker import check_ports_against_running_servers
server = self.get_server(server_id)
if server["status"] in ("running", "starting"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "SERVER_ALREADY_RUNNING", "message": "Server is already running"},
)
adapter = GameAdapterRegistry.get(server["game_type"])
process_config = adapter.get_process_config()
config_gen = adapter.get_config_generator()
# Validate exe
exe_name = Path(server["exe_path"]).name
if exe_name not in process_config.get_allowed_executables():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "EXE_NOT_ALLOWED",
"message": f"Executable '{exe_name}' not in adapter allowlist",
"allowed": process_config.get_allowed_executables(),
},
)
# Check ports
conflicts = check_ports_against_running_servers(
server["game_port"], server.get("rcon_port"), server_id, self._db
)
if conflicts:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "PORT_IN_USE", "message": f"Ports in use: {conflicts}"},
)
# Load config sections (decrypt sensitive fields for config generation)
sensitive_by_section = {
s: config_gen.get_sensitive_fields(s)
for s in config_gen.get_sections()
}
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
# Remove _meta from each section before passing to adapter
raw_sections = {
section: {k: v for k, v in data.items() if k != "_meta"}
for section, data in sections.items()
}
# Inject port into sections so build_launch_args can use it
if "_port" not in raw_sections:
raw_sections["_port"] = server["game_port"]
# Get mod args if adapter supports mods
mod_args: list[str] = []
if adapter.has_capability("mod_manager"):
mod_mgr = adapter.get_mod_manager(server_id)
enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
server_dir = get_server_dir(server_id)
mod_list = [
{
"folder_path": str(server_dir / "mods" / m["name"]),
"game_data": {"is_server_mod": m.get("is_server_mod", False)},
}
for m in enabled_mods
]
mod_args = mod_mgr.build_mod_args(mod_list)
# Write config files (atomic)
server_dir = get_server_dir(server_id)
try:
config_gen.write_configs(server_id, server_dir, raw_sections)
except ConfigWriteError as e:
self._server_repo.update_status(server_id, "error")
self._event_repo.insert(server_id, "config_write_error", detail={"error": str(e)})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "CONFIG_WRITE_ERROR", "message": str(e)},
)
except ConfigValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"code": "INVALID_CONFIG", "message": str(e), "errors": e.errors},
)
# Build launch args
try:
launch_args = config_gen.build_launch_args(raw_sections, mod_args, server_dir=server_dir)
except LaunchArgsError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "INVALID_CONFIG", "message": str(e)},
)
# Start process
pm = ProcessManager.get()
with pm.get_operation_lock(server_id):
pid = pm.start(server_id, server["exe_path"], launch_args, cwd=str(server_dir))
# Update DB
from datetime import datetime, timezone
self._server_repo.update_status(
server_id, "starting", pid=pid,
started_at=datetime.now(timezone.utc).isoformat()
)
self._event_repo.insert(server_id, "started", detail={"pid": pid})
# Start monitoring threads
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.start_server_threads(server_id, self._db)
except Exception as e:
logger.warning("Could not start monitoring threads: %s", e)
return {"status": "starting", "pid": pid}
def stop(self, server_id: int, force: bool = False) -> dict:
server = self.get_server(server_id)
if server["status"] in ("stopped", "crashed"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "SERVER_NOT_RUNNING", "message": "Server is not running"},
)
# Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash
self._server_repo.update_status(server_id, "stopping")
# Stop monitoring threads first so they don't fight with shutdown
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.stop_server_threads(server_id)
except Exception as exc:
logger.warning("Failed to stop monitoring threads for server %d during stop: %s", server_id, exc)
# Try graceful shutdown via remote admin
if not force:
try:
pm = ProcessManager.get()
logger.info("Sending graceful shutdown to server %d", server_id)
except Exception as e:
logger.warning("Graceful shutdown failed: %s, falling back to terminate", e)
pm = ProcessManager.get()
with pm.get_operation_lock(server_id):
exited = pm.stop(server_id, timeout=30)
if not exited:
logger.warning("Server %d did not exit in 30s, force-killing", server_id)
pm.kill(server_id)
from datetime import datetime, timezone
self._server_repo.update_status(
server_id, "stopped",
pid=None, stopped_at=datetime.now(timezone.utc).isoformat()
)
from core.dal.player_repository import PlayerRepository
PlayerRepository(self._db).clear(server_id)
self._event_repo.insert(server_id, "stopped")
return {"status": "stopped"}
def restart(self, server_id: int) -> dict:
self.stop(server_id)
return self.start(server_id)
def kill(self, server_id: int) -> dict:
server = self.get_server(server_id)
# Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash
self._server_repo.update_status(server_id, "stopping")
# Stop monitoring threads first
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.stop_server_threads(server_id)
except Exception as exc:
logger.warning("Failed to stop monitoring threads for server %d during kill: %s", server_id, exc)
pm = ProcessManager.get()
with pm.get_operation_lock(server_id):
pm.kill(server_id)
from datetime import datetime, timezone
self._server_repo.update_status(server_id, "stopped", pid=None,
stopped_at=datetime.now(timezone.utc).isoformat())
from core.dal.player_repository import PlayerRepository
PlayerRepository(self._db).clear(server_id)
self._event_repo.insert(server_id, "killed")
return {"status": "stopped"}
# ── Config ────────────────────────────────────────────────────────────────
def get_config(self, server_id: int) -> dict:
self.get_server(server_id)
adapter = GameAdapterRegistry.get(
self._server_repo.get_by_id(server_id)["game_type"]
)
config_gen = adapter.get_config_generator()
sensitive_by_section = {
s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections()
}
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
# Mask sensitive fields in response (replace actual value with "***")
for section, data in sections.items():
sf = config_gen.get_sensitive_fields(section)
for field in sf:
if field in data and data[field]:
data[field] = "***"
return sections
def kick_player(self, server_id: int, slot_id: int, reason: str) -> None:
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id)
if not ra or not ra.is_connected():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "RCON_NOT_CONNECTED", "message": "RCon not connected — server must be running"},
)
success = ra.kick_player(int(slot_id), reason)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "KICK_FAILED", "message": "Kick command failed"},
)
def ban_from_player(
self,
server_id: int,
slot_id: int,
reason: str,
duration_minutes: int | None,
banned_by: str,
) -> dict:
from datetime import datetime, timezone, timedelta
from core.dal.player_repository import PlayerRepository
from core.dal.ban_repository import BanRepository
player = PlayerRepository(self._db).get_by_slot(server_id, slot_id)
if not player:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": "Player not found"},
)
expires_at = None
if duration_minutes is not None and duration_minutes > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat()
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id)
if ra and ra.is_connected():
ra.ban_player(player["guid"], duration_minutes or 0, reason)
ban_repo = BanRepository(self._db)
ban_id = ban_repo.create(
server_id=server_id,
guid=player["guid"],
name=player["name"],
reason=reason,
banned_by=banned_by,
expires_at=expires_at,
)
return dict(ban_repo.get_by_id(ban_id))
def get_config_schema(self, server_id: int) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
if hasattr(config_gen, "get_ui_schema"):
return config_gen.get_ui_schema()
return {}
def get_config_section(self, server_id: int, section: str) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
if section not in config_gen.get_sections():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"},
)
sensitive = config_gen.get_sensitive_fields(section)
data = self._config_repo.get_section(server_id, section, sensitive)
if data is None:
data = config_gen.get_defaults(section)
data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()}
if hasattr(config_gen, "normalize_section"):
data = config_gen.normalize_section(section, data)
# Mask sensitive fields
for field in sensitive:
if field in data and data[field]:
data[field] = "***"
return data
def update_config_section(
self,
server_id: int,
section: str,
data: dict,
expected_version: int | None = None,
) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
sections = config_gen.get_sections()
if section not in sections:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"},
)
# Validate against adapter's Pydantic model
model_cls = sections[section]
# Get current values, merge with update (partial update support)
current = self._config_repo.get_section(
server_id, section, config_gen.get_sensitive_fields(section)
)
if current:
merged = {k: v for k, v in current.items() if k != "_meta"}
else:
merged = config_gen.get_defaults(section)
# Apply updates
for k, v in data.items():
if k not in ("_meta", "config_version"):
merged[k] = v
# Validate
try:
model_cls(**merged)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"code": "INVALID_CONFIG", "message": str(e)},
)
sensitive = config_gen.get_sensitive_fields(section)
try:
new_version = self._config_repo.upsert_section(
server_id=server_id,
game_type=server["game_type"],
section=section,
config_data=merged,
schema_version=config_gen.get_config_version(),
sensitive_fields=sensitive,
expected_config_version=expected_version,
)
except ValueError as e:
error_msg = str(e)
if "CONFIG_VERSION_CONFLICT" in error_msg:
current_version = int(error_msg.split(":")[1])
current_data = self.get_config_section(server_id, section)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "CONFIG_VERSION_CONFLICT",
"message": "Config was modified by another user. Re-read and merge.",
"current_config": current_data,
"current_version": current_version,
},
)
raise
self._event_repo.insert(
server_id, "config_updated", detail={"section": section, "version": new_version}
)
return self.get_config_section(server_id, section)
def get_config_preview(self, server_id: int) -> dict[str, str]:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
sensitive_by_section = {
s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections()
}
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
raw_sections = {k: {kk: vv for kk, vv in v.items() if kk != "_meta"} for k, v in sections.items()}
server_dir = get_server_dir(server_id)
return config_gen.preview_config(server_id, server_dir, raw_sections)