""" 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="") # ── 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()