diff --git a/README.md b/README.md index 5e79118..b7037b0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Additional operational notes: - [Debian Runtime](docs/debian-runtime.md) - [Healthchecks](docs/healthchecks.md) +- [Server Management](docs/server-management.md) Example installation: diff --git a/channel_inventory.py b/channel_inventory.py new file mode 100644 index 0000000..ef700b0 --- /dev/null +++ b/channel_inventory.py @@ -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) diff --git a/channels.py b/channels.py index ddb305d..a7e1bcf 100644 --- a/channels.py +++ b/channels.py @@ -1,19 +1,6 @@ -## Map Allow Layout -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", -} +from channel_inventory import get_channel_map -MAP_ALLOW_SPECIAL = { - 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 Layout -CHANNEL_MAP = { - 1: MAP_ALLOW_NORMAL, - 2: MAP_ALLOW_NORMAL, - 3: MAP_ALLOW_NORMAL, - 4: MAP_ALLOW_NORMAL, - 99: MAP_ALLOW_SPECIAL, -} \ No newline at end of file +# Compatibility shim for older scripts that still import channels.py directly. +CHANNEL_MAP = get_channel_map() +MAP_ALLOW_NORMAL = dict(CHANNEL_MAP.get(1, {})) +MAP_ALLOW_SPECIAL = dict(CHANNEL_MAP.get(99, {})) diff --git a/deploy/channel-inventory.json b/deploy/channel-inventory.json new file mode 100644 index 0000000..cc50f18 --- /dev/null +++ b/deploy/channel-inventory.json @@ -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" + } + ] + } + ] +} diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md index c64efc5..605087d 100644 --- a/deploy/systemd/README.md +++ b/deploy/systemd/README.md @@ -18,7 +18,11 @@ python3 deploy/systemd/install_systemd.py \ --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 @@ -29,9 +33,12 @@ python3 deploy/systemd/install_systemd.py \ - `metin-game@.service` - `/usr/local/libexec/metin-game-instance-start` - `/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 installer also reconciles enabled `metin-game@...` instances against the selected channel set so stale units do not stay enabled forever. + ## Optional Environment File The runtime units support an optional `EnvironmentFile` for host-local overrides: diff --git a/deploy/systemd/bin/metinctl.in b/deploy/systemd/bin/metinctl.in new file mode 100644 index 0000000..b29682f --- /dev/null +++ b/deploy/systemd/bin/metinctl.in @@ -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:, instance:") + + 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:, instance:") + + 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:, instance:") + 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()) diff --git a/deploy/systemd/install_systemd.py b/deploy/systemd/install_systemd.py index 5abbe5b..2eb4e35 100644 --- a/deploy/systemd/install_systemd.py +++ b/deploy/systemd/install_systemd.py @@ -10,7 +10,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent.parent sys.path.insert(0, str(REPO_ROOT)) -import channels +import channel_inventory 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("--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("--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("--wait-host", default="127.0.0.1", help="DB readiness host") 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]: - available_channels = {int(channel_id) for channel_id in channels.CHANNEL_MAP.keys()} - - if args.channels: - selected = set(args.channels) - else: - selected = {channel_id for channel_id in available_channels if channel_id <= args.channel_limit} - - if 99 in available_channels: - selected.add(99) - - unknown = sorted(selected - available_channels) - if unknown: - print(f"Unknown channels requested: {unknown}", file=sys.stderr) + try: + return channel_inventory.resolve_selected_channels( + channel_limit=args.channel_limit, + explicit_channels=args.channels, + ) + except ValueError as exc: + print(str(exc), file=sys.stderr) raise SystemExit(1) - return sorted(selected) - def resolve_instances(selected_channels: list[int]) -> list[str]: - instances: list[str] = [] - 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 + return channel_inventory.get_instances(selected_channels) def run(command: list[str]) -> None: @@ -111,13 +99,17 @@ def main() -> int: runtime_root = str(Path(args.runtime_root).resolve()) systemd_dir = Path(args.systemd_dir) libexec_dir = Path(args.libexec_dir) + bin_dir = Path(args.bin_dir) selected_channels = resolve_channels(args) 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 = { "USER_NAME": args.user, "GROUP_NAME": group_name, + "REPO_ROOT": str(REPO_ROOT), "RUNTIME_ROOT": runtime_root, "ENV_FILE": args.env_file, "WAIT_HOST": args.wait_host, @@ -144,17 +136,29 @@ def main() -> int: 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] run(["systemd-analyze", "verify", *verify_units]) 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 = [ "metin-server.service", "metin-db.service", "metin-db-ready.service", "metin-auth.service", - *[f"metin-game@{instance}.service" for instance in instances], + *sorted(selected_game_units), ] run(["systemctl", "enable", *enable_units]) diff --git a/docs/README.md b/docs/README.md index 2599e41..b2fbaf1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ Current documents: - [Debian Runtime](debian-runtime.md) - [Healthchecks](healthchecks.md) +- [Server Management](server-management.md) - [Deploy Workflow](deploy-workflow.md) - [Rollback](rollback.md) - [Database Bootstrap](database-bootstrap.md) diff --git a/docs/server-management.md b/docs/server-management.md new file mode 100644 index 0000000..3c7bec9 --- /dev/null +++ b/docs/server-management.md @@ -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. diff --git a/install.py b/install.py index 0698454..f0fbb37 100644 --- a/install.py +++ b/install.py @@ -4,7 +4,7 @@ sys.dont_write_bytecode = True import os import shutil import subprocess -import channels +import channel_inventory GAMEDIR = os.getcwd() @@ -14,19 +14,17 @@ def write_lines_to_files(path, lines): f.write(line) f.write("\n") -def generate_auth_config(path, port, p2p_port): +def generate_auth_config(path, channel_id, port, p2p_port): file_content = [ "HOSTNAME: auth", - "CHANNEL: 1", + f"CHANNEL: {channel_id}", f"PORT: {port}", f"P2P_PORT: {p2p_port}", "AUTH_SERVER: master", ] write_lines_to_files(os.path.join(path, "CONFIG"), file_content) -def generate_game_config(path, channel, core, map_allow): - port = 11000 + (channel * 10 + core) - p2p_port = 12000 + (channel * 10 + core) +def generate_game_config(path, channel, core, map_allow, port, p2p_port): file_content = [ f"HOSTNAME: channel{channel}_{core}", f"CHANNEL: {channel}", @@ -106,17 +104,20 @@ print_green("> Setting up environment for AUTH...") auth_dir = os.path.join(GAMEDIR, "channels", "auth") os.makedirs(auth_dir) 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 -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}...") - 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}") os.makedirs(core_dir) 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!") os.chdir(GAMEDIR)