forked from metin-server/m2dev-server
Compare commits
10 Commits
claude/ser
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2179c46ce0 | ||
|
|
6f16f66543 | ||
|
|
0bc6559283 | ||
|
|
c5bb515781 | ||
|
|
84625652fe | ||
|
|
cd2e1d61ca | ||
|
|
f722475f17 | ||
|
|
825cfbc19b | ||
|
|
4fccf13e09 | ||
|
|
5b0da5a685 |
@@ -51,6 +51,35 @@ def get_channel_ids() -> list[int]:
|
||||
return [int(channel["id"]) for channel in iter_channels()]
|
||||
|
||||
|
||||
def get_public_channel_ids(
|
||||
selected_channel_ids: Iterable[int] | None = None,
|
||||
*,
|
||||
client_visible_only: bool = False,
|
||||
) -> list[int]:
|
||||
selected = None if selected_channel_ids is None else {int(channel_id) for channel_id in selected_channel_ids}
|
||||
result: list[int] = []
|
||||
|
||||
for channel in iter_channels():
|
||||
channel_id = int(channel["id"])
|
||||
if selected is not None and channel_id not in selected:
|
||||
continue
|
||||
if not channel.get("public"):
|
||||
continue
|
||||
if client_visible_only and not channel.get("client_visible"):
|
||||
continue
|
||||
result.append(channel_id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def has_public_channel(
|
||||
selected_channel_ids: Iterable[int] | None = None,
|
||||
*,
|
||||
client_visible_only: bool = False,
|
||||
) -> bool:
|
||||
return bool(get_public_channel_ids(selected_channel_ids, client_visible_only=client_visible_only))
|
||||
|
||||
|
||||
def get_channel_map() -> dict[int, dict[int, str]]:
|
||||
result: dict[int, dict[int, str]] = {}
|
||||
for channel in iter_channels():
|
||||
|
||||
@@ -8,6 +8,38 @@ if [[ "${EUID}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODE="full"
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--mode)
|
||||
shift
|
||||
if (($# == 0)); then
|
||||
echo "Missing value for --mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
MODE="$1"
|
||||
;;
|
||||
--mode=*)
|
||||
MODE="${1#*=}"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
case "${MODE}" in
|
||||
ready|full)
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported mode: ${MODE} (expected ready or full)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
: "${RUN_AS_USER:=mt2.jakubkadlec.dev}"
|
||||
: "${SERVER_HOST:=173.249.9.66}"
|
||||
: "${AUTH_PORT:=11000}"
|
||||
@@ -26,19 +58,26 @@ if ! id "${RUN_AS_USER}" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DELETE_LOGIN="smkdel$(date +%s)"
|
||||
unique_suffix() {
|
||||
printf '%s%s' "$(date +%s%N | tail -c 9)" "$(openssl rand -hex 2)"
|
||||
}
|
||||
|
||||
DELETE_SUFFIX="$(unique_suffix)"
|
||||
FULL_SUFFIX="$(unique_suffix)"
|
||||
|
||||
DELETE_LOGIN="smkd${DELETE_SUFFIX}"
|
||||
DELETE_PASSWORD="$(openssl rand -hex 6)"
|
||||
DELETE_SOCIAL_ID="$(date +%s%N | tail -c 14)"
|
||||
DELETE_EMAIL="${DELETE_LOGIN}@example.invalid"
|
||||
DELETE_CHARACTER_NAME="c${DELETE_LOGIN}"
|
||||
DELETE_CHARACTER_NAME="d${DELETE_SUFFIX}"
|
||||
DELETE_PRIVATE_CODE="${DELETE_SOCIAL_ID: -7}"
|
||||
DELETE_ACCOUNT_ID=""
|
||||
|
||||
FULL_LOGIN="smkfull$(date +%s)"
|
||||
FULL_LOGIN="smkf${FULL_SUFFIX}"
|
||||
FULL_PASSWORD="$(openssl rand -hex 6)"
|
||||
FULL_SOCIAL_ID="$(date +%s%N | tail -c 14)"
|
||||
FULL_EMAIL="${FULL_LOGIN}@example.invalid"
|
||||
FULL_CHARACTER_NAME="c${FULL_LOGIN}"
|
||||
FULL_CHARACTER_NAME="f${FULL_SUFFIX}"
|
||||
FULL_ACCOUNT_ID=""
|
||||
|
||||
cleanup_account() {
|
||||
@@ -84,6 +123,8 @@ create_account() {
|
||||
local social_id="$3"
|
||||
local email="$4"
|
||||
|
||||
cleanup_account "" "${login}"
|
||||
|
||||
mysql -N account <<SQL
|
||||
INSERT INTO account (
|
||||
login,
|
||||
@@ -156,26 +197,35 @@ cleanup() {
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
DELETE_ACCOUNT_ID="$(create_account "${DELETE_LOGIN}" "${DELETE_PASSWORD}" "${DELETE_SOCIAL_ID}" "${DELETE_EMAIL}")"
|
||||
create_player_index "${DELETE_ACCOUNT_ID}"
|
||||
|
||||
FULL_ACCOUNT_ID="$(create_account "${FULL_LOGIN}" "${FULL_PASSWORD}" "${FULL_SOCIAL_ID}" "${FULL_EMAIL}")"
|
||||
create_player_index "${FULL_ACCOUNT_ID}"
|
||||
|
||||
echo "Running create/delete healthcheck for temporary account ${DELETE_LOGIN}"
|
||||
sudo -iu "${RUN_AS_USER}" env METIN_LOGIN_SMOKE_PASSWORD="${DELETE_PASSWORD}" \
|
||||
"${SMOKE_BIN}" "${SERVER_HOST}" "${AUTH_PORT}" "${CHANNEL_PORT}" "${DELETE_LOGIN}" \
|
||||
--password-env=METIN_LOGIN_SMOKE_PASSWORD \
|
||||
--create-character-name="${DELETE_CHARACTER_NAME}" \
|
||||
--delete-private-code="${DELETE_PRIVATE_CODE}" \
|
||||
if [[ "${MODE}" == "full" ]]; then
|
||||
DELETE_ACCOUNT_ID="$(create_account "${DELETE_LOGIN}" "${DELETE_PASSWORD}" "${DELETE_SOCIAL_ID}" "${DELETE_EMAIL}")"
|
||||
create_player_index "${DELETE_ACCOUNT_ID}"
|
||||
|
||||
echo "Running create/delete healthcheck for temporary account ${DELETE_LOGIN}"
|
||||
sudo -iu "${RUN_AS_USER}" env METIN_LOGIN_SMOKE_PASSWORD="${DELETE_PASSWORD}" \
|
||||
"${SMOKE_BIN}" "${SERVER_HOST}" "${AUTH_PORT}" "${CHANNEL_PORT}" "${DELETE_LOGIN}" \
|
||||
--password-env=METIN_LOGIN_SMOKE_PASSWORD \
|
||||
--create-character-name="${DELETE_CHARACTER_NAME}" \
|
||||
--delete-private-code="${DELETE_PRIVATE_CODE}" \
|
||||
--client-version="${CLIENT_VERSION}"
|
||||
fi
|
||||
|
||||
echo "Running ${MODE} login healthcheck for temporary account ${FULL_LOGIN}"
|
||||
FULL_ARGS=(
|
||||
"${SMOKE_BIN}" "${SERVER_HOST}" "${AUTH_PORT}" "${CHANNEL_PORT}" "${FULL_LOGIN}"
|
||||
--password-env=METIN_LOGIN_SMOKE_PASSWORD
|
||||
--create-character-name="${FULL_CHARACTER_NAME}"
|
||||
--client-version="${CLIENT_VERSION}"
|
||||
)
|
||||
|
||||
if [[ "${MODE}" == "full" ]]; then
|
||||
FULL_ARGS+=(--mall-password="${MALL_PASSWORD}")
|
||||
fi
|
||||
|
||||
echo "Running full login healthcheck for temporary account ${FULL_LOGIN}"
|
||||
sudo -iu "${RUN_AS_USER}" env METIN_LOGIN_SMOKE_PASSWORD="${FULL_PASSWORD}" \
|
||||
"${SMOKE_BIN}" "${SERVER_HOST}" "${AUTH_PORT}" "${CHANNEL_PORT}" "${FULL_LOGIN}" \
|
||||
--password-env=METIN_LOGIN_SMOKE_PASSWORD \
|
||||
--create-character-name="${FULL_CHARACTER_NAME}" \
|
||||
--client-version="${CLIENT_VERSION}" \
|
||||
--mall-password="${MALL_PASSWORD}"
|
||||
"${FULL_ARGS[@]}"
|
||||
|
||||
echo "Login healthcheck passed"
|
||||
echo "${MODE^} login healthcheck passed"
|
||||
|
||||
@@ -20,6 +20,8 @@ python3 deploy/systemd/install_systemd.py \
|
||||
|
||||
`--channel-limit 1` is also supported and will auto-include channel `99` when present in the channel inventory.
|
||||
|
||||
By default the installer refuses channel selections that omit every client-visible public channel. If you intentionally want an auth/internal-only stack, pass `--allow-internal-only`.
|
||||
|
||||
The channel selection and port layout now come from the versioned inventory file:
|
||||
|
||||
- [deploy/channel-inventory.json](../channel-inventory.json)
|
||||
@@ -35,6 +37,7 @@ The channel selection and port layout now come from the versioned inventory file
|
||||
- `/usr/local/libexec/metin-wait-port`
|
||||
- `/usr/local/bin/metinctl`
|
||||
- `/usr/local/sbin/metin-collect-incident`
|
||||
- `/usr/local/sbin/metin-core-backtrace`
|
||||
|
||||
The `metin-db-ready.service` gate waits until the DB socket is actually accepting connections before `auth` and `game` units start.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -106,6 +107,78 @@ def copy_core_files(bundle_dir: Path, core_files: list[Path]) -> None:
|
||||
shutil.copy2(path, destination)
|
||||
|
||||
|
||||
def infer_execfn_from_file_output(core_path: Path) -> Path | None:
|
||||
completed = run(["file", str(core_path)], check=False)
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
|
||||
match = re.search(r"execfn: '([^']+)'", completed.stdout)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
candidate = Path(match.group(1))
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def infer_executable_for_core(core_path: Path) -> Path | None:
|
||||
execfn_candidate = infer_execfn_from_file_output(core_path)
|
||||
if execfn_candidate:
|
||||
return execfn_candidate
|
||||
|
||||
parent_name = core_path.parent.name
|
||||
grandparent_name = core_path.parent.parent.name if core_path.parent.parent else ""
|
||||
|
||||
if parent_name == "db":
|
||||
candidate = (core_path.parent / "db").resolve()
|
||||
return candidate if candidate.is_file() else None
|
||||
if parent_name == "auth":
|
||||
candidate = (core_path.parent / "game_auth").resolve()
|
||||
return candidate if candidate.is_file() else None
|
||||
if parent_name.startswith("core") and grandparent_name.startswith("channel"):
|
||||
candidate = (core_path.parent / f"{grandparent_name}_{parent_name}").resolve()
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def executable_metadata(path: Path) -> dict[str, object]:
|
||||
stat = path.stat()
|
||||
return {
|
||||
"path": str(path),
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def write_core_executable_metadata(bundle_dir: Path, core_files: list[Path]) -> None:
|
||||
rows = []
|
||||
for core_path in core_files:
|
||||
row: dict[str, object] = {"core": str(core_path)}
|
||||
executable = infer_executable_for_core(core_path)
|
||||
if executable:
|
||||
row["executable"] = executable_metadata(executable)
|
||||
else:
|
||||
row["executable"] = None
|
||||
rows.append(row)
|
||||
write_text(bundle_dir / "core-executables.json", json.dumps(rows, indent=2))
|
||||
|
||||
|
||||
def copy_core_executables(bundle_dir: Path, core_files: list[Path]) -> None:
|
||||
executables_dir = bundle_dir / "executables"
|
||||
copied: set[Path] = set()
|
||||
for core_path in core_files:
|
||||
executable = infer_executable_for_core(core_path)
|
||||
if not executable or executable in copied:
|
||||
continue
|
||||
copied.add(executable)
|
||||
relative = executable.relative_to(RUNTIME_ROOT)
|
||||
destination = executables_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(executable, destination)
|
||||
|
||||
|
||||
def git_summary(repo_path: Path) -> dict[str, object]:
|
||||
summary: dict[str, object] = {"path": str(repo_path), "present": repo_path.exists()}
|
||||
if not repo_path.exists():
|
||||
@@ -180,8 +253,10 @@ def main() -> int:
|
||||
|
||||
core_files = find_core_files()
|
||||
write_core_metadata(bundle_dir, core_files)
|
||||
write_core_executable_metadata(bundle_dir, core_files)
|
||||
if args.include_cores and core_files:
|
||||
copy_core_files(bundle_dir, core_files)
|
||||
copy_core_executables(bundle_dir, core_files)
|
||||
|
||||
print(bundle_dir)
|
||||
return 0
|
||||
|
||||
222
deploy/systemd/bin/metin-core-backtrace.in
Normal file
222
deploy/systemd/bin/metin-core-backtrace.in
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
RUNTIME_ROOT = Path("{{RUNTIME_ROOT}}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate a backtrace for a Metin runtime core file")
|
||||
parser.add_argument("--core", help="Core file path. Defaults to the newest core under the runtime tree.")
|
||||
parser.add_argument("--exe", help="Executable path override. If omitted, infer it from the core path.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def ensure_root() -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise SystemExit("Run as root.")
|
||||
|
||||
|
||||
def run(command: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, check=check, capture_output=True, text=True)
|
||||
|
||||
|
||||
def iter_core_files() -> list[Path]:
|
||||
return sorted(
|
||||
(path for path in RUNTIME_ROOT.glob("channels/**/core*") if path.is_file()),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def resolve_core_path(core_arg: str | None) -> Path:
|
||||
if core_arg:
|
||||
candidate = Path(core_arg)
|
||||
if not candidate.is_absolute():
|
||||
runtime_relative = RUNTIME_ROOT / core_arg
|
||||
if runtime_relative.exists():
|
||||
candidate = runtime_relative
|
||||
candidate = candidate.resolve()
|
||||
if not candidate.is_file():
|
||||
raise SystemExit(f"Core file not found: {candidate}")
|
||||
return candidate
|
||||
|
||||
cores = iter_core_files()
|
||||
if not cores:
|
||||
raise SystemExit(f"No core files found under {RUNTIME_ROOT}")
|
||||
return cores[0]
|
||||
|
||||
|
||||
def infer_execfn_from_file_output(core_path: Path) -> Path | None:
|
||||
completed = run(["file", str(core_path)])
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
|
||||
match = re.search(r"execfn: '([^']+)'", completed.stdout)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
candidate = Path(match.group(1))
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def infer_executable(core_path: Path, exe_arg: str | None) -> Path:
|
||||
if exe_arg:
|
||||
exe_path = Path(exe_arg).resolve()
|
||||
if not exe_path.is_file():
|
||||
raise SystemExit(f"Executable not found: {exe_path}")
|
||||
return exe_path
|
||||
|
||||
execfn_candidate = infer_execfn_from_file_output(core_path)
|
||||
|
||||
parent_name = core_path.parent.name
|
||||
grandparent_name = core_path.parent.parent.name if core_path.parent.parent else ""
|
||||
|
||||
candidates: list[Path] = []
|
||||
if execfn_candidate:
|
||||
candidates.append(execfn_candidate)
|
||||
|
||||
if parent_name == "db":
|
||||
candidates.append(core_path.parent / "db")
|
||||
elif parent_name == "auth":
|
||||
candidates.append(core_path.parent / "game_auth")
|
||||
elif parent_name.startswith("core") and grandparent_name.startswith("channel"):
|
||||
candidates.append(core_path.parent / f"{grandparent_name}_{parent_name}")
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
|
||||
raise SystemExit(f"Could not infer executable for core file: {core_path}")
|
||||
|
||||
|
||||
def preferred_debugger() -> str | None:
|
||||
for tool in ("gdb", "lldb"):
|
||||
if shutil.which(tool):
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def format_section(title: str, body: str) -> str:
|
||||
return f"== {title} ==\n{body.rstrip()}\n"
|
||||
|
||||
|
||||
def render_file_info(path: Path) -> str:
|
||||
completed = run(["file", str(path)])
|
||||
body = completed.stdout or completed.stderr or "<no output>"
|
||||
return format_section(f"file {path}", body)
|
||||
|
||||
|
||||
def render_executable_freshness(core_path: Path, exe_path: Path) -> str:
|
||||
core_stat = core_path.stat()
|
||||
exe_stat = exe_path.stat()
|
||||
core_mtime = datetime.fromtimestamp(core_stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
exe_mtime = datetime.fromtimestamp(exe_stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
|
||||
lines = [
|
||||
f"core_mtime: {core_mtime}",
|
||||
f"exe_mtime: {exe_mtime}",
|
||||
]
|
||||
|
||||
if exe_stat.st_mtime > core_stat.st_mtime + 1:
|
||||
lines.append(
|
||||
"warning: executable is newer than the core file; symbols may not match. "
|
||||
"Prefer an executable snapshot from an incident bundle or pass --exe explicitly."
|
||||
)
|
||||
else:
|
||||
lines.append("status: executable is not newer than the core file")
|
||||
|
||||
return format_section("core/executable freshness", "\n".join(lines))
|
||||
|
||||
|
||||
def render_readelf_notes(core_path: Path) -> str:
|
||||
if not shutil.which("readelf"):
|
||||
return ""
|
||||
completed = run(["readelf", "-n", str(core_path)])
|
||||
body = completed.stdout or completed.stderr or "<no output>"
|
||||
return format_section(f"readelf -n {core_path}", body)
|
||||
|
||||
|
||||
def render_debugger_backtrace(debugger: str, exe_path: Path, core_path: Path) -> str:
|
||||
if debugger == "gdb":
|
||||
command = [
|
||||
"gdb",
|
||||
"-batch",
|
||||
"-ex",
|
||||
"set pagination off",
|
||||
"-ex",
|
||||
"thread apply all bt full",
|
||||
str(exe_path),
|
||||
str(core_path),
|
||||
]
|
||||
elif debugger == "lldb":
|
||||
command = [
|
||||
"lldb",
|
||||
"--batch",
|
||||
"-o",
|
||||
"thread backtrace all",
|
||||
"-c",
|
||||
str(core_path),
|
||||
str(exe_path),
|
||||
]
|
||||
else:
|
||||
raise SystemExit(f"Unsupported debugger: {debugger}")
|
||||
|
||||
completed = run(command)
|
||||
output = completed.stdout or completed.stderr or "<no output>"
|
||||
return format_section("backtrace", f"$ {' '.join(command)}\n\n{output}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ensure_root()
|
||||
|
||||
core_path = resolve_core_path(args.core)
|
||||
exe_path = infer_executable(core_path, args.exe)
|
||||
debugger = preferred_debugger()
|
||||
|
||||
sections = [
|
||||
format_section(
|
||||
"summary",
|
||||
"\n".join(
|
||||
[
|
||||
f"core: {core_path}",
|
||||
f"executable: {exe_path}",
|
||||
f"debugger: {debugger or '<none>'}",
|
||||
]
|
||||
),
|
||||
),
|
||||
render_file_info(core_path),
|
||||
render_file_info(exe_path),
|
||||
render_executable_freshness(core_path, exe_path),
|
||||
]
|
||||
|
||||
readelf_section = render_readelf_notes(core_path)
|
||||
if readelf_section:
|
||||
sections.append(readelf_section)
|
||||
|
||||
if debugger:
|
||||
sections.append(render_debugger_backtrace(debugger, exe_path, core_path))
|
||||
else:
|
||||
sections.append(
|
||||
format_section(
|
||||
"backtrace",
|
||||
"No supported debugger found. Install gdb or lldb on the host to generate a stack trace.",
|
||||
)
|
||||
)
|
||||
|
||||
print("\n".join(section.rstrip() for section in sections if section).rstrip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import channel_inventory
|
||||
|
||||
TEMPLATES_DIR = SCRIPT_DIR / "templates"
|
||||
BIN_DIR = SCRIPT_DIR / "bin"
|
||||
HEALTHCHECK_DIR = REPO_ROOT / "deploy" / "healthcheck"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -31,6 +32,11 @@ def parse_args() -> argparse.Namespace:
|
||||
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")
|
||||
parser.add_argument(
|
||||
"--allow-internal-only",
|
||||
action="store_true",
|
||||
help="Allow installs that omit every client-visible public channel",
|
||||
)
|
||||
|
||||
channel_group = parser.add_mutually_exclusive_group(required=True)
|
||||
channel_group.add_argument(
|
||||
@@ -75,7 +81,7 @@ def copy_file(source: Path, destination: Path, mode: int) -> None:
|
||||
|
||||
def resolve_channels(args: argparse.Namespace) -> list[int]:
|
||||
try:
|
||||
return channel_inventory.resolve_selected_channels(
|
||||
selected_channels = channel_inventory.resolve_selected_channels(
|
||||
channel_limit=args.channel_limit,
|
||||
explicit_channels=args.channels,
|
||||
)
|
||||
@@ -83,6 +89,20 @@ def resolve_channels(args: argparse.Namespace) -> list[int]:
|
||||
print(str(exc), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not args.allow_internal_only and not channel_inventory.has_public_channel(
|
||||
selected_channels,
|
||||
client_visible_only=True,
|
||||
):
|
||||
print(
|
||||
"Selected channels do not include any client-visible public channel. "
|
||||
"Add a public channel such as --channel 1, or pass --allow-internal-only "
|
||||
"if an auth/internal-only stack is intentional.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
return selected_channels
|
||||
|
||||
|
||||
def resolve_instances(selected_channels: list[int]) -> list[str]:
|
||||
return channel_inventory.get_instances(selected_channels)
|
||||
@@ -148,6 +168,16 @@ def main() -> int:
|
||||
render_template(BIN_DIR / "metin-collect-incident.in", template_values),
|
||||
0o700,
|
||||
)
|
||||
write_text(
|
||||
sbin_dir / "metin-core-backtrace",
|
||||
render_template(BIN_DIR / "metin-core-backtrace.in", template_values),
|
||||
0o700,
|
||||
)
|
||||
copy_file(
|
||||
HEALTHCHECK_DIR / "metin-login-healthcheck.sh",
|
||||
sbin_dir / "metin-login-healthcheck",
|
||||
0o700,
|
||||
)
|
||||
|
||||
verify_units = [str(systemd_dir / unit_name) for unit_name in unit_names]
|
||||
run(["systemd-analyze", "verify", *verify_units])
|
||||
|
||||
@@ -19,7 +19,12 @@ Installed on the VPS:
|
||||
|
||||
## What The Headless Healthcheck Verifies
|
||||
|
||||
The installed wrapper now performs two headless passes against the live server:
|
||||
The installed wrapper supports two modes:
|
||||
|
||||
- `--mode ready`
|
||||
- `--mode full`
|
||||
|
||||
The full mode performs two headless passes against the live server:
|
||||
|
||||
1. a select-screen create/delete pass
|
||||
2. a full auth + channel + `ENTERGAME` + mall pass
|
||||
@@ -48,7 +53,7 @@ This is an end-to-end gameplay-path verification, not just a TCP port check.
|
||||
|
||||
## How The Wrapper Works
|
||||
|
||||
`metin-login-healthcheck.sh` does the following:
|
||||
`metin-login-healthcheck.sh --mode full` does the following:
|
||||
|
||||
- creates two temporary accounts in MariaDB
|
||||
- runs `metin_login_smoke` once in create/delete mode on the select screen
|
||||
@@ -58,6 +63,15 @@ This is an end-to-end gameplay-path verification, not just a TCP port check.
|
||||
- deletes both temporary accounts and any temporary character rows on exit
|
||||
- passes the configured client version expected by the server
|
||||
|
||||
`metin-login-healthcheck.sh --mode ready` is intentionally lighter:
|
||||
|
||||
- creates one temporary account in MariaDB
|
||||
- runs one headless login flow through auth + channel + character create + select + `ENTERGAME`
|
||||
- does not run the delete pass
|
||||
- does not open the mall
|
||||
|
||||
This mode is the right readiness probe immediately after a service restart. It verifies that the server is login-ready without depending on the deeper post-login mall path.
|
||||
|
||||
It is intended for manual admin use on the VPS.
|
||||
|
||||
## Usage
|
||||
@@ -69,6 +83,12 @@ ssh mt2
|
||||
/usr/local/sbin/metin-login-healthcheck
|
||||
```
|
||||
|
||||
Readiness-only mode:
|
||||
|
||||
```bash
|
||||
/usr/local/sbin/metin-login-healthcheck --mode ready
|
||||
```
|
||||
|
||||
The smoke binary can also be run directly:
|
||||
|
||||
```bash
|
||||
@@ -109,6 +129,19 @@ Useful direct flags:
|
||||
- `--mall-password=PASSWORD`
|
||||
after `ENTERGAME`, opens the in-game mall via encrypted chat command and verifies `MALL_OPEN`
|
||||
|
||||
Operational CLI:
|
||||
|
||||
```bash
|
||||
metinctl public-ready
|
||||
metinctl healthcheck --mode full
|
||||
metinctl healthcheck --mode ready
|
||||
metinctl wait-ready
|
||||
```
|
||||
|
||||
`metinctl public-ready` verifies that every enabled client-visible public channel unit is active and that its declared listener port is actually up.
|
||||
|
||||
`metinctl wait-ready` now first waits for the public runtime to be up and only then runs the lighter `ready` login probe. The deeper `full` mode remains available as an explicit admin healthcheck.
|
||||
|
||||
Example negative auth test:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -32,15 +32,26 @@ The Debian deployment installs:
|
||||
|
||||
`metinctl` is a lightweight operational CLI for:
|
||||
|
||||
- showing an operational summary
|
||||
- showing recent auth success/failure activity
|
||||
- showing auth activity grouped by source IP
|
||||
- showing recent `syserr.log` entries
|
||||
- summarizing recurring `syserr.log` entries
|
||||
- viewing inventory
|
||||
- listing managed units
|
||||
- checking service status
|
||||
- listing declared ports
|
||||
- verifying that enabled public client-facing channels are actually up
|
||||
- listing recent auth failures
|
||||
- listing recent login sessions
|
||||
- listing stale open sessions without logout
|
||||
- restarting the whole stack or specific channels/instances
|
||||
- viewing logs
|
||||
- listing core files in the runtime tree
|
||||
- generating a backtrace for the newest or selected core file
|
||||
- collecting incident bundles
|
||||
- running the root-only headless healthcheck
|
||||
- waiting for login-ready state after restart
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -56,12 +67,90 @@ Show current unit state:
|
||||
metinctl status
|
||||
```
|
||||
|
||||
Show a quick operational summary:
|
||||
|
||||
```bash
|
||||
metinctl summary
|
||||
```
|
||||
|
||||
Show declared ports and whether they are currently listening:
|
||||
|
||||
```bash
|
||||
metinctl ports --live
|
||||
```
|
||||
|
||||
Verify that enabled client-visible public channels are active and listening:
|
||||
|
||||
```bash
|
||||
metinctl public-ready
|
||||
```
|
||||
|
||||
Show recent real auth failures and skip smoke-test logins:
|
||||
|
||||
```bash
|
||||
metinctl auth-failures
|
||||
```
|
||||
|
||||
Show recent auth success/failure flow:
|
||||
|
||||
```bash
|
||||
metinctl auth-activity
|
||||
```
|
||||
|
||||
Show only recent auth failures including smoke tests:
|
||||
|
||||
```bash
|
||||
metinctl auth-activity --status failure --include-smoke
|
||||
```
|
||||
|
||||
Show auth activity grouped by IP:
|
||||
|
||||
```bash
|
||||
metinctl auth-ips
|
||||
```
|
||||
|
||||
Show the latest runtime errors collected from all `syserr.log` files:
|
||||
|
||||
```bash
|
||||
metinctl recent-errors
|
||||
```
|
||||
|
||||
Show the most repeated runtime errors in the last 24 hours:
|
||||
|
||||
```bash
|
||||
metinctl error-summary
|
||||
```
|
||||
|
||||
Include smoke-test failures too:
|
||||
|
||||
```bash
|
||||
metinctl auth-failures --include-smoke
|
||||
```
|
||||
|
||||
Show recent login sessions from `log.loginlog2`:
|
||||
|
||||
```bash
|
||||
metinctl sessions
|
||||
```
|
||||
|
||||
Show only sessions that still have no recorded logout:
|
||||
|
||||
```bash
|
||||
metinctl sessions --active-only
|
||||
```
|
||||
|
||||
Show stale open sessions older than 30 minutes:
|
||||
|
||||
```bash
|
||||
metinctl session-audit
|
||||
```
|
||||
|
||||
Use a different stale threshold:
|
||||
|
||||
```bash
|
||||
metinctl session-audit --stale-minutes 10
|
||||
```
|
||||
|
||||
Restart only channel 1 cores:
|
||||
|
||||
```bash
|
||||
@@ -80,10 +169,22 @@ Tail auth logs:
|
||||
metinctl logs auth -n 200 -f
|
||||
```
|
||||
|
||||
Run the end-to-end healthcheck:
|
||||
Run the deeper end-to-end healthcheck:
|
||||
|
||||
```bash
|
||||
metinctl healthcheck
|
||||
metinctl healthcheck --mode full
|
||||
```
|
||||
|
||||
Run the lighter readiness probe:
|
||||
|
||||
```bash
|
||||
metinctl healthcheck --mode ready
|
||||
```
|
||||
|
||||
Wait until a restarted stack is login-ready:
|
||||
|
||||
```bash
|
||||
metinctl wait-ready
|
||||
```
|
||||
|
||||
List core files currently present in the runtime tree:
|
||||
@@ -92,6 +193,18 @@ List core files currently present in the runtime tree:
|
||||
metinctl cores
|
||||
```
|
||||
|
||||
Generate a backtrace for the newest core file:
|
||||
|
||||
```bash
|
||||
metinctl backtrace
|
||||
```
|
||||
|
||||
Generate a backtrace for one specific core file:
|
||||
|
||||
```bash
|
||||
metinctl backtrace --core channels/channel1/core1/core.2255450
|
||||
```
|
||||
|
||||
Collect an incident bundle with logs, unit status, port state and repository revisions:
|
||||
|
||||
```bash
|
||||
@@ -113,6 +226,7 @@ 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`
|
||||
- installs now refuse an auth/internal-only channel selection unless you pass `--allow-internal-only`
|
||||
|
||||
This makes channel enablement declarative instead of depending on whatever happened to be enabled previously.
|
||||
|
||||
@@ -121,6 +235,7 @@ This makes channel enablement declarative instead of depending on whatever happe
|
||||
The Debian deployment now also installs:
|
||||
|
||||
- `/usr/local/sbin/metin-collect-incident`
|
||||
- `/usr/local/sbin/metin-core-backtrace`
|
||||
|
||||
The collector creates a timestamped bundle under:
|
||||
|
||||
@@ -134,7 +249,16 @@ Each bundle contains:
|
||||
- listener state from `ss -ltnp`
|
||||
- tailed runtime `syslog.log` and `syserr.log` files
|
||||
- metadata for any `core*` files found under `runtime/server/channels`
|
||||
- metadata for the executable inferred for each core file
|
||||
|
||||
If you call it with `--include-cores`, matching core files are copied into the bundle as well.
|
||||
If you call it with `--include-cores`, matching core files are copied into the bundle as well. In the same mode, the inferred executable files are copied too, so a later redeploy does not destroy your ability to symbolicate the crash with the original binary snapshot.
|
||||
|
||||
The runtime units now also declare `LimitCORE=infinity`, so after the next service restart the processes are allowed to emit core dumps when the host kernel/core policy permits it.
|
||||
|
||||
For quick manual crash triage outside the incident bundle flow, use:
|
||||
|
||||
```bash
|
||||
metinctl backtrace
|
||||
```
|
||||
|
||||
It defaults to the newest core file under the runtime tree, infers the executable path, and uses `gdb` or `lldb` when present on the host. If no supported debugger is installed, it still prints file/readelf metadata for the core and executable. If the current executable is newer than the core file, the helper prints an explicit warning because the backtrace may no longer match the crashed binary.
|
||||
|
||||
Reference in New Issue
Block a user