fix(stability-agent): tag containers_not_running events with compose service
The aggregate containers_not_running event carried service=None, which the observer skips when building service state and incidents — stability-agent's flagship signal never opened an incident (recon D15). Emit one event per non-running container instead, tagged with the compose service name from the com.docker.compose.service label (same pattern as node-agent's _canonical_container_name fix from May), falling back to the container name with Docker's stale-state hash prefix stripped; never crashes on unlabeled containers. 'created' compose tracking artifacts are skipped — they are not running services and would open fake incidents now that the event is actionable. Adds the service's first test suite covering the label-extraction helper. Smoke-run performed with runtime paths redirected (no docker build, authoring only): main loop runs, service names resolve on live solaria containers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ddae57c842
commit
f92e161ec6
|
|
@ -119,6 +119,29 @@ class DockerClient:
|
||||||
def get_containers(self):
|
def get_containers(self):
|
||||||
return self._request("/containers/json?all=1")
|
return self._request("/containers/json?all=1")
|
||||||
|
|
||||||
|
def container_service_name(container):
|
||||||
|
"""Return the compose service name for a /containers/json entry.
|
||||||
|
|
||||||
|
Priority (same pattern as node-agent's _canonical_container_name):
|
||||||
|
1. com.docker.compose.service label — the clean compose-file key, immune
|
||||||
|
to the "<12-hex>_" prefix Docker uses for stale project-state entries.
|
||||||
|
2. Container name with that stale-state prefix stripped — fallback for
|
||||||
|
non-Compose containers.
|
||||||
|
Never raises: missing/None Labels or Names degrade to "unknown".
|
||||||
|
"""
|
||||||
|
labels = container.get("Labels") or {}
|
||||||
|
if isinstance(labels, dict):
|
||||||
|
compose_svc = (labels.get("com.docker.compose.service") or "").strip()
|
||||||
|
if compose_svc:
|
||||||
|
return compose_svc
|
||||||
|
names = container.get("Names") or []
|
||||||
|
name = names[0].lstrip("/") if names else ""
|
||||||
|
if (len(name) > 13
|
||||||
|
and name[12] == "_"
|
||||||
|
and all(ch in "0123456789abcdef" for ch in name[:12])):
|
||||||
|
name = name[13:]
|
||||||
|
return name or "unknown"
|
||||||
|
|
||||||
def check_docker():
|
def check_docker():
|
||||||
client = DockerClient()
|
client = DockerClient()
|
||||||
if not os.path.exists(client.socket_path):
|
if not os.path.exists(client.socket_path):
|
||||||
|
|
@ -138,19 +161,28 @@ def check_docker():
|
||||||
|
|
||||||
container_info = {
|
container_info = {
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"service": container_service_name(c),
|
||||||
"state": state,
|
"state": state,
|
||||||
"status": status
|
"status": status
|
||||||
}
|
}
|
||||||
summary.append(container_info)
|
summary.append(container_info)
|
||||||
|
|
||||||
if state != "running":
|
# "created" containers are Docker Compose internal tracking artifacts
|
||||||
|
# (never started) — not a running service, nothing to remediate.
|
||||||
|
if state != "running" and state != "created":
|
||||||
unhealthy_containers.append(container_info)
|
unhealthy_containers.append(container_info)
|
||||||
|
|
||||||
if unhealthy_containers:
|
# One event per container, tagged with its compose service name. The
|
||||||
names = [c["name"] for c in unhealthy_containers]
|
# observer keys service state and incidents on event["service"] and skips
|
||||||
# Only emit warning for containers that should be running?
|
# events without it — the old aggregate event (service=None, all names in
|
||||||
# For now, we report any non-running container found by Docker.
|
# one message) never opened an incident (recon D15).
|
||||||
emit_event("containers_not_running", "warning", f"Some containers are not running: {', '.join(names)}", details={"containers": unhealthy_containers})
|
for info in unhealthy_containers:
|
||||||
|
emit_event(
|
||||||
|
"containers_not_running", "warning",
|
||||||
|
f"Container '{info['name']}' is not running (state={info['state']})",
|
||||||
|
service=info["service"],
|
||||||
|
details={"container": info},
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "ok", "containers": summary}
|
return {"status": "ok", "containers": summary}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Tests for container_service_name — the compose-label extraction that fixes
|
||||||
|
the service=None containers_not_running events (recon D15: observer skips
|
||||||
|
events without a service, so stability-agent's flagship signal never opened
|
||||||
|
an incident). Input shape is a /containers/json (Docker list API) entry.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from stability_agent import container_service_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_label_wins_over_container_name():
|
||||||
|
c = {
|
||||||
|
"Names": ["/kb-postgres-db-1"],
|
||||||
|
"Labels": {"com.docker.compose.service": "kb-postgres"},
|
||||||
|
}
|
||||||
|
assert container_service_name(c) == "kb-postgres"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlabeled_falls_back_to_container_name():
|
||||||
|
assert container_service_name({"Names": ["/paperless"], "Labels": {}}) == "paperless"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_labels_key_does_not_crash():
|
||||||
|
assert container_service_name({"Names": ["/mosquitto"]}) == "mosquitto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_labels_does_not_crash():
|
||||||
|
assert container_service_name({"Names": ["/mosquitto"], "Labels": None}) == "mosquitto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_label_falls_back_to_name():
|
||||||
|
c = {"Names": ["/zigbee2mqtt"], "Labels": {"com.docker.compose.service": " "}}
|
||||||
|
assert container_service_name(c) == "zigbee2mqtt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_state_hash_prefix_stripped():
|
||||||
|
# Docker stores stale project-state records as "<12-hex>_<original-name>";
|
||||||
|
# same ghost-key corruption node-agent's _canonical_container_name handles.
|
||||||
|
c = {"Names": ["/9e36297651e7_control-plane-observer"], "Labels": {}}
|
||||||
|
assert container_service_name(c) == "control-plane-observer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_container_dict_degrades_to_unknown():
|
||||||
|
assert container_service_name({}) == "unknown"
|
||||||
Loading…
Reference in a new issue