Files
FabledSteward/tests/integration/test_docker.py
T
bvandeusen 82c3d2cf36
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 44s
CI / integration (push) Successful in 2m20s
CI / publish (push) Successful in 1m10s
feat(docker): per-container enrichment — health, restarts, exit code, I/O, grouping
First slice of milestone 77 (Docker monitoring depth). Surfaces real per-container
stats beyond basic state, all read-only on the existing push model.

- agent (→1.4.0): collect_docker now inspects each container (health, restart
  count, exit code, OOM) and reads net + block I/O from the stats payload; pulls
  compose project + swarm service/task/node from container labels. Per-container
  inspect+stats calls run over a small bounded ThreadPool so the ~1s-per-stats
  blocking doesn't stretch the sample on a busy host.
- schema (docker_003): additive columns on docker_containers — health, exit_code,
  oom_killed, compose_project, service_name, task_id, node_id, and BigInteger
  net/blk byte counters.
- ingest: persists the enrichment + restart_count (.get keeps older agents working).
- ui: Docker page rows now show health badge, uptime ("up 3d 4h"), restart count,
  exit code (+OOM) for stopped containers, and compose/service grouping label.
- tests: agent helpers (grouping, inspect fields, net/IO sum) + collect_docker
  assembly incl. inspect; integration asserts enrichment round-trips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 20:37:40 -04:00

120 lines
5.0 KiB
Python

"""Integration: per-host Docker schema + host-scoped persistence.
Validates the docker_002 migration (host_id columns + composite key) applied and
that persist_host_docker scopes containers/metrics by host so same-named
containers on different hosts no longer collide. Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timezone
import pytest
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
@_NEEDS_DB
def test_docker_tables_are_host_scoped(app):
from sqlalchemy import text
async def _go():
async with app.db_sessionmaker() as s:
# Both tables carry a host_id column after docker_002.
for tbl in ("docker_containers", "docker_metrics"):
cols = {r[0] for r in (await s.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = :t"), {"t": tbl})).all()}
assert "host_id" in cols, f"{tbl} missing host_id"
# docker_containers is keyed by (host_id, name), not name alone.
pk_cols = {r[0] for r in (await s.execute(text(
"SELECT a.attname FROM pg_index i "
"JOIN pg_attribute a ON a.attrelid = i.indrelid "
" AND a.attnum = ANY(i.indkey) "
"WHERE i.indrelid = 'docker_containers'::regclass AND i.indisprimary"
))).all()}
assert pk_cols == {"host_id", "name"}
asyncio.run(_go())
def _persist_fn(app):
"""Resolve persist_host_docker via the registered capability if the docker
plugin is loaded, else import it directly (the import is safe only when the
plugin is NOT loaded — otherwise its models are already mapped)."""
from steward.core.capabilities import has_capability, get_capability
if has_capability("docker.persist_host_samples"):
return get_capability("docker.persist_host_samples").fn
from plugins.docker.ingest import persist_host_docker
return persist_host_docker
@_NEEDS_DB
def test_persist_scopes_containers_by_host(app):
from sqlalchemy import text
from steward.models.hosts import Host
persist = _persist_fn(app)
now = datetime.now(timezone.utc)
snapshot = [(now, [{
"name": "web", "container_id": "abc123", "image": "nginx",
"status": "running", "cpu_pct": 12.5, "mem_pct": 30.0,
"mem_usage_bytes": 100, "mem_limit_bytes": 200, "ports": [], "started_at": None,
# enrichment (agent ≥ 1.4.0)
"health": "healthy", "restart_count": 2, "exit_code": 0, "oom_killed": False,
"compose_project": "stack", "service_name": "web",
"net_rx_bytes": 1000, "net_tx_bytes": 2000,
"blk_read_bytes": 4096, "blk_write_bytes": 8192,
}])]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(text("DELETE FROM docker_metrics"))
await s.execute(text("DELETE FROM docker_containers"))
await s.execute(text(
"DELETE FROM plugin_metrics WHERE source_module = 'docker'"))
h1 = Host(id=str(uuid.uuid4()), name="alpha", address="10.0.0.1")
h2 = Host(id=str(uuid.uuid4()), name="beta", address="10.0.0.2")
s.add_all([h1, h2])
await s.flush()
# Same container name on two hosts — must not collide now.
await persist(s, h1, snapshot)
await persist(s, h2, snapshot)
web_rows = (await s.execute(text(
"SELECT COUNT(*) FROM docker_containers WHERE name = 'web'"))).scalar()
metric_hosts = (await s.execute(text(
"SELECT COUNT(DISTINCT host_id) FROM docker_metrics "
"WHERE container_name = 'web'"))).scalar()
# Alert pipeline received host-scoped resource names.
resources = {r[0] for r in (await s.execute(text(
"SELECT DISTINCT resource_name FROM plugin_metrics "
"WHERE source_module = 'docker'"))).all()}
# Enrichment columns persisted (check one host's row).
enrich = (await s.execute(text(
"SELECT health, restart_count, service_name, net_rx_bytes "
"FROM docker_containers WHERE name = 'web' LIMIT 1"))).first()
return web_rows, metric_hosts, resources, enrich
web_rows, metric_hosts, resources, enrich = asyncio.run(_go())
assert web_rows == 2 # one 'web' per host, keyed by (host_id, name)
assert metric_hosts == 2 # time-series rows scoped per host
assert resources == {"alpha/web", "beta/web"}
assert enrich == ("healthy", 2, "web", 1000) # enrichment round-trips