277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""
|
|
CLI entry point for xonotic-exporter.
|
|
|
|
Sub-commands
|
|
────────────
|
|
serve Start the HTTP exporter (default when no sub-command given).
|
|
query Scrape one server and print results (human-readable or raw Prometheus).
|
|
validate Check a config file for errors and exit.
|
|
|
|
Examples
|
|
────────
|
|
# Start the exporter
|
|
xonotic-exporter serve /etc/xonotic_exporter/config.toml
|
|
|
|
# Query a server defined in a config file
|
|
xonotic-exporter query vehicles --config /etc/xonotic_exporter/config.toml
|
|
|
|
# Query a server ad-hoc (no config file needed)
|
|
xonotic-exporter query --host localhost --port 26010 \\
|
|
--password secret --mode 2
|
|
|
|
# Same but print raw Prometheus exposition format
|
|
xonotic-exporter query vehicles --config config.toml --prometheus
|
|
|
|
# Validate config only
|
|
xonotic-exporter validate /etc/xonotic_exporter/config.toml
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from typing import Optional
|
|
|
|
from .config import (
|
|
ConfigError,
|
|
ExporterConfig,
|
|
ServerConfig,
|
|
RCON_MODE_NAMES,
|
|
RCON_MODE_SECURE_CHALLENGE,
|
|
load_config,
|
|
)
|
|
from .metrics_parser import XonoticMetrics
|
|
from .prometheus import build_registry
|
|
from .rcon import RconError, scrape_server
|
|
from .server import run_server
|
|
|
|
|
|
# ── Logging setup ──────────────────────────────────────────────────────────────
|
|
|
|
def _setup_logging(verbose: bool) -> None:
|
|
level = logging.DEBUG if verbose else logging.INFO
|
|
logging.basicConfig(
|
|
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
level=level,
|
|
)
|
|
|
|
|
|
# ── Human-readable output ──────────────────────────────────────────────────────
|
|
|
|
_SEP = "─" * 52
|
|
|
|
def _print_human(server_name: str, metrics: XonoticMetrics) -> None:
|
|
mode_label = RCON_MODE_NAMES.get(0, "unknown") # filled per-server in query
|
|
|
|
ping_ms = metrics.ping * 1000
|
|
|
|
print(_SEP)
|
|
print(f" Server : {server_name}")
|
|
print(f" Hostname : {metrics.hostname}")
|
|
print(f" Map : {metrics.map_name}")
|
|
print(f" Public : {'yes' if metrics.sv_public > 0 else 'no'} (sv_public={metrics.sv_public})")
|
|
print(_SEP)
|
|
print(f" Ping : {ping_ms:.1f} ms")
|
|
print(f" CPU : {metrics.timing_cpu:.1f} %")
|
|
print(f" Lost : {metrics.timing_lost:.1f} %")
|
|
print(f" Offset : avg={metrics.timing_offset_avg:.2f}ms "
|
|
f"max={metrics.timing_offset_max:.2f}ms "
|
|
f"sdev={metrics.timing_offset_sdev:.2f}ms")
|
|
print(_SEP)
|
|
total = metrics.players_active + metrics.players_spectators + metrics.players_bots
|
|
print(f" Players : {total}/{metrics.players_max}")
|
|
print(f" Active : {metrics.players_active}")
|
|
print(f" Spectators : {metrics.players_spectators}")
|
|
print(f" Bots : {metrics.players_bots}")
|
|
print(_SEP)
|
|
|
|
|
|
def _print_prometheus(server_name: str, metrics: XonoticMetrics) -> None:
|
|
_, raw = build_registry(server_name, metrics, up=True)
|
|
sys.stdout.buffer.write(raw)
|
|
|
|
|
|
# ── Sub-command: serve ────────────────────────────────────────────────────────
|
|
|
|
def _cmd_serve(args: argparse.Namespace) -> None:
|
|
try:
|
|
cfg = load_config(args.config)
|
|
except ConfigError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
_setup_logging(args.verbose)
|
|
|
|
# CLI overrides for listen address / port
|
|
if args.host:
|
|
cfg.host = args.host
|
|
if args.port:
|
|
cfg.port = args.port
|
|
|
|
run_server(cfg, config_path=args.config)
|
|
|
|
|
|
# ── Sub-command: query ────────────────────────────────────────────────────────
|
|
|
|
def _cmd_query(args: argparse.Namespace) -> None:
|
|
_setup_logging(args.verbose)
|
|
|
|
# Build a ServerConfig — either from config file or ad-hoc flags
|
|
server: ServerConfig
|
|
|
|
if args.config and args.target:
|
|
# named server from config file
|
|
try:
|
|
cfg = load_config(args.config)
|
|
except ConfigError as exc:
|
|
print(f"Config error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
server = cfg.get_server(args.target)
|
|
if server is None:
|
|
print(
|
|
f"Server {args.target!r} not found in config. "
|
|
f"Available: {cfg.server_names()}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
elif args.host:
|
|
# ad-hoc connection from CLI flags
|
|
name = args.target or f"{args.host}:{args.port}"
|
|
try:
|
|
server = ServerConfig(
|
|
name=name,
|
|
host=args.host,
|
|
port=args.port,
|
|
rcon_password=args.password or "",
|
|
rcon_mode=args.mode,
|
|
)
|
|
except ConfigError as exc:
|
|
print(f"Invalid arguments: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
else:
|
|
print(
|
|
"Error: provide either --config + target name, or --host (with optional flags).",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
# Run the scrape
|
|
try:
|
|
metrics = asyncio.run(scrape_server(server, retries=args.retries, timeout=args.timeout))
|
|
except RconError as exc:
|
|
print(f"Scrape failed: {exc}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
rcon_mode_label = RCON_MODE_NAMES.get(server.rcon_mode, str(server.rcon_mode))
|
|
|
|
if args.prometheus:
|
|
_print_prometheus(server.name, metrics)
|
|
else:
|
|
print(f"\n RCON mode : {rcon_mode_label}")
|
|
_print_human(server.name, metrics)
|
|
|
|
|
|
# ── Sub-command: validate ─────────────────────────────────────────────────────
|
|
|
|
def _cmd_validate(args: argparse.Namespace) -> None:
|
|
_setup_logging(False)
|
|
try:
|
|
cfg = load_config(args.config)
|
|
except ConfigError as exc:
|
|
print(f"Invalid: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"OK — {len(cfg.servers)} server(s) configured:")
|
|
for s in cfg.servers:
|
|
mode_label = RCON_MODE_NAMES.get(s.rcon_mode, str(s.rcon_mode))
|
|
print(f" {s.name:<20} {s.host}:{s.port} rcon_mode={s.rcon_mode} ({mode_label})")
|
|
|
|
|
|
# ── Argument parser ───────────────────────────────────────────────────────────
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
root = argparse.ArgumentParser(
|
|
prog="xonotic-exporter",
|
|
description="Xonotic Prometheus exporter — multi-server, TOML config",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
root.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
|
|
|
|
sub = root.add_subparsers(dest="command", metavar="<command>")
|
|
|
|
# ── serve ──────────────────────────────────────────────────────────────────
|
|
p_serve = sub.add_parser("serve", help="Start the HTTP exporter")
|
|
p_serve.add_argument("config", metavar="CONFIG.toml", help="Path to TOML config file")
|
|
p_serve.add_argument("-l", "--host", default=None, help="Override listen host")
|
|
p_serve.add_argument("-p", "--port", type=int, default=None, help="Override listen port")
|
|
|
|
# ── query ──────────────────────────────────────────────────────────────────
|
|
p_query = sub.add_parser(
|
|
"query",
|
|
help="Scrape a server and print metrics (testing)",
|
|
description=(
|
|
"Scrape one server.\n\n"
|
|
"Use a name from a config file:\n"
|
|
" xonotic-exporter query vehicles --config config.toml\n\n"
|
|
"Or specify connection details ad-hoc:\n"
|
|
" xonotic-exporter query --host localhost --port 26010 --password s3cr3t --mode 2"
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
p_query.add_argument(
|
|
"target", nargs="?", metavar="SERVER_NAME",
|
|
help="Name of the server as defined in config (requires --config)"
|
|
)
|
|
p_query.add_argument("--config", metavar="CONFIG.toml", help="TOML config file")
|
|
# ad-hoc flags
|
|
p_query.add_argument("--host", help="Server hostname or IP")
|
|
p_query.add_argument("--port", type=int, default=26000, help="Server port (default 26000)")
|
|
p_query.add_argument("--password", default="", help="RCON password")
|
|
p_query.add_argument(
|
|
"--mode", type=int, default=RCON_MODE_SECURE_CHALLENGE,
|
|
choices=list(RCON_MODE_NAMES),
|
|
help=(
|
|
"RCON mode: "
|
|
+ ", ".join(f"{k}={v}" for k, v in RCON_MODE_NAMES.items())
|
|
+ f" (default {RCON_MODE_SECURE_CHALLENGE})"
|
|
),
|
|
)
|
|
p_query.add_argument(
|
|
"--prometheus", action="store_true",
|
|
help="Output raw Prometheus exposition format instead of human-readable",
|
|
)
|
|
p_query.add_argument("--retries", type=int, default=3, help="Scrape retry count (default 3)")
|
|
p_query.add_argument("--timeout", type=float, default=5.0, help="Per-attempt timeout seconds (default 5)")
|
|
|
|
# ── validate ───────────────────────────────────────────────────────────────
|
|
p_val = sub.add_parser("validate", help="Validate a config file and exit")
|
|
p_val.add_argument("config", metavar="CONFIG.toml")
|
|
|
|
return root
|
|
|
|
|
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
def main(argv: Optional[list[str]] = None) -> None:
|
|
parser = _build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.command is None:
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
|
|
dispatch = {
|
|
"serve": _cmd_serve,
|
|
"query": _cmd_query,
|
|
"validate": _cmd_validate,
|
|
}
|
|
dispatch[args.command](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|