Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python. Each phase file is fully self-contained: lists Java source files to read, output files to create, implementation patterns, REST endpoint contracts, and a completion checklist. A future agent can execute any single phase without rescanning the Java project. Phases: - 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas - 02: Auth & Users — JWT middleware, RBAC, user CRUD - 03: CFG parser + server process — server.cfg round-trip, start/stop - 04: Server settings — general/network/logging/security/difficulty - 05: Mod management — mod CRUD, presets, settings, WebSocket progress - 06: Steam integration — SteamCMD queue, Workshop API, python-a2s - 07: Missions, CDLC, Discord, APScheduler jobs - 08: Middleware & polish — global exception handler, SPA redirect, structlog - 09: Testing — pytest-asyncio, respx, 80% coverage target
257 lines
9.2 KiB
Markdown
257 lines
9.2 KiB
Markdown
# Phase 3 — CFG Parser + Server Process Management
|
|
|
|
**Status**: PENDING
|
|
**Depends on**: Phase 1 complete
|
|
**Next phase**: `phase-04-server-settings.md`
|
|
**Risk**: HIGH — CFG parser must be bit-perfect; server process management is complex.
|
|
|
|
---
|
|
|
|
## Goal
|
|
|
|
Two deliverables built in this phase:
|
|
|
|
1. **Arma `.cfg` file parser** — reads and writes Arma server config files. Used by Phases 4 and 5.
|
|
2. **Server process management** — start/stop the Arma executable, PID tracking, cross-platform liveness check, SSE log streaming, status endpoint.
|
|
|
|
After this phase: `GET /api/v1/status` returns server status; `GET /api/v1/logging/logs-sse` streams server log lines.
|
|
|
|
---
|
|
|
|
## Java Source Files to Read
|
|
|
|
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
|
|
|
|
### CFG Parser — read every file in this tree
|
|
```
|
|
<JAVA_SRC>domain/server/storage/util/cfg/CfgFileHandler.java
|
|
<JAVA_SRC>domain/server/storage/util/cfg/DefaultCfgConfigReader.java
|
|
<JAVA_SRC>domain/server/storage/util/cfg/DefaultCfgConfigWriter.java
|
|
<JAVA_SRC>application/config/CfgHandlerConfig.java (shows how parsers are wired)
|
|
<JAVA_SRC>domain/server/storage/util/cfg/parser/ (all 8 parser classes)
|
|
<JAVA_SRC>domain/server/storage/util/cfg/annotation/ (CfgProperty, ClassName, ClassList)
|
|
<JAVA_SRC>domain/server/storage/util/cfg/util/ (all utility classes)
|
|
<JAVA_SRC>domain/server/storage/config/ServerConfigStorageImpl.java
|
|
<JAVA_SRC>domain/server/storage/config/model/ (all model classes)
|
|
```
|
|
|
|
### Server Process
|
|
```
|
|
<JAVA_SRC>domain/server/process/ProcessServiceImpl.java (388 lines — read fully)
|
|
<JAVA_SRC>domain/server/process/ArmaServerParametersGeneratorImpl.java
|
|
<JAVA_SRC>domain/server/process/WindowsProcessAliveChecker.java
|
|
<JAVA_SRC>domain/server/process/UnixProcessAliveChecker.java
|
|
<JAVA_SRC>domain/server/process/ServerStatusService.java
|
|
<JAVA_SRC>domain/server/process/dto/ServerStatus.java
|
|
<JAVA_SRC>domain/server/process/model/ServerProcessStatus.java
|
|
<JAVA_SRC>domain/server/process/model/ArmaServerParameters.java
|
|
<JAVA_SRC>domain/server/process/log/ServerProcessLogMessageObserver.java
|
|
<JAVA_SRC>domain/server/process/log/SseServerServerProcessLogsObserver.java
|
|
<JAVA_SRC>domain/server/process/log/LoggerServerProcessLogsObserver.java
|
|
<JAVA_SRC>domain/server/process/log/FileServerProcessLogsObserver.java
|
|
<JAVA_SRC>web/StatusController.java
|
|
<JAVA_SRC>web/LoggingRestController.java (SSE endpoint only; properties endpoint in Phase 4)
|
|
```
|
|
|
|
---
|
|
|
|
## Output Files to Create
|
|
|
|
```
|
|
src/domain/server/__init__.py
|
|
src/domain/server/storage/__init__.py
|
|
src/domain/server/storage/cfg/__init__.py
|
|
src/domain/server/storage/cfg/cfg_file_handler.py
|
|
src/domain/server/storage/cfg/cfg_reader.py
|
|
src/domain/server/storage/cfg/cfg_writer.py
|
|
src/domain/server/storage/cfg/cfg_parsers.py
|
|
src/domain/server/storage/cfg/cfg_annotations.py
|
|
src/domain/server/storage/config/__init__.py
|
|
src/domain/server/storage/config/server_config_storage.py
|
|
src/domain/server/storage/config/models.py
|
|
src/domain/server/process/__init__.py
|
|
src/domain/server/process/process_service.py
|
|
src/domain/server/process/process_alive_checker.py
|
|
src/domain/server/process/server_status_service.py
|
|
src/domain/server/process/parameters_generator.py
|
|
src/domain/server/process/log_observers.py
|
|
src/domain/server/process/models.py
|
|
src/web/schemas/status.py
|
|
src/web/status_router.py
|
|
```
|
|
|
|
Update `src/main.py`: register `status_router`, add SSE route from `log_observers`.
|
|
|
|
---
|
|
|
|
## Implementation Notes
|
|
|
|
### CFG Parser — replacing Java annotation-reflection with Python dataclass metadata
|
|
|
|
Java uses `@CfgProperty(name="maxPlayers")` + reflection to map fields to cfg keys.
|
|
Python equivalent: `dataclasses.field(metadata={"cfg_name": "maxPlayers", "parser": "integer"})`.
|
|
|
|
```python
|
|
# cfg_annotations.py
|
|
from dataclasses import field
|
|
|
|
def cfg_property(cfg_name: str, parser: str = "string", default=None):
|
|
return field(default=default, metadata={"cfg_name": cfg_name, "parser": parser})
|
|
|
|
def class_name(name: str):
|
|
return field(default=name, metadata={"class_name": name})
|
|
```
|
|
|
|
Example config model:
|
|
```python
|
|
@dataclass
|
|
class NetworkConfig:
|
|
max_players: int = cfg_property("maxPlayers", parser="integer", default=32)
|
|
loopback: bool = cfg_property("loopback", parser="bool", default=False)
|
|
hostname: str = cfg_property("hostname", parser="quoted_string", default="")
|
|
```
|
|
|
|
### CFG Reader — core algorithm
|
|
|
|
Arma `.cfg` format:
|
|
```
|
|
maxPlayers = 32;
|
|
hostname = "My Server";
|
|
class Missions {
|
|
class Mission_1 {
|
|
template = "Stratis.Stratis";
|
|
difficulty = "Regular";
|
|
};
|
|
};
|
|
```
|
|
|
|
Parse steps:
|
|
1. Strip `//` line comments and `/* */` block comments
|
|
2. Tokenize: `class`, `{`, `}`, `;`, `=`, identifiers, quoted strings, numbers
|
|
3. Recursively process `class NAME { ... };` blocks
|
|
4. For `key = value;` lines: look up field in dataclass by `cfg_name` metadata, call appropriate parser
|
|
|
|
### CFG Parsers (port 8 Java parser classes as functions)
|
|
|
|
```python
|
|
# cfg_parsers.py
|
|
def parse_quoted_string(s: str) -> str: # strips surrounding quotes
|
|
def parse_raw_string(s: str) -> str: # as-is
|
|
def parse_integer(s: str) -> int:
|
|
def parse_long(s: str) -> int:
|
|
def parse_bool(s: str) -> bool: # 1/0 or true/false
|
|
def parse_string_array(s: str) -> list[str]: # parses {"a","b","c"}
|
|
def parse_class(tokens, cls: type): # recursive class parser
|
|
def parse_class_list(tokens, cls: type) -> list: # list of class instances
|
|
```
|
|
|
|
**CRITICAL**: Write round-trip tests before implementing Phase 4:
|
|
```python
|
|
def test_roundtrip_network_config(tmp_path):
|
|
original = 'maxPlayers = 32;\nhostname = "Test";\n'
|
|
cfg = cfg_reader.read(original, NetworkConfig)
|
|
written = cfg_writer.write(cfg)
|
|
re_read = cfg_reader.read(written, NetworkConfig)
|
|
assert cfg == re_read
|
|
```
|
|
|
|
### Process alive checker
|
|
|
|
Replace both Java Windows/Unix checkers with one `psutil` function:
|
|
```python
|
|
import psutil, sys
|
|
|
|
def is_pid_alive(pid: int) -> bool:
|
|
if pid == 0:
|
|
return False
|
|
try:
|
|
proc = psutil.Process(pid)
|
|
return proc.status() not in (psutil.STATUS_DEAD, psutil.STATUS_ZOMBIE)
|
|
except psutil.NoSuchProcess:
|
|
return False
|
|
```
|
|
|
|
### Process service — key patterns from ProcessServiceImpl.java
|
|
|
|
- PID file: `<server_dir>/arma_server.pid` — plain text integer, `0` means not running
|
|
- Startup lock: `asyncio.Lock()` (replaces Java `ReentrantLock`)
|
|
- Status transitions: `NOT_RUNNING → STARTING → RUNNING`, `RUNNING → NOT_RUNNING`
|
|
- Process launched: `subprocess.Popen(cmd, stdout=PIPE, stderr=PIPE)`
|
|
- stdout/stderr reader: two `threading.Thread` daemons reading lines, calling `_notify_observers(line)`
|
|
- Stop: `psutil.Process(pid).terminate()`, save pid=0
|
|
|
|
Observers list (`list[LogObserver]`) is wired in Phase 7 for Discord and at startup for SSE/file observers.
|
|
|
|
### SSE log streaming — log_observers.py
|
|
|
|
```python
|
|
import asyncio, uuid
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
_sse_clients: dict[str, asyncio.Queue] = {}
|
|
|
|
def broadcast(line: str):
|
|
"""Called by process service threads — thread-safe queue put."""
|
|
for q in _sse_clients.values():
|
|
q.put_nowait(line)
|
|
|
|
async def _stream(client_id: str):
|
|
q = asyncio.Queue()
|
|
_sse_clients[client_id] = q
|
|
try:
|
|
while True:
|
|
try:
|
|
line = await asyncio.wait_for(q.get(), timeout=30.0)
|
|
yield f"data: {line}\n\n"
|
|
except asyncio.TimeoutError:
|
|
yield ": heartbeat\n\n"
|
|
finally:
|
|
_sse_clients.pop(client_id, None)
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/api/v1/logging/logs-sse") # permit-all — no auth dependency
|
|
async def logs_sse():
|
|
cid = str(uuid.uuid4())
|
|
return StreamingResponse(_stream(cid), media_type="text/event-stream")
|
|
```
|
|
|
|
### Status endpoints (from StatusController.java)
|
|
|
|
```
|
|
GET /api/v1/status → {"status": "RUNNING"|"NOT_RUNNING"|"STARTING"|"UPDATING"}
|
|
POST /api/v1/status/toggle body: {"performUpdate": true|false} → 200
|
|
```
|
|
|
|
Permission for toggle: `AswgAuthority.SERVER_START_STOP`.
|
|
|
|
---
|
|
|
|
## Completion Checklist
|
|
|
|
### CFG Parser
|
|
- [ ] Parses `key = value;` assignments of all types (string, int, bool, array)
|
|
- [ ] Parses nested `class X { ... };` blocks recursively
|
|
- [ ] Handles quoted strings, `{a,b,c}` arrays, and `//` / `/* */` comments
|
|
- [ ] Round-trip test passes for `NetworkConfig` and `ArmaServerConfig`
|
|
|
|
### Server Process
|
|
- [ ] `is_pid_alive(pid)` works on both Windows and Linux
|
|
- [ ] `start_server()` launches Arma executable and saves PID to file
|
|
- [ ] `stop_server()` terminates the process and writes pid=0
|
|
- [ ] `get_status()` reads PID file and checks liveness
|
|
- [ ] Log lines reach all connected SSE clients
|
|
- [ ] `GET /api/v1/status` → correct status JSON
|
|
- [ ] `POST /api/v1/status/toggle` → starts or stops server
|
|
- [ ] `GET /api/v1/logging/logs-sse` streams lines (no auth required)
|
|
- [ ] Heartbeat ping sent every 30s to keep SSE alive
|
|
|
|
## Contract for Phase 4
|
|
|
|
Phase 4 imports:
|
|
- `from src.domain.server.storage.cfg.cfg_file_handler import CfgFileHandler`
|
|
- `from src.domain.server.storage.config.server_config_storage import ServerConfigStorage`
|
|
- `from src.domain.server.storage.config.models import ArmaServerConfig, NetworkConfig`
|
|
- `from src.domain.server.process.process_service import ProcessService`
|