af60ca446d
The fabledscryer->steward rename had only ever reached host_agent. The other five bundled plugins (http, snmp, traefik, unifi, docker) still imported `from fabledscryer.*` (package no longer exists) and read FABLEDSCRYER_* env vars — so every one of them was broken at import since the original rebrand. CI stayed green only because none are enabled by default and migrations don't import plugin modules. Now that they version in-tree, complete the rename: - fabledscryer.* -> steward.* imports across all five plugins - FABLEDSCRYER_* -> STEWARD_* in plugin migration env.py files - author/repository/homepage + user-facing 'Fabled Scryer' strings -> Steward - snmp/scheduler.py: also drop dead `now`/datetime; record_metric from steward Adds tests/test_no_legacy_names.py — fails if 'scryer'/'roundtable' ever reappear in shipped code (the drift bit twice; this stops a third time). Also clears pre-existing ruff lint debt (unused imports, semicolon statements, mid-file import) surfaced by the new lint lane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
# plugins/docker/scraper.py
|
|
"""Docker API client via Unix socket."""
|
|
from __future__ import annotations
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _short_id(container_id: str) -> str:
|
|
return container_id[:12] if container_id else ""
|
|
|
|
|
|
def _calc_cpu_pct(stats: dict) -> float:
|
|
"""Calculate CPU % from a Docker stats snapshot (one-shot)."""
|
|
try:
|
|
cpu = stats.get("cpu_stats", {})
|
|
precpu = stats.get("precpu_stats", {})
|
|
cpu_delta = (
|
|
cpu["cpu_usage"]["total_usage"] - precpu["cpu_usage"]["total_usage"]
|
|
)
|
|
sys_delta = cpu.get("system_cpu_usage", 0) - precpu.get("system_cpu_usage", 0)
|
|
num_cpus = cpu.get("online_cpus") or len(
|
|
cpu["cpu_usage"].get("percpu_usage") or [None]
|
|
)
|
|
if sys_delta <= 0 or cpu_delta < 0:
|
|
return 0.0
|
|
return round((cpu_delta / sys_delta) * num_cpus * 100.0, 2)
|
|
except (KeyError, TypeError, ZeroDivisionError):
|
|
return 0.0
|
|
|
|
|
|
def _calc_mem(stats: dict) -> tuple[int, int, float]:
|
|
"""Return (usage_bytes, limit_bytes, mem_pct) from Docker stats."""
|
|
try:
|
|
mem = stats.get("memory_stats", {})
|
|
usage = mem.get("usage", 0)
|
|
limit = mem.get("limit", 0)
|
|
# Subtract page cache for working set (cgroup v1: "cache", v2: "inactive_file")
|
|
cache = mem.get("stats", {}).get("cache", 0) or \
|
|
mem.get("stats", {}).get("inactive_file", 0)
|
|
actual = max(0, usage - cache)
|
|
pct = round(actual / limit * 100.0, 2) if limit > 0 else 0.0
|
|
return actual, limit, pct
|
|
except (KeyError, TypeError):
|
|
return 0, 0, 0.0
|
|
|
|
|
|
def _parse_ports(ports: list) -> list[dict]:
|
|
"""Normalise Docker port bindings to a compact list."""
|
|
result = []
|
|
for p in (ports or []):
|
|
if p.get("PublicPort"):
|
|
result.append({
|
|
"host_port": p["PublicPort"],
|
|
"container_port": p["PrivatePort"],
|
|
"protocol": p.get("Type", "tcp"),
|
|
})
|
|
return result
|
|
|
|
|
|
async def scrape_docker(socket_path: str, include_stopped: bool) -> list[dict]:
|
|
"""
|
|
Fetch all containers + resource stats from the Docker daemon.
|
|
Returns a list of normalised dicts ready for the scheduler to persist.
|
|
Raises ConnectionError if the socket is unreachable.
|
|
"""
|
|
transport = httpx.AsyncHTTPTransport(uds=socket_path)
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
transport=transport,
|
|
base_url="http://docker",
|
|
timeout=10.0,
|
|
) as client:
|
|
resp = await client.get(
|
|
"/containers/json",
|
|
params={"all": "true" if include_stopped else "false"},
|
|
)
|
|
resp.raise_for_status()
|
|
containers = resp.json()
|
|
|
|
async def _get_stats(c: dict) -> tuple[dict, dict | None]:
|
|
if c.get("State") != "running":
|
|
return c, None
|
|
try:
|
|
r = await client.get(
|
|
f"/containers/{c['Id']}/stats",
|
|
params={"stream": "false", "one-shot": "true"},
|
|
timeout=5.0,
|
|
)
|
|
return c, r.json()
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"Stats unavailable for %s: %s", _short_id(c.get("Id", "")), exc
|
|
)
|
|
return c, None
|
|
|
|
pairs = await asyncio.gather(*[_get_stats(c) for c in containers])
|
|
|
|
except httpx.ConnectError as exc:
|
|
raise ConnectionError(
|
|
f"Cannot connect to Docker socket at {socket_path}: {exc}"
|
|
) from exc
|
|
|
|
results = []
|
|
for container, stats in pairs:
|
|
names = container.get("Names") or []
|
|
name = names[0].lstrip("/") if names else _short_id(container.get("Id", ""))
|
|
|
|
cpu_pct = mem_usage = mem_limit = mem_pct = None
|
|
if stats is not None:
|
|
cpu_pct = _calc_cpu_pct(stats)
|
|
mem_usage, mem_limit, mem_pct = _calc_mem(stats)
|
|
|
|
# Created is a Unix epoch int from /containers/json
|
|
created_ts = container.get("Created")
|
|
started_at = (
|
|
datetime.fromtimestamp(created_ts, tz=timezone.utc)
|
|
if isinstance(created_ts, (int, float)) else None
|
|
)
|
|
|
|
results.append({
|
|
"name": name,
|
|
"container_id": _short_id(container.get("Id", "")),
|
|
"image": container.get("Image", ""),
|
|
"status": container.get("State", "unknown"),
|
|
"cpu_pct": cpu_pct,
|
|
"mem_usage_bytes": mem_usage,
|
|
"mem_limit_bytes": mem_limit,
|
|
"mem_pct": mem_pct,
|
|
"restart_count": 0, # would require /inspect; left as 0 for now
|
|
"ports": _parse_ports(container.get("Ports", [])),
|
|
"started_at": started_at,
|
|
})
|
|
|
|
return results
|