diff --git a/services/stability-agent/src/stability_agent.py b/services/stability-agent/src/stability_agent.py index 18f09f8..b9f0faf 100644 --- a/services/stability-agent/src/stability_agent.py +++ b/services/stability-agent/src/stability_agent.py @@ -119,6 +119,29 @@ class DockerClient: def get_containers(self): 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(): client = DockerClient() if not os.path.exists(client.socket_path): @@ -138,19 +161,28 @@ def check_docker(): container_info = { "name": name, + "service": container_service_name(c), "state": state, "status": status } 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) - if unhealthy_containers: - names = [c["name"] for c in unhealthy_containers] - # Only emit warning for containers that should be running? - # For now, we report any non-running container found by Docker. - emit_event("containers_not_running", "warning", f"Some containers are not running: {', '.join(names)}", details={"containers": unhealthy_containers}) + # One event per container, tagged with its compose service name. The + # observer keys service state and incidents on event["service"] and skips + # events without it — the old aggregate event (service=None, all names in + # one message) never opened an incident (recon D15). + 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} diff --git a/services/stability-agent/tests/test_container_service_name.py b/services/stability-agent/tests/test_container_service_name.py new file mode 100644 index 0000000..7876f5f --- /dev/null +++ b/services/stability-agent/tests/test_container_service_name.py @@ -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>_"; + # 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"