"""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, }])] 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()} return web_rows, metric_hosts, resources web_rows, metric_hosts, resources = 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"}