Files
bvandeusen a840d6f823
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Failing after 2m23s
CI / publish (push) Has been skipped
feat(docker): collect + persist /system/df image/disk usage (agent 1.6.0)
Backend for the image/disk panel (milestone 77 #942). Agent gains
collect_disk_usage() — one /system/df call (gated on containers existing, so
Docker-less hosts pay nothing), surfacing reclaimable bytes (image size held by
unreferenced images), layers/containers/volumes/build-cache sizes, and the top
50 images by size. Emitted as sample["docker_disk"]; host_agent ingest tracks
the newest sample's copy and hands it to the docker capability as a 5th arg.

New current-state tables docker_disk_usage (one row/host) + docker_images
(per-host image rows), docker_007 migration; ingest upserts the summary and
replaces the image set per host (stale images pruned). Unit tests for the df
parsing/reclaimable math + build_sample gating; integration test for
persistence + image-set replacement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 21:51:14 -04:00

1089 lines
38 KiB
Python

# plugins/host_agent/agent.py
"""Steward host agent — pushes resource metrics to a Steward instance.
Python 3.8+ stdlib only. Target ~300 lines. Served to targets at
GET /plugins/host_agent/agent.py.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import signal
import socket
import sys
import time
import urllib.error
import urllib.request
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
AGENT_VERSION = "1.6.0"
# Default path to the local Docker Engine socket. Overridable via the
# `docker_socket` config key; collection is silently skipped if it's absent or
# unreadable, so this never needs setting on a Docker-less host (zero-config).
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
class ConfigError(Exception):
pass
REQUIRED_KEYS = ("url", "token")
INT_KEYS = ("interval_seconds",)
LIST_KEYS = ("mounts",)
def read_config(path: str) -> dict:
"""Parse a flat `key = value` config file."""
cfg: dict = {}
try:
with open(path, "r", encoding="utf-8") as f:
for lineno, raw in enumerate(f, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise ConfigError(f"{path}:{lineno}: expected 'key = value'")
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if key in INT_KEYS:
try:
cfg[key] = int(value)
except ValueError:
raise ConfigError(f"{path}:{lineno}: {key} must be int")
elif key in LIST_KEYS:
cfg[key] = [v.strip() for v in value.split(",") if v.strip()]
else:
cfg[key] = value
except FileNotFoundError:
raise ConfigError(f"{path}: not found")
missing = [k for k in REQUIRED_KEYS if k not in cfg]
if missing:
raise ConfigError(f"{path}: missing required key(s): {', '.join(missing)}")
cfg.setdefault("interval_seconds", 30)
cfg.setdefault("docker_socket", DEFAULT_DOCKER_SOCKET)
return cfg
# ─── collectors ──────────────────────────────────────────────────────────────
STAT_PATH = "/proc/stat"
MEMINFO_PATH = "/proc/meminfo"
LOADAVG_PATH = "/proc/loadavg"
UPTIME_PATH = "/proc/uptime"
OS_RELEASE_PATH = "/etc/os-release"
NETDEV_PATH = "/proc/net/dev"
DISKSTATS_PATH = "/proc/diskstats"
HWMON_DIR = "/sys/class/hwmon"
PSI_DIR = "/proc/pressure"
# Partition names to drop from disk I/O (we report whole-disk throughput only).
_PARTITION_RE = re.compile(
r"^(?:sd[a-z]+\d+|vd[a-z]+\d+|hd[a-z]+\d+|nvme\d+n\d+p\d+|mmcblk\d+p\d+)$"
)
def _read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
def _parse_cpu_lines(stat_text: str) -> dict:
"""Return {name: (total_jiffies, idle_jiffies)} for every cpu line.
'cpu' is the aggregate; 'cpu0', 'cpu1', … are per-core.
"""
out: dict = {}
for line in stat_text.splitlines():
if not line.startswith("cpu"):
continue
parts = line.split()
try:
fields = [int(x) for x in parts[1:]]
except ValueError:
continue
if len(fields) < 4:
continue
idle = fields[3] + (fields[4] if len(fields) > 4 else 0) # idle + iowait
out[parts[0]] = (sum(fields), idle)
return out
def collect_cpu(sample_window: float = 0.2) -> tuple[float, list]:
"""Return (aggregate_pct, [per_core_pct, …]) over sample_window seconds."""
a = _parse_cpu_lines(_read_file(STAT_PATH))
time.sleep(sample_window)
b = _parse_cpu_lines(_read_file(STAT_PATH))
def _pct(name: str):
if name not in a or name not in b:
return None
dt = b[name][0] - a[name][0]
di = b[name][1] - a[name][1]
if dt <= 0:
return 0.0
return round(100.0 * (dt - di) / dt, 2)
agg = _pct("cpu") or 0.0
cores: list = []
i = 0
while f"cpu{i}" in b:
cores.append(_pct(f"cpu{i}"))
i += 1
return agg, cores
def collect_memory() -> dict:
info: dict[str, int] = {}
for line in _read_file(MEMINFO_PATH).splitlines():
if ":" not in line:
continue
key, _, rest = line.partition(":")
parts = rest.strip().split()
if not parts:
continue
try:
value_kb = int(parts[0])
except ValueError:
continue
info[key] = value_kb * 1024 # bytes
total = info.get("MemTotal", 0)
available = info.get("MemAvailable", 0)
swap_total = info.get("SwapTotal", 0)
swap_free = info.get("SwapFree", 0)
return {
"total_bytes": total,
"used_bytes": max(total - available, 0),
"available_bytes": available,
"swap_used_bytes": max(swap_total - swap_free, 0),
"cached_bytes": info.get("Cached", 0),
"buffers_bytes": info.get("Buffers", 0),
}
def collect_storage(mounts: list[str]) -> list[dict]:
out: list[dict] = []
for m in mounts:
try:
u = shutil.disk_usage(m)
except (FileNotFoundError, PermissionError):
continue
out.append({"mount": m, "total_bytes": u.total, "used_bytes": u.used})
return out
def collect_load() -> dict:
parts = _read_file(LOADAVG_PATH).split()
return {"1m": float(parts[0]), "5m": float(parts[1]), "15m": float(parts[2])}
def collect_uptime() -> int:
return int(float(_read_file(UPTIME_PATH).split()[0]))
def detect_primary_ip() -> str | None:
"""Best-effort primary non-loopback IPv4 of this host.
connect() on a SOCK_DGRAM socket only sets the default peer and selects the
outbound interface — no packets are sent — so getsockname() reveals the
source IP the kernel would use to reach the internet. This is the host's own
view of its address, so it survives reverse proxies / NAT (unlike the
server reading request.remote_addr). Returns None on any error (offline / no
route) and skips loopback.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
except OSError:
return None
finally:
s.close()
if not ip or ip.startswith("127."):
return None
return ip
def collect_metadata() -> dict:
u = os.uname()
distro = "unknown"
try:
for line in _read_file(OS_RELEASE_PATH).splitlines():
if line.startswith("PRETTY_NAME="):
distro = line.split("=", 1)[1].strip().strip('"')
break
except (OSError, FileNotFoundError):
pass
return {
"kernel": u.release,
"distro": distro,
"arch": u.machine,
"host_ip": detect_primary_ip(),
}
def default_mounts() -> list[str]:
"""Return real mount points from /proc/mounts, skipping pseudo filesystems."""
skip_types = {"tmpfs", "devtmpfs", "proc", "sysfs", "cgroup", "cgroup2",
"overlay", "squashfs", "ramfs", "devpts", "mqueue", "pstore",
"securityfs", "debugfs", "tracefs", "hugetlbfs", "fusectl",
"configfs", "bpf", "autofs", "efivarfs", "binfmt_misc"}
mounts: list[str] = []
try:
with open("/proc/mounts", "r") as f:
for line in f:
parts = line.split()
if len(parts) < 3:
continue
if parts[2] in skip_types:
continue
mounts.append(parts[1])
except OSError:
return ["/"]
return mounts or ["/"]
def collect_net_raw() -> dict:
"""Return {iface: (rx_bytes, tx_bytes)} cumulative counters from /proc/net/dev.
Skips loopback and veth* (container) interfaces to cut noise. Counters are
monotonic; per-second rates are derived from deltas in build_sample().
"""
out: dict = {}
try:
text = _read_file(NETDEV_PATH)
except OSError:
return out
for line in text.splitlines():
if ":" not in line:
continue
name, _, rest = line.partition(":")
name = name.strip()
if name == "lo" or name.startswith("veth"):
continue
f = rest.split()
if len(f) < 16:
continue
try:
out[name] = (int(f[0]), int(f[8])) # rx_bytes, tx_bytes
except ValueError:
continue
return out
def collect_diskio_raw() -> dict:
"""Return {device: (read_bytes, write_bytes)} cumulative from /proc/diskstats.
Whole disks only — loop/ram/sr/fd and partitions are skipped. Sectors are
512 bytes. Counters are monotonic; rates derived from deltas.
"""
out: dict = {}
try:
text = _read_file(DISKSTATS_PATH)
except OSError:
return out
for line in text.splitlines():
f = line.split()
if len(f) < 10:
continue
name = f[2]
if name.startswith(("loop", "ram", "sr", "fd")) or _PARTITION_RE.match(name):
continue
try:
out[name] = (int(f[5]) * 512, int(f[9]) * 512) # sectors_read, sectors_written
except ValueError:
continue
return out
def collect_temps() -> list:
"""Return [{label, celsius}] from /sys/class/hwmon (best-effort, may be empty)."""
out: list = []
try:
chips = os.listdir(HWMON_DIR)
except OSError:
return out
for hw in chips:
d = os.path.join(HWMON_DIR, hw)
chip = ""
try:
chip = _read_file(os.path.join(d, "name")).strip()
except OSError:
pass
try:
files = os.listdir(d)
except OSError:
continue
for fn in files:
if not (fn.startswith("temp") and fn.endswith("_input")):
continue
idx = fn[: -len("_input")] # e.g. "temp1"
try:
milli = int(_read_file(os.path.join(d, fn)).strip())
except (OSError, ValueError):
continue
label = ""
try:
label = _read_file(os.path.join(d, idx + "_label")).strip()
except OSError:
pass
name = label or (f"{chip}_{idx}" if chip else idx)
out.append({"label": name, "celsius": round(milli / 1000.0, 1)})
return out
def _parse_psi(text: str) -> dict:
"""Parse a /proc/pressure/* file into {some_avg10, some_avg60, full_avg10, …}."""
res: dict = {}
for line in text.splitlines():
parts = line.split()
if not parts:
continue
kind = parts[0] # "some" | "full"
for p in parts[1:]:
for prefix, suffix in (("avg10=", "avg10"), ("avg60=", "avg60")):
if p.startswith(prefix):
try:
res[f"{kind}_{suffix}"] = float(p[len(prefix):])
except ValueError:
pass
return res
def collect_psi() -> dict:
"""Return pressure-stall (PSI) gauges, prefixed by resource (mem/cpu/io).
Absent on kernels without CONFIG_PSI — returns only what exists.
"""
out: dict = {}
for res_name, fname in (("mem", "memory"), ("cpu", "cpu"), ("io", "io")):
try:
text = _read_file(os.path.join(PSI_DIR, fname))
except OSError:
continue
for k, v in _parse_psi(text).items():
out[f"{res_name}_{k}"] = v
return out
def _rates(cur: dict, prev: dict, dt: float) -> dict:
"""Per-second rates for counters present in both snapshots over dt seconds.
Skips names missing from prev and negative deltas (counter reset / iface or
disk hot-plug), so a reboot never emits a bogus spike.
"""
out: dict = {}
if dt <= 0:
return out
for name, cur_vals in cur.items():
if name not in prev:
continue
prev_vals = prev[name]
deltas = [c - p for c, p in zip(cur_vals, prev_vals)]
if any(d < 0 for d in deltas):
continue
out[name] = tuple(d / dt for d in deltas)
return out
# ─── docker ──────────────────────────────────────────────────────────────────
#
# Per-host container collection over the local Docker socket. stdlib-only: a
# minimal HTTP/1.1 client over an AF_UNIX socket. We send `Connection: close`
# so the daemon closes the socket at end of response and we can read to EOF;
# Docker still frames the body with Transfer-Encoding: chunked, so we de-chunk
# when that header is present rather than assume Content-Length.
DOCKER_API_TIMEOUT = 5.0
def _dechunk(body: bytes) -> bytes:
"""Decode an HTTP/1.1 chunked-transfer body into the raw payload."""
out = bytearray()
while body:
line, sep, rest = body.partition(b"\r\n")
if not sep:
break
try:
size = int(line.split(b";", 1)[0], 16) # ignore chunk extensions
except ValueError:
break
if size == 0:
break
out += rest[:size]
body = rest[size + 2:] # skip the chunk data and its trailing CRLF
return bytes(out)
def _docker_request(socket_path: str, path: str, timeout: float = DOCKER_API_TIMEOUT):
"""GET `path` from the Docker Engine API over a Unix socket; return JSON.
Raises OSError on any socket/transport problem (absent socket, permission
denied, non-200) and ValueError on a non-JSON body — both are caught by the
caller and treated as "no docker here", so collection degrades silently.
"""
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect(socket_path)
req = (
"GET " + path + " HTTP/1.1\r\n"
"Host: docker\r\n"
"Accept: application/json\r\n"
"Connection: close\r\n"
"\r\n"
)
sock.sendall(req.encode("ascii"))
chunks = []
while True:
buf = sock.recv(65536)
if not buf:
break
chunks.append(buf)
finally:
sock.close()
raw = b"".join(chunks)
head, _, body = raw.partition(b"\r\n\r\n")
header_text = head.decode("latin-1")
status_line = header_text.split("\r\n", 1)[0]
parts = status_line.split(None, 2) # "HTTP/1.1 200 OK"
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 0
if status != 200:
raise OSError(f"docker API {path} returned {status}")
if "transfer-encoding: chunked" in header_text.lower():
body = _dechunk(body)
return json.loads(body.decode("utf-8"))
def _docker_cpu_pct(stats: dict) -> float:
"""CPU % from a Docker stats snapshot (delta of container vs system CPU).
Ported verbatim from the old central scraper's math; correct as long as both
cpu_stats and precpu_stats are populated (we request without `one-shot` so
the daemon fills precpu over one cycle).
"""
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 _docker_mem(stats: dict):
"""Return (usage_bytes, limit_bytes, mem_pct); working set excludes cache."""
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 _docker_ports(ports: list) -> list:
"""Normalise Docker port bindings to a compact published-ports list."""
out = []
for p in (ports or []):
if p.get("PublicPort"):
out.append({
"host_port": p["PublicPort"],
"container_port": p.get("PrivatePort"),
"protocol": p.get("Type", "tcp"),
})
return out
def _docker_grouping(labels: dict) -> dict:
"""Pull compose project + swarm service/task/node out of container labels.
These are set by Docker itself (compose / swarm), so reading them off the
container costs nothing extra and lets Steward group + place containers
without a manager-node query.
"""
labels = labels or {}
return {
"compose_project": labels.get("com.docker.compose.project"),
"service_name": labels.get("com.docker.swarm.service.name"),
"task_id": labels.get("com.docker.swarm.task.id"),
"node_id": labels.get("com.docker.swarm.node.id"),
}
def _docker_inspect_fields(insp: dict):
"""Return (health, restart_count, exit_code, oom_killed) from an inspect.
health is None for containers without a HEALTHCHECK (no State.Health).
"""
state = (insp or {}).get("State") or {}
health = (state.get("Health") or {}).get("Status") # healthy|unhealthy|starting
restart_count = insp.get("RestartCount", 0) if isinstance(insp, dict) else 0
exit_code = state.get("ExitCode")
oom_killed = bool(state.get("OOMKilled", False))
return health, restart_count, exit_code, oom_killed
def _docker_net_io(stats: dict):
"""Return cumulative (net_rx, net_tx, blk_read, blk_write) bytes from stats.
Counters are cumulative since container start (rates can be derived later);
block I/O comes from the cgroup io_service_bytes list, absent on some setups.
"""
net_rx = net_tx = blk_read = blk_write = 0
for iface in (stats.get("networks") or {}).values():
net_rx += iface.get("rx_bytes", 0) or 0
net_tx += iface.get("tx_bytes", 0) or 0
for e in ((stats.get("blkio_stats") or {}).get("io_service_bytes_recursive") or []):
op = (e.get("op") or "").lower()
if op == "read":
blk_read += e.get("value", 0) or 0
elif op == "write":
blk_write += e.get("value", 0) or 0
return net_rx, net_tx, blk_read, blk_write
def _collect_one_container(socket_path: str, c: dict) -> dict:
"""Build one container's enriched record (runs in a worker thread).
Does a per-container inspect (health/restart/exit/oom — only available there)
plus a stats read for running containers (cpu/mem/net/io). All best-effort:
a failed call just leaves the affected fields at their defaults.
"""
cid = c.get("Id", "") or ""
names = c.get("Names") or []
name = names[0].lstrip("/") if names else cid[:12]
state = c.get("State", "unknown")
# Inspect every container (incl. stopped) — exit codes + restart counts only
# live here, and stopped containers are exactly where exit_code matters.
health = exit_code = None
restart_count = 0
oom_killed = False
try:
insp = _docker_request(socket_path, f"/containers/{cid}/json")
health, restart_count, exit_code, oom_killed = _docker_inspect_fields(insp)
except (OSError, ValueError):
pass
cpu_pct = mem_usage = mem_limit = mem_pct = None
net_rx = net_tx = blk_read = blk_write = None
if state == "running":
try:
stats = _docker_request(socket_path, f"/containers/{cid}/stats?stream=false")
cpu_pct = _docker_cpu_pct(stats)
mem_usage, mem_limit, mem_pct = _docker_mem(stats)
net_rx, net_tx, blk_read, blk_write = _docker_net_io(stats)
except (OSError, ValueError):
pass
created = c.get("Created")
started_at = (
datetime.fromtimestamp(created, tz=timezone.utc).isoformat()
if isinstance(created, (int, float)) else None
)
record = {
"name": name,
"container_id": cid[:12],
"image": c.get("Image", ""),
"status": state,
"cpu_pct": cpu_pct,
"mem_usage_bytes": mem_usage,
"mem_limit_bytes": mem_limit,
"mem_pct": mem_pct,
"ports": _docker_ports(c.get("Ports", [])),
"started_at": started_at,
"health": health,
"restart_count": restart_count,
"exit_code": exit_code,
"oom_killed": oom_killed,
"net_rx_bytes": net_rx,
"net_tx_bytes": net_tx,
"blk_read_bytes": blk_read,
"blk_write_bytes": blk_write,
}
record.update(_docker_grouping(c.get("Labels")))
return record
def collect_docker(socket_path: str) -> list:
"""Per-container state from the local Docker socket, or [] if unavailable.
Absent socket / permission denied / non-Docker endpoint all yield [] — the
agent silently reports no containers rather than erroring, so a Docker-less
host needs no configuration.
"""
try:
containers = _docker_request(socket_path, "/containers/json?all=true")
except (OSError, ValueError):
return []
if not isinstance(containers, list) or not containers:
return []
# Each container needs an inspect (+ a stats read if running), and the stats
# call blocks ~1s while the daemon computes the cpu delta. Done serially that
# stretches the whole sample on a busy host, so fan the per-container work out
# over a small bounded thread pool (I/O-bound → threads are enough).
workers = min(8, len(containers))
with ThreadPoolExecutor(max_workers=workers) as ex:
return list(ex.map(lambda c: _collect_one_container(socket_path, c), containers))
# ─── swarm (manager-only) ────────────────────────────────────────────────────
def _swarm_is_manager(info: dict) -> bool:
"""True only on a reachable Swarm manager.
`/services`, `/tasks`, `/nodes` are manager-only — a worker (or a non-swarm
daemon) returns 503 for them. `Swarm.ControlAvailable` in `/info` is exactly
"this node can serve the control-plane API", so we gate on it and skip the
topology query everywhere else (no errors, near-zero cost on workers).
"""
return bool((info or {}).get("Swarm", {}).get("ControlAvailable"))
def _swarm_services(services: list, tasks: list) -> list:
"""Roll desired-vs-running replicas per service up from the task list.
Replica health isn't on the service object — it's derived by counting tasks:
`running` = tasks currently in state running; `desired` = the service's
configured replica count (replicated) or the count of tasks the scheduler
wants running (global, which has no fixed number).
"""
running_by_svc: dict = {}
desired_by_svc: dict = {} # only used for global-mode services
placement: dict = {} # svc_id -> {node_id: running_count}
for t in (tasks or []):
sid = t.get("ServiceID")
if not sid:
continue
if ((t.get("Status") or {}).get("State") or "").lower() == "running":
running_by_svc[sid] = running_by_svc.get(sid, 0) + 1
node = t.get("NodeID")
if node:
placement.setdefault(sid, {})[node] = \
placement.setdefault(sid, {}).get(node, 0) + 1
if (t.get("DesiredState") or "").lower() == "running":
desired_by_svc[sid] = desired_by_svc.get(sid, 0) + 1
out = []
for s in (services or []):
sid = s.get("ID", "") or ""
spec = s.get("Spec") or {}
name = spec.get("Name") or sid[:12]
mode_obj = spec.get("Mode") or {}
if "Global" in mode_obj:
mode = "global"
desired = desired_by_svc.get(sid, 0)
else:
mode = "replicated"
desired = (mode_obj.get("Replicated") or {}).get("Replicas", 0) or 0
image = (((spec.get("TaskTemplate") or {}).get("ContainerSpec") or {})
.get("Image") or "")
image = image.split("@", 1)[0] # drop the @sha256:… digest suffix
out.append({
"service_name": name, "mode": mode,
"desired": desired, "running": running_by_svc.get(sid, 0),
"image": image,
# task→node placement of the running replicas (cross-node — a manager
# sees every task, even ones whose containers live on other hosts).
"placement": [{"node_id": n, "running": cnt}
for n, cnt in sorted(placement.get(sid, {}).items())],
})
return out
def _swarm_nodes(nodes: list) -> list:
"""Normalise node role / availability / status (+ manager leader flag)."""
out = []
for n in (nodes or []):
spec = n.get("Spec") or {}
desc = n.get("Description") or {}
status = n.get("Status") or {}
mgr = n.get("ManagerStatus") or {}
out.append({
"node_id": n.get("ID", "") or "",
"hostname": desc.get("Hostname", "") or "",
"role": (spec.get("Role") or "worker").lower(),
"availability": (spec.get("Availability") or "active").lower(),
"status": (status.get("State") or "unknown").lower(),
"leader": bool(mgr.get("Leader", False)),
})
return out
def collect_swarm(socket_path: str):
"""Swarm topology from a manager node, or None on workers / non-swarm hosts.
Self-detects manager via `/info` (one cheap call); only then queries the
manager-only endpoints. Any transport failure degrades to None, same silent
contract as collect_docker — a non-swarm host adds nothing to the payload.
"""
try:
info = _docker_request(socket_path, "/info")
except (OSError, ValueError):
return None
if not _swarm_is_manager(info):
return None
try:
services = _docker_request(socket_path, "/services")
tasks = _docker_request(socket_path, "/tasks")
nodes = _docker_request(socket_path, "/nodes")
except (OSError, ValueError):
return None
return {
"services": _swarm_services(
services if isinstance(services, list) else [],
tasks if isinstance(tasks, list) else []),
"nodes": _swarm_nodes(nodes if isinstance(nodes, list) else []),
}
# ─── disk usage (image storage) ──────────────────────────────────────────────
def _disk_images(images: list) -> list:
"""Normalise /system/df image rows → compact per-image storage records.
Sorted largest-first and capped so a host with a huge image cache doesn't
bloat the payload; the panel only needs the heavy hitters. `containers` is
how many containers reference the image (0 ⇒ reclaimable).
"""
out = []
for im in images:
if not isinstance(im, dict):
continue
tags = im.get("RepoTags") or []
# Untagged/dangling images report ["<none>:<none>"] or null.
tag = next((t for t in tags if t and t != "<none>:<none>"), None)
raw_id = (im.get("Id") or "").split(":", 1)[-1]
out.append({
"image_id": raw_id[:12],
"repo_tag": tag or "<none>",
"size": int(im.get("Size") or 0),
"shared_size": int(im.get("SharedSize") or 0) if im.get("SharedSize", -1) >= 0 else 0,
"containers": int(im.get("Containers") or 0) if (im.get("Containers") or 0) > 0 else 0,
})
out.sort(key=lambda r: r["size"], reverse=True)
return out[:50]
def collect_disk_usage(socket_path: str):
"""Docker disk usage from `/system/df`, or None if unavailable.
One bounded API call (the daemon walks images/layers/volumes). Reclaimable =
bytes held by images no container references — the same intent as
`docker system df`'s RECLAIMABLE column. Silent-skip (None) on any transport
failure, same contract as collect_docker/collect_swarm.
"""
try:
df = _docker_request(socket_path, "/system/df")
except (OSError, ValueError):
return None
if not isinstance(df, dict):
return None
images = [im for im in (df.get("Images") or []) if isinstance(im, dict)]
containers = [c for c in (df.get("Containers") or []) if isinstance(c, dict)]
volumes = [v for v in (df.get("Volumes") or []) if isinstance(v, dict)]
build_cache = [b for b in (df.get("BuildCache") or []) if isinstance(b, dict)]
def _vol_size(v):
return int((v.get("UsageData") or {}).get("Size") or 0)
return {
"layers_size": int(df.get("LayersSize") or 0),
"images_total": len(images),
"images_active": sum(1 for im in images if (im.get("Containers") or 0) > 0),
"images_size": sum(int(im.get("Size") or 0) for im in images),
"images_reclaimable": sum(
int(im.get("Size") or 0) for im in images if (im.get("Containers") or 0) <= 0),
"containers_count": len(containers),
"containers_size": sum(int(c.get("SizeRw") or 0) for c in containers),
"volumes_count": len(volumes),
"volumes_size": sum(_vol_size(v) for v in volumes if _vol_size(v) > 0),
"build_cache_size": sum(int(b.get("Size") or 0) for b in build_cache),
"images": _disk_images(images),
}
# ─── ring buffer ─────────────────────────────────────────────────────────────
class RingBuffer:
def __init__(self, maxlen: int = 20) -> None:
self._dq: deque = deque(maxlen=maxlen)
def push(self, item) -> None:
self._dq.append(item)
def drain(self) -> list:
out = list(self._dq)
self._dq.clear()
return out
def __len__(self) -> int:
return len(self._dq)
# ─── payload ─────────────────────────────────────────────────────────────────
def build_sample(mounts: list[str], state: dict,
docker_socket: str = DEFAULT_DOCKER_SOCKET) -> dict:
"""Collect one full sample. Partial samples allowed if a collector fails.
`state` carries the previous network/disk counters + monotonic timestamp so
throughput rates can be derived from deltas; it is mutated in place.
`docker_socket` is probed best-effort — the `docker` key is omitted entirely
when no containers are found, so non-Docker hosts add nothing to the payload.
"""
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
try:
agg, cores = collect_cpu()
sample["cpu_pct"] = agg
sample["cpu_cores"] = cores
except Exception:
sample["cpu_pct"] = None
try:
sample["mem"] = collect_memory()
except Exception:
sample["mem"] = None
try:
sample["load"] = collect_load()
except Exception:
sample["load"] = None
try:
sample["uptime_secs"] = collect_uptime()
except Exception:
sample["uptime_secs"] = None
try:
sample["storage"] = collect_storage(mounts)
except Exception:
sample["storage"] = []
try:
sample["temps"] = collect_temps()
except Exception:
sample["temps"] = []
try:
sample["psi"] = collect_psi()
except Exception:
sample["psi"] = {}
# Throughput rates: diff cumulative counters against the previous sample.
now_mono = time.monotonic()
try:
net_raw = collect_net_raw()
except Exception:
net_raw = {}
try:
disk_raw = collect_diskio_raw()
except Exception:
disk_raw = {}
dt = now_mono - state["mono"] if state.get("mono") else 0.0
sample["net"] = [
{"iface": n, "rx_bps": round(rx, 1), "tx_bps": round(tx, 1)}
for n, (rx, tx) in _rates(net_raw, state.get("net", {}), dt).items()
]
sample["diskio"] = [
{"device": n, "read_bps": round(rd, 1), "write_bps": round(wr, 1)}
for n, (rd, wr) in _rates(disk_raw, state.get("disk", {}), dt).items()
]
state["net"], state["disk"], state["mono"] = net_raw, disk_raw, now_mono
try:
docker = collect_docker(docker_socket)
except Exception:
docker = []
if docker:
sample["docker"] = docker
# Swarm topology is only emitted by managers (collect_swarm returns None
# everywhere else), so the key is absent on workers / non-swarm hosts.
try:
swarm = collect_swarm(docker_socket)
except Exception:
swarm = None
if swarm is not None:
sample["swarm"] = swarm
# Image/disk usage — host-level docker fact (one /system/df call), omitted on
# Docker-less hosts. Only meaningful when there's docker here at all, so skip
# the call entirely when collect_docker found nothing.
if docker:
try:
disk = collect_disk_usage(docker_socket)
except Exception:
disk = None
if disk is not None:
sample["docker_disk"] = disk
return sample
def build_payload(samples: list[dict], hostname: str, metadata: dict) -> dict:
return {
"agent_version": AGENT_VERSION,
"hostname": hostname,
"metadata": metadata,
"samples": samples,
}
# ─── POST + backoff ──────────────────────────────────────────────────────────
BACKOFF_CAP = 300
def next_backoff(current: int) -> int:
if current <= 0:
return 30
return min(current * 2, BACKOFF_CAP)
def post_payload(url: str, token: str, payload: dict) -> tuple[bool, int | None]:
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url.rstrip("/") + "/plugins/host_agent/ingest",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"User-Agent": f"steward-host-agent/{AGENT_VERSION}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return (200 <= resp.status < 300, resp.status)
except urllib.error.HTTPError as e:
return (False, e.code)
except (urllib.error.URLError, TimeoutError, socket.timeout, OSError):
return (False, None)
# ─── main loop ───────────────────────────────────────────────────────────────
_reload_requested = False
_shutdown_requested = False
def _handle_hup(_signum, _frame):
global _reload_requested
_reload_requested = True
def _handle_term(_signum, _frame):
global _shutdown_requested
_shutdown_requested = True
def _log(level: str, msg: str) -> None:
sys.stderr.write(f"[{level}] {msg}\n")
sys.stderr.flush()
def main_loop(conf_path: str) -> int:
global _reload_requested, _shutdown_requested
signal.signal(signal.SIGHUP, _handle_hup)
signal.signal(signal.SIGTERM, _handle_term)
try:
cfg = read_config(conf_path)
except ConfigError as e:
_log("ERROR", str(e))
return 2
metadata = collect_metadata()
hostname = cfg.get("hostname") or socket.gethostname()
mounts = cfg.get("mounts") or default_mounts()
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
buffer = RingBuffer(maxlen=20)
backoff = 0
# Carries previous net/disk counters + monotonic ts for rate computation.
rate_state: dict = {}
_log("INFO", f"steward-host-agent {AGENT_VERSION} starting "
f"(url={cfg['url']}, interval={cfg['interval_seconds']}s)")
while not _shutdown_requested:
if _reload_requested:
try:
cfg = read_config(conf_path)
mounts = cfg.get("mounts") or default_mounts()
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
metadata = collect_metadata() # refresh host_ip/distro on reload
_log("INFO", "config reloaded")
except ConfigError as e:
_log("ERROR", f"reload failed: {e}")
_reload_requested = False
sample = build_sample(mounts, rate_state, docker_socket)
buffered = buffer.drain()
payload = build_payload(
samples=buffered + [sample],
hostname=hostname,
metadata=metadata,
)
ok, status = post_payload(cfg["url"], cfg["token"], payload)
if ok:
if buffered:
_log("INFO", f"flushed {len(buffered)} buffered samples")
backoff = 0
sleep_for = cfg["interval_seconds"]
else:
if status == 400:
_log("ERROR", "server rejected payload (400) — dropping sample")
elif status == 401:
_log("ERROR", "token rejected (401) — check config + UI")
buffer.push(sample)
else:
_log("WARN", f"POST failed (status={status}); buffering")
for s in buffered:
buffer.push(s)
buffer.push(sample)
backoff = next_backoff(backoff)
sleep_for = backoff
slept = 0.0
while slept < sleep_for and not _shutdown_requested and not _reload_requested:
time.sleep(min(1.0, sleep_for - slept))
slept += 1.0
_log("INFO", "SIGTERM — flushing and exiting")
final = buffer.drain()
if final:
post_payload(cfg["url"], cfg["token"],
build_payload(final, hostname, metadata))
return 0
if __name__ == "__main__":
conf = os.environ.get("STEWARD_AGENT_CONFIG", "/etc/steward-agent.conf")
sys.exit(main_loop(conf))