Files
FabledSteward/plugins/docker/scraper.py
T
bvandeusen af60ca446d
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m8s
fix(plugins): complete steward rename across bundled plugins; clear lint debt
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>
2026-06-01 11:22:20 -04:00

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