feat(docker): agent manager-only swarm collector (AGENT_VERSION 1.5.0)
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:
@@ -192,20 +192,135 @@ def test_collect_docker_skips_stats_for_stopped(monkeypatch):
|
||||
assert out[0]["health"] is None
|
||||
|
||||
|
||||
# ── swarm (manager-only) ──────────────────────────────────────────────────────
|
||||
|
||||
def test_swarm_is_manager():
|
||||
assert a._swarm_is_manager({"Swarm": {"ControlAvailable": True}}) is True
|
||||
# Worker: in a swarm but no control plane.
|
||||
assert a._swarm_is_manager({"Swarm": {"ControlAvailable": False}}) is False
|
||||
# Non-swarm daemon: no Swarm block at all.
|
||||
assert a._swarm_is_manager({}) is False
|
||||
|
||||
|
||||
def test_swarm_services_rolls_up_replicas():
|
||||
services = [
|
||||
{"ID": "svc1", "Spec": {
|
||||
"Name": "web", "Mode": {"Replicated": {"Replicas": 3}},
|
||||
"TaskTemplate": {"ContainerSpec": {"Image": "nginx:1@sha256:deadbeef"}}}},
|
||||
{"ID": "svc2", "Spec": {
|
||||
"Name": "agent", "Mode": {"Global": {}},
|
||||
"TaskTemplate": {"ContainerSpec": {"Image": "agent:latest"}}}},
|
||||
]
|
||||
tasks = [
|
||||
# web: 2 of 3 desired replicas actually running, both on node n1
|
||||
{"ServiceID": "svc1", "NodeID": "n1", "DesiredState": "running",
|
||||
"Status": {"State": "running"}},
|
||||
{"ServiceID": "svc1", "NodeID": "n1", "DesiredState": "running",
|
||||
"Status": {"State": "running"}},
|
||||
{"ServiceID": "svc1", "NodeID": "n2", "DesiredState": "running",
|
||||
"Status": {"State": "failed"}},
|
||||
# agent: global, 2 tasks desired+running across 2 nodes
|
||||
{"ServiceID": "svc2", "NodeID": "n1", "DesiredState": "running",
|
||||
"Status": {"State": "running"}},
|
||||
{"ServiceID": "svc2", "NodeID": "n2", "DesiredState": "running",
|
||||
"Status": {"State": "running"}},
|
||||
]
|
||||
out = {s["service_name"]: s for s in a._swarm_services(services, tasks)}
|
||||
assert out["web"] == {"service_name": "web", "mode": "replicated",
|
||||
"desired": 3, "running": 2, "image": "nginx:1",
|
||||
"placement": [{"node_id": "n1", "running": 2}]}
|
||||
# Global mode has no fixed replica count → desired derived from desired tasks.
|
||||
assert out["agent"] == {"service_name": "agent", "mode": "global",
|
||||
"desired": 2, "running": 2, "image": "agent:latest",
|
||||
"placement": [{"node_id": "n1", "running": 1},
|
||||
{"node_id": "n2", "running": 1}]}
|
||||
|
||||
|
||||
def test_swarm_nodes_normalises_fields():
|
||||
nodes = [
|
||||
{"ID": "n1", "Description": {"Hostname": "mgr"},
|
||||
"Spec": {"Role": "manager", "Availability": "active"},
|
||||
"Status": {"State": "ready"}, "ManagerStatus": {"Leader": True}},
|
||||
{"ID": "n2", "Description": {"Hostname": "wrk"},
|
||||
"Spec": {"Role": "worker", "Availability": "drain"},
|
||||
"Status": {"State": "down"}},
|
||||
]
|
||||
out = {n["node_id"]: n for n in a._swarm_nodes(nodes)}
|
||||
assert out["n1"] == {"node_id": "n1", "hostname": "mgr", "role": "manager",
|
||||
"availability": "active", "status": "ready", "leader": True}
|
||||
assert out["n2"] == {"node_id": "n2", "hostname": "wrk", "role": "worker",
|
||||
"availability": "drain", "status": "down", "leader": False}
|
||||
|
||||
|
||||
def test_collect_swarm_skips_on_worker(monkeypatch):
|
||||
# A worker reports ControlAvailable=False → None, and never hits /services.
|
||||
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||
if path == "/info":
|
||||
return {"Swarm": {"ControlAvailable": False}}
|
||||
raise AssertionError("manager-only endpoint queried on a worker")
|
||||
|
||||
monkeypatch.setattr(a, "_docker_request", fake_request)
|
||||
assert a.collect_swarm("/var/run/docker.sock") is None
|
||||
|
||||
|
||||
def test_collect_swarm_assembles_on_manager(monkeypatch):
|
||||
services = [{"ID": "svc1", "Spec": {
|
||||
"Name": "web", "Mode": {"Replicated": {"Replicas": 1}},
|
||||
"TaskTemplate": {"ContainerSpec": {"Image": "nginx"}}}}]
|
||||
tasks = [{"ServiceID": "svc1", "DesiredState": "running",
|
||||
"Status": {"State": "running"}}]
|
||||
nodes = [{"ID": "n1", "Description": {"Hostname": "mgr"},
|
||||
"Spec": {"Role": "manager", "Availability": "active"},
|
||||
"Status": {"State": "ready"}, "ManagerStatus": {"Leader": True}}]
|
||||
|
||||
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||
return {
|
||||
"/info": {"Swarm": {"ControlAvailable": True}},
|
||||
"/services": services, "/tasks": tasks, "/nodes": nodes,
|
||||
}[path]
|
||||
|
||||
monkeypatch.setattr(a, "_docker_request", fake_request)
|
||||
out = a.collect_swarm("/var/run/docker.sock")
|
||||
assert out["services"][0]["service_name"] == "web"
|
||||
assert out["services"][0]["running"] == 1 and out["services"][0]["desired"] == 1
|
||||
assert out["nodes"][0]["hostname"] == "mgr" and out["nodes"][0]["leader"] is True
|
||||
|
||||
|
||||
def test_collect_swarm_silent_skip_when_no_socket():
|
||||
assert a.collect_swarm("/nonexistent/does-not-exist.sock") is None
|
||||
|
||||
|
||||
# ── build_sample wiring ───────────────────────────────────────────────────────
|
||||
|
||||
def test_build_sample_includes_docker_when_present(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [{"name": "web", "status": "running"}])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert sample["docker"] == [{"name": "web", "status": "running"}]
|
||||
|
||||
|
||||
def test_build_sample_omits_docker_when_empty(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert "docker" not in sample
|
||||
|
||||
|
||||
def test_build_sample_includes_swarm_on_manager(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||
monkeypatch.setattr(a, "collect_swarm",
|
||||
lambda _s: {"services": [], "nodes": [{"node_id": "n1"}]})
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert sample["swarm"]["nodes"] == [{"node_id": "n1"}]
|
||||
|
||||
|
||||
def test_build_sample_omits_swarm_off_manager(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert "swarm" not in sample
|
||||
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_read_config_defaults_docker_socket(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user