feat(docker): Docker's own status reaches Steward — an empty answer clears a host, a lost one greys it, a demoted manager's swarm rows go (#5152)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 53s
CI / unit (push) Failing after 1m17s
CI / publish (push) Skipped

The agent (1.9.0) said nothing when Docker could not be asked and nothing
when Docker had no containers, so Steward could tell neither apart from the
other, and kept a host's last containers on screen as running either way.

- Agent: collect_docker returns (status, detail, containers). "ok" sends
  docker: [] when there are none; "unavailable" (no socket, nothing
  listening) and "error" (permission, timeout, bad answer) say why.
  collect_swarm says whether /info answered, and a node that is not a
  manager sends swarm_manager: false. A failed /info says nothing.
- Ingest: an empty docker list is a snapshot; the newest sample's status
  goes to the Docker plugin with the rest.
- docker_host_status (docker_011): per host, status, detail, since and
  last report. A host that never had Docker gets no row.
- An "ok" empty snapshot reaps the host's containers; an error keeps them.
  swarm_manager: false drops the host's swarm services and nodes.
- Docker page: a "Docker not answering" card names each such host, why,
  and since when; its containers read as stale. Duplicate header comment
  in rows.html removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:41:22 -04:00
co-authored by Claude Opus 5.5
parent f802c9f153
commit 8ceb983183
11 changed files with 510 additions and 65 deletions
+57 -31
View File
@@ -20,7 +20,7 @@ from collections import deque
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
AGENT_VERSION = "1.8.0"
AGENT_VERSION = "1.9.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
@@ -645,19 +645,37 @@ def _collect_one_container(socket_path: str, c: dict) -> dict:
return record
def collect_docker(socket_path: str) -> list:
"""Per-container state from the local Docker socket, or [] if unavailable.
def _docker_failure(socket_path: str, exc: Exception) -> tuple:
"""(status, detail) for a failed Docker call: "unavailable" when there is no
Docker here to ask, "error" when there is one and the call went wrong."""
if isinstance(exc, FileNotFoundError):
return "unavailable", "no Docker socket at " + socket_path
if isinstance(exc, ConnectionRefusedError):
return "unavailable", "Docker is not accepting connections on " + socket_path
if isinstance(exc, PermissionError):
return "error", "permission denied on " + socket_path
if isinstance(exc, socket.timeout):
return "error", "Docker did not answer within %.0f s" % DOCKER_API_TIMEOUT
return "error", (str(exc) or type(exc).__name__)[:200]
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.
def collect_docker(socket_path: str) -> tuple:
"""(status, detail, containers) from the local Docker socket.
status is "ok" when Docker answered (containers may then be [] on a host
with none, which tells Steward to clear its list), "unavailable" when there
is no Docker here (no socket, or nothing listening), and "error" when Docker
is here but the listing failed. detail says why, for the last two. A
Docker-less host still needs no configuration: it reports "unavailable".
"""
try:
containers = _docker_request(socket_path, "/containers/json?all=true")
except (OSError, ValueError):
return []
if not isinstance(containers, list) or not containers:
return []
except (OSError, ValueError) as exc:
return _docker_failure(socket_path, exc) + ([],)
if not isinstance(containers, list):
return "error", "unexpected answer from /containers/json", []
if not containers:
return "ok", None, []
# 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
@@ -665,7 +683,7 @@ def collect_docker(socket_path: str) -> list:
# 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))
return "ok", None, list(ex.map(lambda c: _collect_one_container(socket_path, c), containers))
# ─── container logs (m79) ─────────────────────────────────────────────────────
@@ -978,26 +996,28 @@ def _swarm_nodes(nodes: list) -> list:
return out
def collect_swarm(socket_path: str):
"""Swarm topology from a manager node, or None on workers / non-swarm hosts.
def collect_swarm(socket_path: str) -> tuple:
"""(is_manager, topology) from the local Docker socket.
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.
manager-only endpoints. is_manager is None when /info itself failed, so a
transient error is never mistaken for a node that stopped being a manager;
False tells Steward to drop the swarm rows this host reported as one.
topology is None unless this is a manager and every call answered.
"""
try:
info = _docker_request(socket_path, "/info")
except (OSError, ValueError):
return None
return None, None
if not _swarm_is_manager(info):
return None
return False, 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 {
return True, None
return True, {
"services": _swarm_services(
services if isinstance(services, list) else [],
tasks if isinstance(tasks, list) else []),
@@ -1161,26 +1181,32 @@ def build_sample(mounts: list[str], state: dict,
]
state["net"], state["disk"], state["mono"] = net_raw, disk_raw, now_mono
# Docker's status rides every sample, so Steward can tell "no containers"
# (status ok, docker: []) from "couldn't ask" (unavailable / error).
try:
docker = collect_docker(docker_socket)
except Exception:
docker = []
if docker:
docker_status, docker_detail, docker = collect_docker(docker_socket)
except Exception as exc:
docker_status, docker_detail, docker = "error", (str(exc) or type(exc).__name__)[:200], []
sample["docker_status"] = docker_status
if docker_detail:
sample["docker_detail"] = docker_detail
if docker_status == "ok":
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.
# Swarm topology is only emitted by managers. A node whose /info says it
# is not a manager says so, so Steward can expire what it once reported.
try:
swarm = collect_swarm(docker_socket)
is_manager, swarm = collect_swarm(docker_socket)
except Exception:
swarm = None
is_manager, swarm = None, None
if swarm is not None:
sample["swarm"] = swarm
elif is_manager is False:
sample["swarm_manager"] = False
# 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:
# Image/disk usage — host-level docker fact (one /system/df call), only
# when Docker answered.
if docker_status == "ok":
try:
disk = collect_disk_usage(docker_socket)
except Exception:
+12 -3
View File
@@ -214,15 +214,24 @@ async def ingest():
latest_swarm_ts: datetime | None = None
latest_disk: dict | None = None
latest_disk_ts: datetime | None = None
# Whether Docker answered, from the newest sample that says (agent ≥ 1.9.0).
docker_status: dict | None = None
for sample in samples:
try:
recorded_at = _parse_ts(sample["ts"])
except (KeyError, ValueError):
continue
metric_rows.extend(_expand_sample_to_metrics(sample, host.name, recorded_at))
# An empty list is a real answer: Docker is up with no containers.
docker = sample.get("docker")
if isinstance(docker, list) and docker:
if isinstance(docker, list):
docker_snapshots.append((recorded_at, docker))
status = sample.get("docker_status")
if status in ("ok", "unavailable", "error") and (
docker_status is None or recorded_at > docker_status["at"]):
docker_status = {
"status": status, "detail": sample.get("docker_detail"),
"at": recorded_at, "swarm_manager": sample.get("swarm_manager")}
# Container logs are time-series (append every line, not newest-only);
# each record carries its own Docker ts, recorded_at is the fallback.
docker_logs = sample.get("docker_logs")
@@ -257,7 +266,7 @@ async def ingest():
# (opportunistic synergy via the capability registry — no hard import,
# no-op when docker is disabled). A failure here must never sink the
# whole ingest, so the metrics above still land.
if (docker_snapshots or latest_swarm is not None
if (docker_snapshots or latest_swarm is not None or docker_status is not None
or latest_disk is not None or docker_log_batches):
from steward.core.capabilities import has_capability, invoke_capability
if has_capability("docker.persist_host_samples"):
@@ -268,7 +277,7 @@ async def ingest():
await invoke_capability(
"docker.persist_host_samples", UserRole.admin,
session, host, docker_snapshots, latest_swarm,
latest_disk, docker_log_batches,
latest_disk, docker_log_batches, docker_status,
)
except Exception:
current_app.logger.exception(