This commit is contained in:
DerGrumpf
2026-04-26 16:56:13 +00:00
commit 76adff71d7
10 changed files with 2016 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
"""
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()