Added Unix socket support for the Prometheus metrics server.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Failing after 5s
CI / Tests (push) Successful in 8s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-06-11 15:07:44 -04:00
parent 7b395e1834
commit 446c0cd9ca
5 changed files with 255 additions and 45 deletions
+25 -3
View File
@@ -33,6 +33,7 @@ from crabstero.__main__ import (
_watchdog_interval,
_watchdog_loop,
)
from crabstero.metrics import TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
@@ -141,7 +142,7 @@ class TestParseArgs:
def test_listen_metrics_parses_address(self) -> None:
"""--listen-metrics HOST:PORT sets listen_metrics to (host, port)."""
args = _parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:9090"])
assert args.listen_metrics == ("127.0.0.1", 9090)
assert args.listen_metrics == TcpMetricsAddress("127.0.0.1", 9090)
def test_listen_metrics_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Omitting --listen-metrics defaults to None."""
@@ -152,13 +153,29 @@ class TestParseArgs:
def test_listen_metrics_ipv6(self) -> None:
"""--listen-metrics [::1]:PORT parses IPv6 address correctly."""
args = _parse_args(["--token", "test", "--listen-metrics", "[::1]:9090"])
assert args.listen_metrics == ("[::1]", 9090)
assert args.listen_metrics == TcpMetricsAddress("[::1]", 9090)
def test_listen_metrics_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""LISTEN_METRICS env var is used when --listen-metrics is omitted."""
monkeypatch.setenv("LISTEN_METRICS", "127.0.0.1:8080")
args = _parse_args(["--token", "test"])
assert args.listen_metrics == ("127.0.0.1", 8080)
assert args.listen_metrics == TcpMetricsAddress("127.0.0.1", 8080)
def test_listen_metrics_unix_socket(self) -> None:
"""--listen-metrics unix:/path sets listen_metrics to a Unix socket."""
args = _parse_args(
["--token", "test", "--listen-metrics", "unix:/run/crabstero.sock"],
)
assert args.listen_metrics == UnixMetricsAddress("/run/crabstero.sock")
def test_listen_metrics_unix_socket_from_env(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LISTEN_METRICS can enable metrics on a Unix socket."""
monkeypatch.setenv("LISTEN_METRICS", "unix:/run/crabstero.sock")
args = _parse_args(["--token", "test"])
assert args.listen_metrics == UnixMetricsAddress("/run/crabstero.sock")
def test_listen_metrics_invalid_format(self) -> None:
"""--listen-metrics with no colon raises SystemExit."""
@@ -170,6 +187,11 @@ class TestParseArgs:
with pytest.raises(SystemExit):
_parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:abc"])
def test_listen_metrics_empty_unix_socket_path(self) -> None:
"""--listen-metrics unix: with no path raises SystemExit."""
with pytest.raises(SystemExit):
_parse_args(["--token", "test", "--listen-metrics", "unix:"])
def test_missing_token_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Missing token causes SystemExit."""
monkeypatch.delenv("TOKEN", raising=False)
+112 -15
View File
@@ -17,17 +17,41 @@
Tests cover metric object registration and the MetricsServer HTTP endpoint.
"""
import errno
import socket
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal, cast
import aiohttp
import pytest
from prometheus_client import generate_latest
from crabstero.metrics import MetricsServer
from crabstero.metrics import MetricsServer, TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
type MetricsTransport = Literal["tcp", "unix-socket"]
@dataclass(frozen=True, slots=True)
class MetricsEndpoint:
"""Client details for one running metrics transport."""
base_url: str
unix_socket_path: str | None = None
def client_session(self) -> aiohttp.ClientSession:
"""Create an aiohttp client session for this metrics transport."""
connector = (
aiohttp.UnixConnector(path=self.unix_socket_path)
if self.unix_socket_path is not None
else None
)
return aiohttp.ClientSession(connector=connector)
class TestMetricObjects:
@@ -75,23 +99,49 @@ class TestMetricObjects:
assert name in output, f"{name} not found in Prometheus output"
@pytest.fixture
async def metrics_server() -> AsyncGenerator[MetricsServer]:
"""Start a MetricsServer on an OS-assigned port and stop it after the test."""
server = MetricsServer("127.0.0.1", 0)
await server.start()
yield server
await server.stop()
@pytest.fixture(
params=[
pytest.param("tcp", id="tcp"),
pytest.param("unix-socket", id="unix-socket"),
],
)
async def metrics_endpoint(
request: pytest.FixtureRequest,
tmp_path: Path,
) -> AsyncGenerator[MetricsEndpoint]:
"""Start a MetricsServer on each supported transport."""
transport = cast("MetricsTransport", request.param)
match transport:
case "tcp":
server = MetricsServer(TcpMetricsAddress("127.0.0.1", 0))
await server.start()
endpoint = MetricsEndpoint(f"http://127.0.0.1:{server.port}")
case "unix-socket":
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
endpoint = MetricsEndpoint(
"http://crabstero",
unix_socket_path=str(socket_path),
)
try:
yield endpoint
finally:
await server.stop()
class TestMetricsServer:
"""HTTP server serves Prometheus metrics on /metrics."""
async def test_serves_metrics_endpoint(self, metrics_server: MetricsServer) -> None:
async def test_serves_metrics_endpoint(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET /metrics returns 200 with metric output containing our metrics."""
async with (
aiohttp.ClientSession() as session,
session.get(f"http://127.0.0.1:{metrics_server.port}/metrics") as resp,
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
@@ -99,11 +149,58 @@ class TestMetricsServer:
async def test_non_metrics_path_returns_404(
self,
metrics_server: MetricsServer,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET on an unknown path returns 404."""
async with (
aiohttp.ClientSession() as session,
session.get(f"http://127.0.0.1:{metrics_server.port}/notfound") as resp,
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/notfound") as resp,
):
assert resp.status == HTTPStatus.NOT_FOUND
async def test_stale_unix_socket_path_is_recovered(
self,
tmp_path: Path,
) -> None:
"""Startup removes an abandoned Unix socket file left by a crash."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as stale_socket:
stale_socket.bind(str(socket_path))
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
try:
connector = aiohttp.UnixConnector(path=str(socket_path))
async with (
aiohttp.ClientSession(connector=connector) as session,
session.get("http://crabstero/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
finally:
await server.stop()
async def test_active_unix_socket_path_fails(
self,
tmp_path: Path,
) -> None:
"""Startup refuses to replace a Unix socket path that is still in use."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as active_socket:
active_socket.bind(str(socket_path))
active_socket.listen(1)
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(OSError, match="already in use") as exc_info:
await server.start()
assert exc_info.value.errno == errno.EADDRINUSE
async def test_non_socket_unix_path_fails(self, tmp_path: Path) -> None:
"""Startup refuses to replace a non-socket path."""
socket_path = tmp_path / "metrics.sock"
socket_path.write_text("")
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(FileExistsError):
await server.start()