feat(docker): agent manager-only swarm collector (AGENT_VERSION 1.5.0)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 44s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m6s

Adds collect_swarm(socket_path) to the host agent. Self-detects a Swarm
manager via /info (Swarm.ControlAvailable) — workers and non-swarm daemons
return None and never touch the manager-only endpoints (one cheap /info call,
no 503s). On a manager it queries /services, /tasks, /nodes and emits
sample["swarm"] = {services, nodes}:

  * services roll desired-vs-running replicas up from the task list (replica
    health isn't on the service object), handle replicated + global mode, strip
    the @sha256 image digest, and carry cross-node task→node placement.
  * nodes normalise role / availability / status + the manager leader flag.

build_sample omits the swarm key entirely off managers, same silent contract
as collect_docker. Unit tests cover manager detection, replica roll-up +
placement (replicated & global), node normalisation, worker silent-skip, and
build_sample wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-18 20:57:18 -04:00
parent fee654b53a
commit 448258c5b4
2 changed files with 236 additions and 1 deletions
+121 -1
View File
@@ -20,7 +20,7 @@ from collections import deque
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
AGENT_VERSION = "1.4.0"
AGENT_VERSION = "1.5.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
@@ -650,6 +650,117 @@ def collect_docker(socket_path: str) -> list:
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 []),
}
# ─── ring buffer ─────────────────────────────────────────────────────────────
@@ -741,6 +852,15 @@ def build_sample(mounts: list[str], state: dict,
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
return sample