feat(traffic): a host's top senders, on its page and as get_host_senders (#6115)
The host page gains a "Who's sending" panel, and the MCP a tool, that put the host's outbound (or inbound) traffic down to its containers and NFS mounts: read in shared time buckets, so the host's busiest bucket says who was sending in it. A swarm service's tasks add up under its name; what none of them accounts for is the host itself. Also fixes CI 9488: per-container net_rx_bps / net_tx_bps join the docker alert catalog, so a rule can catch a container flooding the uplink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Integration (#6115): a host's traffic put down to its containers and NFS
|
||||
mounts, read from plugin_metrics in shared time buckets on Postgres."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def test_the_host_s_peak_is_put_down_to_its_senders(app):
|
||||
from steward.models.metrics import PluginMetric
|
||||
from plugins.host_agent.senders import senders
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
host = "senders-host-" + uuid.uuid4().hex[:8]
|
||||
peak, later = now - timedelta(minutes=30), now - timedelta(minutes=10)
|
||||
task = "a" * 25
|
||||
|
||||
def m(source, resource, metric, value, at):
|
||||
return PluginMetric(source_module=source, resource_name=resource, metric_name=metric,
|
||||
value=value, recorded_at=at)
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add_all([
|
||||
m("host_agent", host, "net_tx_bps", 1000.0, peak),
|
||||
m("host_agent", host, "net_tx_bps", 100.0, later),
|
||||
# one service's two tasks add up; another host's don't count
|
||||
m("docker", f"{host}/web.1.{task}", "net_tx_bps", 600.0, peak),
|
||||
m("docker", f"{host}/web.2.{task}", "net_tx_bps", 200.0, peak),
|
||||
m("docker", f"{host}/web.1.{task}", "net_tx_bps", 50.0, later),
|
||||
m("docker", f"{host}x/web.1.{task}", "net_tx_bps", 9999.0, peak),
|
||||
m("host_agent", f"{host}:/nfs/data", "nfs_tx_bps", 100.0, peak),
|
||||
m("host_agent", f"{host}:net:eno1", "net_tx_bps", 1000.0, peak),
|
||||
])
|
||||
async with app.db_sessionmaker() as s:
|
||||
return await senders(s, host, now - timedelta(hours=1))
|
||||
|
||||
got = asyncio.run(_go())
|
||||
assert got["host"]["peak_bps"] == 1000.0 and got["bucket_s"] == 60
|
||||
assert [(r["kind"], r["name"], r["at_host_peak_bps"]) for r in got["senders"]] == [
|
||||
("container", "web", 800.0), ("nfs", "/nfs/data", 100.0)]
|
||||
assert got["unaccounted_at_peak_bps"] == 100.0
|
||||
@@ -19,10 +19,12 @@ def test_plugin_exposes_the_mcp_hooks():
|
||||
reg = MagicMock()
|
||||
agent_plugin.register_mcp_tools(reg)
|
||||
assert [c.args[0].__name__ for c in reg.call_args_list] == [
|
||||
"list_agents", "list_maintenance_plans", "get_maintenance_job", "run_maintenance"]
|
||||
"list_agents", "list_maintenance_plans", "get_maintenance_job", "get_host_senders",
|
||||
"run_maintenance"]
|
||||
roles = {c.args[0].__name__: c.kwargs["role"] for c in reg.call_args_list}
|
||||
assert roles["run_maintenance"].value == "admin" # as Update now on the page
|
||||
assert roles["get_maintenance_job"].value == "viewer"
|
||||
assert roles["get_host_senders"].value == "viewer"
|
||||
assert callable(agent_plugin.mcp_overview)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""senders.rank: a host's traffic put down to its containers and NFS mounts
|
||||
(#6115). Pure; no DB."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from plugins.host_agent.senders import rank, sender_name
|
||||
|
||||
T = [datetime(2026, 10, 19, 18, 0, tzinfo=timezone.utc) + timedelta(minutes=i) for i in range(4)]
|
||||
|
||||
|
||||
def test_a_swarm_task_is_its_service_and_anything_else_its_own_name():
|
||||
assert sender_name("traefik_router.inlbfs5bk5want3q2pw95x9jw.dja1psmx7k2d59o45e1tqyku6") == "traefik_router"
|
||||
assert sender_name("steward_app.1.ww9m1povzpkx0dcei3p4zh8dm") == "steward_app"
|
||||
assert sender_name("ak-outpost-fabled_ldap") == "ak-outpost-fabled_ldap"
|
||||
|
||||
|
||||
def test_senders_are_ranked_by_what_they_sent_in_the_host_s_peak():
|
||||
host = {T[0]: 100.0, T[1]: 1000.0, T[2]: 100.0, T[3]: 200.0}
|
||||
got = rank(host, {
|
||||
("container", "web"): {T[0]: 80.0, T[1]: 100.0, T[2]: 80.0, T[3]: 80.0},
|
||||
("container", "backup"): {T[1]: 700.0},
|
||||
("nfs", "/nfs/data"): {T[1]: 50.0, T[3]: 100.0},
|
||||
})
|
||||
assert got["host"] == {"avg_bps": 350.0, "peak_bps": 1000.0, "peak_at": T[1]}
|
||||
assert [r["name"] for r in got["senders"]] == ["backup", "web", "/nfs/data"]
|
||||
backup = got["senders"][0]
|
||||
assert backup == {"kind": "container", "name": "backup", "avg_bps": 175.0, "peak_bps": 700.0,
|
||||
"peak_at": T[1], "at_host_peak_bps": 700.0}
|
||||
assert got["unaccounted_at_peak_bps"] == 150.0 # 1000 - 700 - 100 - 50
|
||||
assert got["unaccounted_avg_bps"] == 350.0 - 175.0 - 85.0 - 37.5
|
||||
|
||||
|
||||
def test_senders_beyond_the_host_leave_nothing_unaccounted_and_the_list_is_cut():
|
||||
host = {T[0]: 10.0}
|
||||
got = rank(host, {("container", f"c{i}"): {T[0]: 20.0} for i in range(3)}, limit=2)
|
||||
assert len(got["senders"]) == 2 and got["more"] == 1
|
||||
assert got["unaccounted_at_peak_bps"] == 0.0 and got["unaccounted_avg_bps"] == 0.0
|
||||
|
||||
|
||||
def test_no_host_samples_is_no_answer():
|
||||
assert rank({}, {("container", "web"): {T[0]: 1.0}})["host"] is None
|
||||
Reference in New Issue
Block a user