ops: add channel inventory and metinctl
This commit is contained in:
@@ -61,6 +61,7 @@ Additional operational notes:
|
|||||||
|
|
||||||
- [Debian Runtime](docs/debian-runtime.md)
|
- [Debian Runtime](docs/debian-runtime.md)
|
||||||
- [Healthchecks](docs/healthchecks.md)
|
- [Healthchecks](docs/healthchecks.md)
|
||||||
|
- [Server Management](docs/server-management.md)
|
||||||
|
|
||||||
Example installation:
|
Example installation:
|
||||||
|
|
||||||
|
|||||||
109
channel_inventory.py
Normal file
109
channel_inventory.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
|
INVENTORY_PATH = REPO_ROOT / "deploy" / "channel-inventory.json"
|
||||||
|
|
||||||
|
STACK_UNIT = "metin-server.service"
|
||||||
|
DB_UNIT = "metin-db.service"
|
||||||
|
DB_READY_UNIT = "metin-db-ready.service"
|
||||||
|
AUTH_UNIT = "metin-auth.service"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_inventory() -> dict[str, Any]:
|
||||||
|
return json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> dict[str, Any]:
|
||||||
|
return dict(load_inventory()["db"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth() -> dict[str, Any]:
|
||||||
|
return dict(load_inventory()["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def iter_channels() -> list[dict[str, Any]]:
|
||||||
|
channels = load_inventory()["channels"]
|
||||||
|
return sorted((dict(channel) for channel in channels), key=lambda channel: int(channel["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel(channel_id: int) -> dict[str, Any]:
|
||||||
|
for channel in iter_channels():
|
||||||
|
if int(channel["id"]) == int(channel_id):
|
||||||
|
return channel
|
||||||
|
raise KeyError(f"Unknown channel id: {channel_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_core(channel_id: int, core_id: int) -> dict[str, Any]:
|
||||||
|
channel = get_channel(channel_id)
|
||||||
|
for core in channel["cores"]:
|
||||||
|
if int(core["id"]) == int(core_id):
|
||||||
|
return dict(core)
|
||||||
|
raise KeyError(f"Unknown core {core_id} for channel {channel_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_ids() -> list[int]:
|
||||||
|
return [int(channel["id"]) for channel in iter_channels()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_map() -> dict[int, dict[int, str]]:
|
||||||
|
result: dict[int, dict[int, str]] = {}
|
||||||
|
for channel in iter_channels():
|
||||||
|
result[int(channel["id"])] = {
|
||||||
|
int(core["id"]): str(core["map_allow"])
|
||||||
|
for core in channel["cores"]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def instance_name(channel_id: int, core_id: int) -> str:
|
||||||
|
return f"channel{int(channel_id)}_core{int(core_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def game_unit(instance: str) -> str:
|
||||||
|
return f"metin-game@{instance}.service"
|
||||||
|
|
||||||
|
|
||||||
|
def get_instances(selected_channel_ids: Iterable[int] | None = None) -> list[str]:
|
||||||
|
selected = None if selected_channel_ids is None else {int(channel_id) for channel_id in selected_channel_ids}
|
||||||
|
instances: list[str] = []
|
||||||
|
for channel in iter_channels():
|
||||||
|
channel_id = int(channel["id"])
|
||||||
|
if selected is not None and channel_id not in selected:
|
||||||
|
continue
|
||||||
|
for core in sorted(channel["cores"], key=lambda core: int(core["id"])):
|
||||||
|
instances.append(instance_name(channel_id, int(core["id"])))
|
||||||
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_units(selected_channel_ids: Iterable[int] | None = None) -> list[str]:
|
||||||
|
return [game_unit(instance) for instance in get_instances(selected_channel_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_selected_channels(
|
||||||
|
channel_limit: int | None = None,
|
||||||
|
explicit_channels: Iterable[int] | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
available = set(get_channel_ids())
|
||||||
|
|
||||||
|
if explicit_channels:
|
||||||
|
selected = {int(channel_id) for channel_id in explicit_channels}
|
||||||
|
elif channel_limit is None or channel_limit == 0:
|
||||||
|
selected = set(available)
|
||||||
|
else:
|
||||||
|
selected = {channel_id for channel_id in available if channel_id <= channel_limit}
|
||||||
|
|
||||||
|
for channel in iter_channels():
|
||||||
|
if channel.get("always_include"):
|
||||||
|
selected.add(int(channel["id"]))
|
||||||
|
|
||||||
|
unknown = sorted(selected - available)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown channels requested: {unknown}")
|
||||||
|
|
||||||
|
return sorted(selected)
|
||||||
23
channels.py
23
channels.py
@@ -1,19 +1,6 @@
|
|||||||
## Map Allow Layout
|
from channel_inventory import get_channel_map
|
||||||
MAP_ALLOW_NORMAL = {
|
|
||||||
1 : "1 4 5 6 3 23 43 112 107 67 68 72 208 302 304",
|
|
||||||
2 : "21 24 25 26 108 61 63 69 70 73 216 217 303 352",
|
|
||||||
3 : "41 44 45 46 109 62 64 65 66 71 104 301 351",
|
|
||||||
}
|
|
||||||
|
|
||||||
MAP_ALLOW_SPECIAL = {
|
# Compatibility shim for older scripts that still import channels.py directly.
|
||||||
1 : "113 81 100 101 103 105 110 111 114 118 119 120 121 122 123 124 125 126 127 128 181 182 183 200"
|
CHANNEL_MAP = get_channel_map()
|
||||||
}
|
MAP_ALLOW_NORMAL = dict(CHANNEL_MAP.get(1, {}))
|
||||||
|
MAP_ALLOW_SPECIAL = dict(CHANNEL_MAP.get(99, {}))
|
||||||
## Channel Layout
|
|
||||||
CHANNEL_MAP = {
|
|
||||||
1: MAP_ALLOW_NORMAL,
|
|
||||||
2: MAP_ALLOW_NORMAL,
|
|
||||||
3: MAP_ALLOW_NORMAL,
|
|
||||||
4: MAP_ALLOW_NORMAL,
|
|
||||||
99: MAP_ALLOW_SPECIAL,
|
|
||||||
}
|
|
||||||
|
|||||||
131
deploy/channel-inventory.json
Normal file
131
deploy/channel-inventory.json
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{
|
||||||
|
"db": {
|
||||||
|
"port": 9000
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"channel": 1,
|
||||||
|
"port": 11000,
|
||||||
|
"p2p_port": 12000
|
||||||
|
},
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "CH1",
|
||||||
|
"public": true,
|
||||||
|
"client_visible": true,
|
||||||
|
"cores": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 11011,
|
||||||
|
"p2p_port": 12011,
|
||||||
|
"map_allow": "1 4 5 6 3 23 43 112 107 67 68 72 208 302 304"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"port": 11012,
|
||||||
|
"p2p_port": 12012,
|
||||||
|
"map_allow": "21 24 25 26 108 61 63 69 70 73 216 217 303 352"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"port": 11013,
|
||||||
|
"p2p_port": 12013,
|
||||||
|
"map_allow": "41 44 45 46 109 62 64 65 66 71 104 301 351"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "CH2",
|
||||||
|
"public": true,
|
||||||
|
"client_visible": true,
|
||||||
|
"cores": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 11021,
|
||||||
|
"p2p_port": 12021,
|
||||||
|
"map_allow": "1 4 5 6 3 23 43 112 107 67 68 72 208 302 304"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"port": 11022,
|
||||||
|
"p2p_port": 12022,
|
||||||
|
"map_allow": "21 24 25 26 108 61 63 69 70 73 216 217 303 352"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"port": 11023,
|
||||||
|
"p2p_port": 12023,
|
||||||
|
"map_allow": "41 44 45 46 109 62 64 65 66 71 104 301 351"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "CH3",
|
||||||
|
"public": true,
|
||||||
|
"client_visible": true,
|
||||||
|
"cores": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 11031,
|
||||||
|
"p2p_port": 12031,
|
||||||
|
"map_allow": "1 4 5 6 3 23 43 112 107 67 68 72 208 302 304"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"port": 11032,
|
||||||
|
"p2p_port": 12032,
|
||||||
|
"map_allow": "21 24 25 26 108 61 63 69 70 73 216 217 303 352"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"port": 11033,
|
||||||
|
"p2p_port": 12033,
|
||||||
|
"map_allow": "41 44 45 46 109 62 64 65 66 71 104 301 351"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "CH4",
|
||||||
|
"public": true,
|
||||||
|
"client_visible": true,
|
||||||
|
"cores": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 11041,
|
||||||
|
"p2p_port": 12041,
|
||||||
|
"map_allow": "1 4 5 6 3 23 43 112 107 67 68 72 208 302 304"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"port": 11042,
|
||||||
|
"p2p_port": 12042,
|
||||||
|
"map_allow": "21 24 25 26 108 61 63 69 70 73 216 217 303 352"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"port": 11043,
|
||||||
|
"p2p_port": 12043,
|
||||||
|
"map_allow": "41 44 45 46 109 62 64 65 66 71 104 301 351"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"name": "CH99",
|
||||||
|
"public": false,
|
||||||
|
"client_visible": false,
|
||||||
|
"always_include": true,
|
||||||
|
"cores": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 11991,
|
||||||
|
"p2p_port": 12991,
|
||||||
|
"map_allow": "113 81 100 101 103 105 110 111 114 118 119 120 121 122 123 124 125 126 127 128 181 182 183 200"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -18,7 +18,11 @@ python3 deploy/systemd/install_systemd.py \
|
|||||||
--restart
|
--restart
|
||||||
```
|
```
|
||||||
|
|
||||||
`--channel-limit 1` is also supported and will auto-include channel `99` when present in `channels.py`.
|
`--channel-limit 1` is also supported and will auto-include channel `99` when present in the channel inventory.
|
||||||
|
|
||||||
|
The channel selection and port layout now come from the versioned inventory file:
|
||||||
|
|
||||||
|
- [deploy/channel-inventory.json](../channel-inventory.json)
|
||||||
|
|
||||||
## What it installs
|
## What it installs
|
||||||
|
|
||||||
@@ -29,9 +33,12 @@ python3 deploy/systemd/install_systemd.py \
|
|||||||
- `metin-game@.service`
|
- `metin-game@.service`
|
||||||
- `/usr/local/libexec/metin-game-instance-start`
|
- `/usr/local/libexec/metin-game-instance-start`
|
||||||
- `/usr/local/libexec/metin-wait-port`
|
- `/usr/local/libexec/metin-wait-port`
|
||||||
|
- `/usr/local/bin/metinctl`
|
||||||
|
|
||||||
The `metin-db-ready.service` gate waits until the DB socket is actually accepting connections before `auth` and `game` units start.
|
The `metin-db-ready.service` gate waits until the DB socket is actually accepting connections before `auth` and `game` units start.
|
||||||
|
|
||||||
|
The installer also reconciles enabled `metin-game@...` instances against the selected channel set so stale units do not stay enabled forever.
|
||||||
|
|
||||||
## Optional Environment File
|
## Optional Environment File
|
||||||
|
|
||||||
The runtime units support an optional `EnvironmentFile` for host-local overrides:
|
The runtime units support an optional `EnvironmentFile` for host-local overrides:
|
||||||
|
|||||||
284
deploy/systemd/bin/metinctl.in
Normal file
284
deploy/systemd/bin/metinctl.in
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path("{{REPO_ROOT}}")
|
||||||
|
RUNTIME_ROOT = Path("{{RUNTIME_ROOT}}")
|
||||||
|
HEALTHCHECK_PATH = Path("/usr/local/sbin/metin-login-healthcheck")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import channel_inventory
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Operational CLI for the Debian Metin runtime")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
inventory_parser = subparsers.add_parser("inventory", help="Show declared channel inventory")
|
||||||
|
inventory_parser.add_argument("--json", action="store_true", help="Print raw JSON")
|
||||||
|
|
||||||
|
subparsers.add_parser("units", help="List managed systemd units")
|
||||||
|
|
||||||
|
status_parser = subparsers.add_parser("status", help="Show current unit state")
|
||||||
|
status_parser.add_argument("target", nargs="?", default="all", help="stack, db, auth, game, channel:<id>, instance:<name>")
|
||||||
|
|
||||||
|
ports_parser = subparsers.add_parser("ports", help="Show declared listener ports")
|
||||||
|
ports_parser.add_argument("--live", action="store_true", help="Also show whether the port is currently listening")
|
||||||
|
|
||||||
|
for action in ("start", "stop", "restart"):
|
||||||
|
action_parser = subparsers.add_parser(action, help=f"{action.title()} a managed target")
|
||||||
|
action_parser.add_argument("target", help="stack, db, auth, game, channel:<id>, instance:<name>")
|
||||||
|
|
||||||
|
logs_parser = subparsers.add_parser("logs", help="Show journalctl logs for a managed target")
|
||||||
|
logs_parser.add_argument("target", help="stack, db, auth, game, channel:<id>, instance:<name>")
|
||||||
|
logs_parser.add_argument("-n", "--lines", type=int, default=100, help="Number of journal lines")
|
||||||
|
logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow the journal")
|
||||||
|
|
||||||
|
subparsers.add_parser("healthcheck", help="Run the root-only headless healthcheck")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(command: list[str], require_root: bool = False) -> list[str]:
|
||||||
|
if require_root and os.geteuid() != 0:
|
||||||
|
return ["sudo", *command]
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str], require_root: bool = False, capture_output: bool = False, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
build_command(command, require_root=require_root),
|
||||||
|
check=check,
|
||||||
|
capture_output=capture_output,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_unit_state(unit: str) -> tuple[str, str, str]:
|
||||||
|
active = run(["systemctl", "is-active", unit], capture_output=True, check=False).stdout.strip() or "unknown"
|
||||||
|
enabled = run(["systemctl", "is-enabled", unit], capture_output=True, check=False).stdout.strip() or "unknown"
|
||||||
|
sub_state = run(["systemctl", "show", unit, "--property=SubState", "--value"], capture_output=True, check=False).stdout.strip() or "-"
|
||||||
|
return active, sub_state, enabled
|
||||||
|
|
||||||
|
|
||||||
|
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
||||||
|
widths = [len(header) for header in headers]
|
||||||
|
for row in rows:
|
||||||
|
for index, value in enumerate(row):
|
||||||
|
widths[index] = max(widths[index], len(value))
|
||||||
|
|
||||||
|
header_line = " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
|
||||||
|
print(header_line)
|
||||||
|
print(" ".join("-" * widths[index] for index in range(len(headers))))
|
||||||
|
for row in rows:
|
||||||
|
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
||||||
|
|
||||||
|
|
||||||
|
def iter_port_rows() -> list[dict[str, str]]:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"scope": "db",
|
||||||
|
"name": "db",
|
||||||
|
"port": str(channel_inventory.get_db()["port"]),
|
||||||
|
"p2p_port": "-",
|
||||||
|
"unit": channel_inventory.DB_UNIT,
|
||||||
|
"visibility": "internal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "auth",
|
||||||
|
"name": "auth",
|
||||||
|
"port": str(channel_inventory.get_auth()["port"]),
|
||||||
|
"p2p_port": str(channel_inventory.get_auth()["p2p_port"]),
|
||||||
|
"unit": channel_inventory.AUTH_UNIT,
|
||||||
|
"visibility": "public",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for channel in channel_inventory.iter_channels():
|
||||||
|
channel_id = int(channel["id"])
|
||||||
|
visibility = "public" if channel.get("public") else "internal"
|
||||||
|
for core in sorted(channel["cores"], key=lambda item: int(item["id"])):
|
||||||
|
core_id = int(core["id"])
|
||||||
|
instance = channel_inventory.instance_name(channel_id, core_id)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"scope": f"channel:{channel_id}",
|
||||||
|
"name": instance,
|
||||||
|
"port": str(core["port"]),
|
||||||
|
"p2p_port": str(core["p2p_port"]),
|
||||||
|
"unit": channel_inventory.game_unit(instance),
|
||||||
|
"visibility": visibility,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def live_ports() -> set[int]:
|
||||||
|
if shutil.which("ss") is None:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
completed = run(["ss", "-ltnH"], capture_output=True, check=True)
|
||||||
|
ports: set[int] = set()
|
||||||
|
for line in completed.stdout.splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) < 4:
|
||||||
|
continue
|
||||||
|
local = fields[3]
|
||||||
|
if ":" not in local:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ports.add(int(local.rsplit(":", 1)[1]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return ports
|
||||||
|
|
||||||
|
|
||||||
|
def print_inventory(as_json: bool) -> int:
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(channel_inventory.load_inventory(), indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for channel in channel_inventory.iter_channels():
|
||||||
|
channel_id = int(channel["id"])
|
||||||
|
ports = ",".join(str(core["port"]) for core in sorted(channel["cores"], key=lambda item: int(item["id"])))
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
str(channel_id),
|
||||||
|
channel["name"],
|
||||||
|
"yes" if channel.get("public") else "no",
|
||||||
|
"yes" if channel.get("client_visible") else "no",
|
||||||
|
str(len(channel["cores"])),
|
||||||
|
ports,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
print_table(["channel", "name", "public", "visible", "cores", "ports"], rows)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def print_units() -> int:
|
||||||
|
rows = [
|
||||||
|
["stack", channel_inventory.STACK_UNIT],
|
||||||
|
["db", channel_inventory.DB_UNIT],
|
||||||
|
["db-ready", channel_inventory.DB_READY_UNIT],
|
||||||
|
["auth", channel_inventory.AUTH_UNIT],
|
||||||
|
]
|
||||||
|
for unit in channel_inventory.get_game_units():
|
||||||
|
rows.append(["game", unit])
|
||||||
|
print_table(["kind", "unit"], rows)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target_units(target: str) -> list[str]:
|
||||||
|
normalized = target.strip().lower()
|
||||||
|
|
||||||
|
if normalized in {"all", "stack", "server"}:
|
||||||
|
return [channel_inventory.STACK_UNIT]
|
||||||
|
if normalized == "db":
|
||||||
|
return [channel_inventory.DB_UNIT]
|
||||||
|
if normalized in {"db-ready", "db_ready"}:
|
||||||
|
return [channel_inventory.DB_READY_UNIT]
|
||||||
|
if normalized == "auth":
|
||||||
|
return [channel_inventory.AUTH_UNIT]
|
||||||
|
if normalized in {"game", "games"}:
|
||||||
|
return channel_inventory.get_game_units()
|
||||||
|
if normalized.startswith("channel:"):
|
||||||
|
channel_id = int(normalized.split(":", 1)[1])
|
||||||
|
return channel_inventory.get_game_units([channel_id])
|
||||||
|
if normalized.startswith("instance:"):
|
||||||
|
return [channel_inventory.game_unit(target.split(":", 1)[1])]
|
||||||
|
if normalized.startswith("channel") and "_core" in normalized:
|
||||||
|
return [channel_inventory.game_unit(target)]
|
||||||
|
raise SystemExit(f"Unknown target: {target}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_status(target: str) -> int:
|
||||||
|
if target == "all":
|
||||||
|
units = [
|
||||||
|
channel_inventory.STACK_UNIT,
|
||||||
|
channel_inventory.DB_UNIT,
|
||||||
|
channel_inventory.DB_READY_UNIT,
|
||||||
|
channel_inventory.AUTH_UNIT,
|
||||||
|
*channel_inventory.get_game_units(),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
units = resolve_target_units(target)
|
||||||
|
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for unit in units:
|
||||||
|
active, sub_state, enabled = get_unit_state(unit)
|
||||||
|
rows.append([unit, active, sub_state, enabled])
|
||||||
|
print_table(["unit", "active", "sub", "enabled"], rows)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def print_ports(show_live: bool) -> int:
|
||||||
|
listening = live_ports() if show_live else set()
|
||||||
|
headers = ["scope", "name", "port", "p2p", "visibility", "unit"]
|
||||||
|
if show_live:
|
||||||
|
headers.append("live")
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for row in iter_port_rows():
|
||||||
|
values = [row["scope"], row["name"], row["port"], row["p2p_port"], row["visibility"], row["unit"]]
|
||||||
|
if show_live:
|
||||||
|
values.append("yes" if int(row["port"]) in listening else "no")
|
||||||
|
rows.append(values)
|
||||||
|
print_table(headers, rows)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_unit_action(action: str, target: str) -> int:
|
||||||
|
units = resolve_target_units(target)
|
||||||
|
run(["systemctl", action, *units], require_root=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_logs(target: str, lines: int, follow: bool) -> int:
|
||||||
|
units = resolve_target_units(target)
|
||||||
|
command = ["journalctl", "--no-pager", f"-n{lines}"]
|
||||||
|
for unit in units:
|
||||||
|
command.extend(["-u", unit])
|
||||||
|
if follow:
|
||||||
|
command = ["journalctl", f"-n{lines}", "-f", *sum((["-u", unit] for unit in units), [])]
|
||||||
|
run(command, require_root=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_healthcheck() -> int:
|
||||||
|
if not HEALTHCHECK_PATH.exists():
|
||||||
|
raise SystemExit(f"Missing healthcheck wrapper: {HEALTHCHECK_PATH}")
|
||||||
|
run([str(HEALTHCHECK_PATH)], require_root=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
if args.command == "inventory":
|
||||||
|
return print_inventory(args.json)
|
||||||
|
if args.command == "units":
|
||||||
|
return print_units()
|
||||||
|
if args.command == "status":
|
||||||
|
return print_status(args.target)
|
||||||
|
if args.command == "ports":
|
||||||
|
return print_ports(args.live)
|
||||||
|
if args.command in {"start", "stop", "restart"}:
|
||||||
|
return run_unit_action(args.command, args.target)
|
||||||
|
if args.command == "logs":
|
||||||
|
return run_logs(args.target, args.lines, args.follow)
|
||||||
|
if args.command == "healthcheck":
|
||||||
|
return run_healthcheck()
|
||||||
|
raise SystemExit(f"Unsupported command: {args.command}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -10,7 +10,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent
|
|||||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT))
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
import channels
|
import channel_inventory
|
||||||
|
|
||||||
|
|
||||||
TEMPLATES_DIR = SCRIPT_DIR / "templates"
|
TEMPLATES_DIR = SCRIPT_DIR / "templates"
|
||||||
@@ -24,6 +24,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--runtime-root", required=True, help="Absolute path to the live runtime root")
|
parser.add_argument("--runtime-root", required=True, help="Absolute path to the live runtime root")
|
||||||
parser.add_argument("--systemd-dir", default="/etc/systemd/system", help="systemd unit destination")
|
parser.add_argument("--systemd-dir", default="/etc/systemd/system", help="systemd unit destination")
|
||||||
parser.add_argument("--libexec-dir", default="/usr/local/libexec", help="Helper script destination")
|
parser.add_argument("--libexec-dir", default="/usr/local/libexec", help="Helper script destination")
|
||||||
|
parser.add_argument("--bin-dir", default="/usr/local/bin", help="Binary/script destination")
|
||||||
parser.add_argument("--env-file", default="/etc/metin/metin.env", help="Optional EnvironmentFile path for runtime overrides")
|
parser.add_argument("--env-file", default="/etc/metin/metin.env", help="Optional EnvironmentFile path for runtime overrides")
|
||||||
parser.add_argument("--wait-host", default="127.0.0.1", help="DB readiness host")
|
parser.add_argument("--wait-host", default="127.0.0.1", help="DB readiness host")
|
||||||
parser.add_argument("--wait-port", type=int, default=9000, help="DB readiness port")
|
parser.add_argument("--wait-port", type=int, default=9000, help="DB readiness port")
|
||||||
@@ -72,31 +73,18 @@ def copy_file(source: Path, destination: Path, mode: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_channels(args: argparse.Namespace) -> list[int]:
|
def resolve_channels(args: argparse.Namespace) -> list[int]:
|
||||||
available_channels = {int(channel_id) for channel_id in channels.CHANNEL_MAP.keys()}
|
try:
|
||||||
|
return channel_inventory.resolve_selected_channels(
|
||||||
if args.channels:
|
channel_limit=args.channel_limit,
|
||||||
selected = set(args.channels)
|
explicit_channels=args.channels,
|
||||||
else:
|
)
|
||||||
selected = {channel_id for channel_id in available_channels if channel_id <= args.channel_limit}
|
except ValueError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
if 99 in available_channels:
|
|
||||||
selected.add(99)
|
|
||||||
|
|
||||||
unknown = sorted(selected - available_channels)
|
|
||||||
if unknown:
|
|
||||||
print(f"Unknown channels requested: {unknown}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
return sorted(selected)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_instances(selected_channels: list[int]) -> list[str]:
|
def resolve_instances(selected_channels: list[int]) -> list[str]:
|
||||||
instances: list[str] = []
|
return channel_inventory.get_instances(selected_channels)
|
||||||
for channel_id in selected_channels:
|
|
||||||
cores = channels.CHANNEL_MAP[int(channel_id)]
|
|
||||||
for core_id in sorted(int(core) for core in cores.keys()):
|
|
||||||
instances.append(f"channel{channel_id}_core{core_id}")
|
|
||||||
return instances
|
|
||||||
|
|
||||||
|
|
||||||
def run(command: list[str]) -> None:
|
def run(command: list[str]) -> None:
|
||||||
@@ -111,13 +99,17 @@ def main() -> int:
|
|||||||
runtime_root = str(Path(args.runtime_root).resolve())
|
runtime_root = str(Path(args.runtime_root).resolve())
|
||||||
systemd_dir = Path(args.systemd_dir)
|
systemd_dir = Path(args.systemd_dir)
|
||||||
libexec_dir = Path(args.libexec_dir)
|
libexec_dir = Path(args.libexec_dir)
|
||||||
|
bin_dir = Path(args.bin_dir)
|
||||||
|
|
||||||
selected_channels = resolve_channels(args)
|
selected_channels = resolve_channels(args)
|
||||||
instances = resolve_instances(selected_channels)
|
instances = resolve_instances(selected_channels)
|
||||||
|
selected_game_units = set(channel_inventory.get_game_units(selected_channels))
|
||||||
|
all_game_units = set(channel_inventory.get_game_units())
|
||||||
|
|
||||||
template_values = {
|
template_values = {
|
||||||
"USER_NAME": args.user,
|
"USER_NAME": args.user,
|
||||||
"GROUP_NAME": group_name,
|
"GROUP_NAME": group_name,
|
||||||
|
"REPO_ROOT": str(REPO_ROOT),
|
||||||
"RUNTIME_ROOT": runtime_root,
|
"RUNTIME_ROOT": runtime_root,
|
||||||
"ENV_FILE": args.env_file,
|
"ENV_FILE": args.env_file,
|
||||||
"WAIT_HOST": args.wait_host,
|
"WAIT_HOST": args.wait_host,
|
||||||
@@ -144,17 +136,29 @@ def main() -> int:
|
|||||||
0o755,
|
0o755,
|
||||||
)
|
)
|
||||||
copy_file(BIN_DIR / "metin-wait-port", libexec_dir / "metin-wait-port", 0o755)
|
copy_file(BIN_DIR / "metin-wait-port", libexec_dir / "metin-wait-port", 0o755)
|
||||||
|
write_text(
|
||||||
|
bin_dir / "metinctl",
|
||||||
|
render_template(BIN_DIR / "metinctl.in", template_values),
|
||||||
|
0o755,
|
||||||
|
)
|
||||||
|
|
||||||
verify_units = [str(systemd_dir / unit_name) for unit_name in unit_names]
|
verify_units = [str(systemd_dir / unit_name) for unit_name in unit_names]
|
||||||
run(["systemd-analyze", "verify", *verify_units])
|
run(["systemd-analyze", "verify", *verify_units])
|
||||||
run(["systemctl", "daemon-reload"])
|
run(["systemctl", "daemon-reload"])
|
||||||
|
|
||||||
|
stale_game_units = sorted(all_game_units - selected_game_units)
|
||||||
|
if stale_game_units:
|
||||||
|
disable_command = ["systemctl", "disable"]
|
||||||
|
if args.restart:
|
||||||
|
disable_command.append("--now")
|
||||||
|
run([*disable_command, *stale_game_units])
|
||||||
|
|
||||||
enable_units = [
|
enable_units = [
|
||||||
"metin-server.service",
|
"metin-server.service",
|
||||||
"metin-db.service",
|
"metin-db.service",
|
||||||
"metin-db-ready.service",
|
"metin-db-ready.service",
|
||||||
"metin-auth.service",
|
"metin-auth.service",
|
||||||
*[f"metin-game@{instance}.service" for instance in instances],
|
*sorted(selected_game_units),
|
||||||
]
|
]
|
||||||
run(["systemctl", "enable", *enable_units])
|
run(["systemctl", "enable", *enable_units])
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Current documents:
|
|||||||
|
|
||||||
- [Debian Runtime](debian-runtime.md)
|
- [Debian Runtime](debian-runtime.md)
|
||||||
- [Healthchecks](healthchecks.md)
|
- [Healthchecks](healthchecks.md)
|
||||||
|
- [Server Management](server-management.md)
|
||||||
- [Deploy Workflow](deploy-workflow.md)
|
- [Deploy Workflow](deploy-workflow.md)
|
||||||
- [Rollback](rollback.md)
|
- [Rollback](rollback.md)
|
||||||
- [Database Bootstrap](database-bootstrap.md)
|
- [Database Bootstrap](database-bootstrap.md)
|
||||||
|
|||||||
97
docs/server-management.md
Normal file
97
docs/server-management.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Server Management
|
||||||
|
|
||||||
|
This document describes the current Debian-side control plane for the production Metin runtime.
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
The channel topology now lives in one versioned file:
|
||||||
|
|
||||||
|
- `deploy/channel-inventory.json`
|
||||||
|
|
||||||
|
It defines:
|
||||||
|
|
||||||
|
- auth and DB listener ports
|
||||||
|
- channel ids
|
||||||
|
- per-core public ports and P2P ports
|
||||||
|
- whether a channel is public/client-visible
|
||||||
|
- whether a special channel should always be included by management tooling
|
||||||
|
|
||||||
|
This inventory is now the source used by:
|
||||||
|
|
||||||
|
- `channel_inventory.py`
|
||||||
|
- `channels.py` compatibility exports
|
||||||
|
- `install.py`
|
||||||
|
- `deploy/systemd/install_systemd.py`
|
||||||
|
- `metinctl`
|
||||||
|
|
||||||
|
## metinctl
|
||||||
|
|
||||||
|
The Debian deployment installs:
|
||||||
|
|
||||||
|
- `/usr/local/bin/metinctl`
|
||||||
|
|
||||||
|
`metinctl` is a lightweight operational CLI for:
|
||||||
|
|
||||||
|
- viewing inventory
|
||||||
|
- listing managed units
|
||||||
|
- checking service status
|
||||||
|
- listing declared ports
|
||||||
|
- restarting the whole stack or specific channels/instances
|
||||||
|
- viewing logs
|
||||||
|
- running the root-only headless healthcheck
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Show inventory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl inventory
|
||||||
|
```
|
||||||
|
|
||||||
|
Show current unit state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
Show declared ports and whether they are currently listening:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl ports --live
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart only channel 1 cores:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl restart channel:1
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart one specific game instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl restart instance:channel1_core2
|
||||||
|
```
|
||||||
|
|
||||||
|
Tail auth logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl logs auth -n 200 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the end-to-end healthcheck:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
metinctl healthcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## systemd installer behavior
|
||||||
|
|
||||||
|
`deploy/systemd/install_systemd.py` now uses the same inventory and installs `metinctl`.
|
||||||
|
|
||||||
|
It also reconciles enabled game instance units against the selected channels:
|
||||||
|
|
||||||
|
- selected game units are enabled
|
||||||
|
- stale game units are disabled
|
||||||
|
- if `--restart` is passed, stale game units are disabled with `--now`
|
||||||
|
|
||||||
|
This makes channel enablement declarative instead of depending on whatever happened to be enabled previously.
|
||||||
21
install.py
21
install.py
@@ -4,7 +4,7 @@ sys.dont_write_bytecode = True
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import channels
|
import channel_inventory
|
||||||
|
|
||||||
GAMEDIR = os.getcwd()
|
GAMEDIR = os.getcwd()
|
||||||
|
|
||||||
@@ -14,19 +14,17 @@ def write_lines_to_files(path, lines):
|
|||||||
f.write(line)
|
f.write(line)
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
|
|
||||||
def generate_auth_config(path, port, p2p_port):
|
def generate_auth_config(path, channel_id, port, p2p_port):
|
||||||
file_content = [
|
file_content = [
|
||||||
"HOSTNAME: auth",
|
"HOSTNAME: auth",
|
||||||
"CHANNEL: 1",
|
f"CHANNEL: {channel_id}",
|
||||||
f"PORT: {port}",
|
f"PORT: {port}",
|
||||||
f"P2P_PORT: {p2p_port}",
|
f"P2P_PORT: {p2p_port}",
|
||||||
"AUTH_SERVER: master",
|
"AUTH_SERVER: master",
|
||||||
]
|
]
|
||||||
write_lines_to_files(os.path.join(path, "CONFIG"), file_content)
|
write_lines_to_files(os.path.join(path, "CONFIG"), file_content)
|
||||||
|
|
||||||
def generate_game_config(path, channel, core, map_allow):
|
def generate_game_config(path, channel, core, map_allow, port, p2p_port):
|
||||||
port = 11000 + (channel * 10 + core)
|
|
||||||
p2p_port = 12000 + (channel * 10 + core)
|
|
||||||
file_content = [
|
file_content = [
|
||||||
f"HOSTNAME: channel{channel}_{core}",
|
f"HOSTNAME: channel{channel}_{core}",
|
||||||
f"CHANNEL: {channel}",
|
f"CHANNEL: {channel}",
|
||||||
@@ -106,17 +104,20 @@ print_green("> Setting up environment for AUTH...")
|
|||||||
auth_dir = os.path.join(GAMEDIR, "channels", "auth")
|
auth_dir = os.path.join(GAMEDIR, "channels", "auth")
|
||||||
os.makedirs(auth_dir)
|
os.makedirs(auth_dir)
|
||||||
setup_links_game(auth_dir, "game_auth")
|
setup_links_game(auth_dir, "game_auth")
|
||||||
generate_auth_config(auth_dir, 11000, 12000)
|
auth_config = channel_inventory.get_auth()
|
||||||
|
generate_auth_config(auth_dir, auth_config["channel"], auth_config["port"], auth_config["p2p_port"])
|
||||||
|
|
||||||
## Game Channel Setup
|
## Game Channel Setup
|
||||||
|
|
||||||
for channel_id, cores in channels.CHANNEL_MAP.items():
|
for channel in channel_inventory.iter_channels():
|
||||||
|
channel_id = int(channel["id"])
|
||||||
print_green(f"> Setting up environment for CH{channel_id}...")
|
print_green(f"> Setting up environment for CH{channel_id}...")
|
||||||
for core_id, maps in cores.items():
|
for core in sorted(channel["cores"], key=lambda item: int(item["id"])):
|
||||||
|
core_id = int(core["id"])
|
||||||
core_dir = os.path.join(GAMEDIR, "channels", f"channel{channel_id}", f"core{core_id}")
|
core_dir = os.path.join(GAMEDIR, "channels", f"channel{channel_id}", f"core{core_id}")
|
||||||
os.makedirs(core_dir)
|
os.makedirs(core_dir)
|
||||||
setup_links_game(core_dir, f"channel{channel_id}_core{core_id}")
|
setup_links_game(core_dir, f"channel{channel_id}_core{core_id}")
|
||||||
generate_game_config(core_dir, channel_id, core_id, maps)
|
generate_game_config(core_dir, channel_id, core_id, core["map_allow"], core["port"], core["p2p_port"])
|
||||||
|
|
||||||
print_green("> We are all done!")
|
print_green("> We are all done!")
|
||||||
os.chdir(GAMEDIR)
|
os.chdir(GAMEDIR)
|
||||||
|
|||||||
Reference in New Issue
Block a user