deploy: version systemd runtime setup
This commit is contained in:
20
README.md
20
README.md
@@ -51,6 +51,26 @@ This tutorial will be showing how to install in both FreeBSD and Windows environ
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
## Debian (systemd)
|
||||||
|
|
||||||
|
For the Debian runtime used in production, the repository now also contains versioned `systemd` deployment files under [deploy/systemd](deploy/systemd/README.md).
|
||||||
|
|
||||||
|
Example installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 deploy/systemd/install_systemd.py \
|
||||||
|
--user mt2.jakubkadlec.dev \
|
||||||
|
--group mt2.jakubkadlec.dev \
|
||||||
|
--runtime-root /home/mt2.jakubkadlec.dev/metin/runtime/server \
|
||||||
|
--channel 1 \
|
||||||
|
--channel 99 \
|
||||||
|
--restart
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs the stack units, direct game/db launch helpers and a DB readiness gate so `auth` and `game` do not start before the DB socket is listening.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
##  **FreeBSD**
|
##  **FreeBSD**
|
||||||
|
|
||||||
### ⬇️ Obtaining the Serverfiles
|
### ⬇️ Obtaining the Serverfiles
|
||||||
|
|||||||
32
deploy/systemd/README.md
Normal file
32
deploy/systemd/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# systemd deployment
|
||||||
|
|
||||||
|
This directory contains the versioned systemd deployment used for the Debian runtime.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Run the installer as `root` and point it at the live runtime root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/m2dev-server
|
||||||
|
python3 deploy/systemd/install_systemd.py \
|
||||||
|
--user mt2.jakubkadlec.dev \
|
||||||
|
--group mt2.jakubkadlec.dev \
|
||||||
|
--runtime-root /home/mt2.jakubkadlec.dev/metin/runtime/server \
|
||||||
|
--channel 1 \
|
||||||
|
--channel 99 \
|
||||||
|
--restart
|
||||||
|
```
|
||||||
|
|
||||||
|
`--channel-limit 1` is also supported and will auto-include channel `99` when present in `channels.py`.
|
||||||
|
|
||||||
|
## What it installs
|
||||||
|
|
||||||
|
- `metin-server.service`
|
||||||
|
- `metin-db.service`
|
||||||
|
- `metin-db-ready.service`
|
||||||
|
- `metin-auth.service`
|
||||||
|
- `metin-game@.service`
|
||||||
|
- `/usr/local/libexec/metin-game-instance-start`
|
||||||
|
- `/usr/local/libexec/metin-wait-port`
|
||||||
|
|
||||||
|
The `metin-db-ready.service` gate waits until the DB socket is actually accepting connections before `auth` and `game` units start.
|
||||||
23
deploy/systemd/bin/metin-game-instance-start.in
Normal file
23
deploy/systemd/bin/metin-game-instance-start.in
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
instance="${1:?missing instance name}"
|
||||||
|
root_dir="{{RUNTIME_ROOT}}/channels"
|
||||||
|
channel_dir="${instance%_*}"
|
||||||
|
core_dir="${instance##*_}"
|
||||||
|
workdir="${root_dir}/${channel_dir}/${core_dir}"
|
||||||
|
binary="./${instance}"
|
||||||
|
|
||||||
|
if [ ! -d "$workdir" ]; then
|
||||||
|
echo "Missing workdir for instance ${instance}: ${workdir}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$workdir"
|
||||||
|
|
||||||
|
if [ ! -x "$binary" ]; then
|
||||||
|
echo "Missing executable for instance ${instance}: ${workdir}/${instance}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$binary"
|
||||||
29
deploy/systemd/bin/metin-wait-port
Normal file
29
deploy/systemd/bin/metin-wait-port
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("usage: metin-wait-port <host> <port> [timeout_secs]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
host = sys.argv[1]
|
||||||
|
port = int(sys.argv[2])
|
||||||
|
timeout_secs = float(sys.argv[3]) if len(sys.argv) > 3 else 30.0
|
||||||
|
deadline = time.monotonic() + timeout_secs
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=0.5):
|
||||||
|
return 0
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
print(f"Timed out waiting for {host}:{port}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
169
deploy/systemd/install_systemd.py
Normal file
169
deploy/systemd/install_systemd.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import channels
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES_DIR = SCRIPT_DIR / "templates"
|
||||||
|
BIN_DIR = SCRIPT_DIR / "bin"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Install Metin systemd units")
|
||||||
|
parser.add_argument("--user", required=True, help="Runtime user")
|
||||||
|
parser.add_argument("--group", help="Runtime group, defaults to --user")
|
||||||
|
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("--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-timeout", type=int, default=30, help="DB readiness timeout in seconds")
|
||||||
|
parser.add_argument("--restart", action="store_true", help="Restart metin-server.service after install")
|
||||||
|
|
||||||
|
channel_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
channel_group.add_argument(
|
||||||
|
"--channel-limit",
|
||||||
|
type=int,
|
||||||
|
help="Enable channels up to this number and keep channel 99 if present",
|
||||||
|
)
|
||||||
|
channel_group.add_argument(
|
||||||
|
"--channel",
|
||||||
|
dest="channels",
|
||||||
|
action="append",
|
||||||
|
type=int,
|
||||||
|
help="Enable an explicit channel, may be passed multiple times",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_root() -> None:
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("This installer must run as root.", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template_path: Path, values: dict[str, str]) -> str:
|
||||||
|
content = template_path.read_text(encoding="utf-8")
|
||||||
|
for key, value in values.items():
|
||||||
|
content = content.replace(f"{{{{{key}}}}}", value)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def write_text(path: Path, content: str, mode: int) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
os.chmod(path, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_file(source: Path, destination: Path, mode: int) -> None:
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source, destination)
|
||||||
|
os.chmod(destination, mode)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str]) -> None:
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
ensure_root()
|
||||||
|
|
||||||
|
group_name = args.group or args.user
|
||||||
|
runtime_root = str(Path(args.runtime_root).resolve())
|
||||||
|
systemd_dir = Path(args.systemd_dir)
|
||||||
|
libexec_dir = Path(args.libexec_dir)
|
||||||
|
|
||||||
|
selected_channels = resolve_channels(args)
|
||||||
|
instances = resolve_instances(selected_channels)
|
||||||
|
|
||||||
|
template_values = {
|
||||||
|
"USER_NAME": args.user,
|
||||||
|
"GROUP_NAME": group_name,
|
||||||
|
"RUNTIME_ROOT": runtime_root,
|
||||||
|
"WAIT_HOST": args.wait_host,
|
||||||
|
"WAIT_PORT": str(args.wait_port),
|
||||||
|
"WAIT_TIMEOUT": str(args.wait_timeout),
|
||||||
|
"WAIT_TIMEOUT_SYSTEMD": str(args.wait_timeout + 5),
|
||||||
|
}
|
||||||
|
|
||||||
|
unit_names = [
|
||||||
|
"metin-server.service",
|
||||||
|
"metin-db.service",
|
||||||
|
"metin-db-ready.service",
|
||||||
|
"metin-auth.service",
|
||||||
|
"metin-game@.service",
|
||||||
|
]
|
||||||
|
|
||||||
|
for unit_name in unit_names:
|
||||||
|
template_path = TEMPLATES_DIR / f"{unit_name}.in"
|
||||||
|
write_text(systemd_dir / unit_name, render_template(template_path, template_values), 0o644)
|
||||||
|
|
||||||
|
write_text(
|
||||||
|
libexec_dir / "metin-game-instance-start",
|
||||||
|
render_template(BIN_DIR / "metin-game-instance-start.in", template_values),
|
||||||
|
0o755,
|
||||||
|
)
|
||||||
|
copy_file(BIN_DIR / "metin-wait-port", libexec_dir / "metin-wait-port", 0o755)
|
||||||
|
|
||||||
|
verify_units = [str(systemd_dir / unit_name) for unit_name in unit_names]
|
||||||
|
run(["systemd-analyze", "verify", *verify_units])
|
||||||
|
run(["systemctl", "daemon-reload"])
|
||||||
|
|
||||||
|
enable_units = [
|
||||||
|
"metin-server.service",
|
||||||
|
"metin-db.service",
|
||||||
|
"metin-db-ready.service",
|
||||||
|
"metin-auth.service",
|
||||||
|
*[f"metin-game@{instance}.service" for instance in instances],
|
||||||
|
]
|
||||||
|
run(["systemctl", "enable", *enable_units])
|
||||||
|
|
||||||
|
if args.restart:
|
||||||
|
run(["systemctl", "restart", "metin-server.service"])
|
||||||
|
|
||||||
|
print("Installed systemd units for instances:")
|
||||||
|
for instance in instances:
|
||||||
|
print(f" - {instance}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
22
deploy/systemd/templates/metin-auth.service.in
Normal file
22
deploy/systemd/templates/metin-auth.service.in
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Metin auth runtime
|
||||||
|
After=metin-db-ready.service
|
||||||
|
Wants=metin-db-ready.service
|
||||||
|
Requires=metin-db-ready.service
|
||||||
|
PartOf=metin-server.service
|
||||||
|
Before=metin-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{USER_NAME}}
|
||||||
|
Group={{GROUP_NAME}}
|
||||||
|
WorkingDirectory={{RUNTIME_ROOT}}/channels/auth
|
||||||
|
ExecStart={{RUNTIME_ROOT}}/channels/auth/game_auth
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=60
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
RequiredBy=metin-server.service
|
||||||
16
deploy/systemd/templates/metin-db-ready.service.in
Normal file
16
deploy/systemd/templates/metin-db-ready.service.in
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Metin DB readiness gate
|
||||||
|
After=metin-db.service
|
||||||
|
Wants=metin-db.service
|
||||||
|
Requires=metin-db.service
|
||||||
|
PartOf=metin-server.service
|
||||||
|
Before=metin-auth.service metin-game@.service metin-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/local/libexec/metin-wait-port {{WAIT_HOST}} {{WAIT_PORT}} {{WAIT_TIMEOUT}}
|
||||||
|
TimeoutStartSec={{WAIT_TIMEOUT_SYSTEMD}}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
RequiredBy=metin-server.service
|
||||||
22
deploy/systemd/templates/metin-db.service.in
Normal file
22
deploy/systemd/templates/metin-db.service.in
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Metin database runtime
|
||||||
|
After=network-online.target mariadb.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=mariadb.service
|
||||||
|
PartOf=metin-server.service
|
||||||
|
Before=metin-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{USER_NAME}}
|
||||||
|
Group={{GROUP_NAME}}
|
||||||
|
WorkingDirectory={{RUNTIME_ROOT}}/channels/db
|
||||||
|
ExecStart={{RUNTIME_ROOT}}/channels/db/db
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=60
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
RequiredBy=metin-server.service
|
||||||
22
deploy/systemd/templates/metin-game@.service.in
Normal file
22
deploy/systemd/templates/metin-game@.service.in
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Metin game runtime (%i)
|
||||||
|
After=metin-auth.service metin-db-ready.service
|
||||||
|
Wants=metin-auth.service metin-db-ready.service
|
||||||
|
Requires=metin-auth.service metin-db-ready.service
|
||||||
|
PartOf=metin-server.service
|
||||||
|
Before=metin-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{USER_NAME}}
|
||||||
|
Group={{GROUP_NAME}}
|
||||||
|
WorkingDirectory={{RUNTIME_ROOT}}
|
||||||
|
ExecStart=/usr/local/libexec/metin-game-instance-start %i
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=60
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
RequiredBy=metin-server.service
|
||||||
15
deploy/systemd/templates/metin-server.service.in
Normal file
15
deploy/systemd/templates/metin-server.service.in
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Metin server stack
|
||||||
|
After=network-online.target mariadb.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=mariadb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/bin/true
|
||||||
|
ExecStop=/bin/true
|
||||||
|
TimeoutStopSec=60
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user