feat(docker): uptime from StartedAt, a restart is an event, host totals count physical devices only (#5153)
- Agent: started_at comes from inspect's State.StartedAt (nanoseconds
trimmed; Docker's zero time is "never started"), not the list's Created,
so a restarted container's uptime counts from its restart.
- A rising restart_count between two snapshots emits a "restart" event
("restart #3", or "3 restarts, now #5"), said instead of a start; a
restart then a death gives both. A count that falls (recreated under the
same name) says nothing. The timeline draws it as ↻.
- Network and disk I/O count what the kernel calls physical: interfaces
not under /sys/devices/virtual/net (bridges, docker0, veth, vxlan, tun),
block devices with a device behind them (not dm, md, zram), so a byte is
not counted twice. Where /sys calls everything virtual (an agent in LXC)
the name-filtered set is kept rather than reporting no traffic.
- Agent tests updated for #5152's (status, detail, containers) and
(is_manager, topology) shapes; the no-Docker disk test now states the
case it was passing for by accident.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,13 +148,14 @@ def test_collect_docker_assembles_container(monkeypatch):
|
||||
"Names": ["/web"],
|
||||
"Image": "nginx:latest",
|
||||
"State": "running",
|
||||
"Created": 1_700_000_000,
|
||||
"Created": 1_600_000_000,
|
||||
"Ports": [{"PublicPort": 8080, "PrivatePort": 80, "Type": "tcp"}],
|
||||
"Labels": {"com.docker.compose.project": "myproj",
|
||||
"com.docker.swarm.service.name": "web"},
|
||||
}
|
||||
inspect = {"RestartCount": 2,
|
||||
"State": {"Health": {"Status": "healthy"}, "ExitCode": 0, "OOMKilled": False}}
|
||||
"State": {"Health": {"Status": "healthy"}, "ExitCode": 0, "OOMKilled": False,
|
||||
"StartedAt": "2023-11-14T22:13:20.123456789Z"}}
|
||||
stats = {
|
||||
"cpu_stats": {"cpu_usage": {"total_usage": 200_000_000},
|
||||
"system_cpu_usage": 2_000_000_000, "online_cpus": 4},
|
||||
@@ -188,7 +189,8 @@ def test_collect_docker_assembles_container(monkeypatch):
|
||||
assert c["cpu_pct"] == 40.0
|
||||
assert c["mem_pct"] == 15.0
|
||||
assert c["ports"] == [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
|
||||
assert c["started_at"].startswith("2023-11-14T") # epoch → ISO-8601
|
||||
# When it last started (inspect), not when it was created (2020).
|
||||
assert c["started_at"] == "2023-11-14T22:13:20.123456+00:00"
|
||||
# enrichment
|
||||
assert c["health"] == "healthy" and c["restart_count"] == 2
|
||||
assert c["exit_code"] == 0 and c["oom_killed"] is False
|
||||
@@ -418,8 +420,9 @@ def test_build_sample_says_when_a_node_is_not_a_manager(monkeypatch, swarm, flag
|
||||
|
||||
|
||||
def test_build_sample_omits_swarm_off_manager(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, []))
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: (False, None))
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert "swarm" not in sample
|
||||
|
||||
@@ -470,18 +473,19 @@ def test_collect_disk_usage_silent_skip_when_no_socket():
|
||||
|
||||
|
||||
def test_build_sample_includes_disk_when_docker_present(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [{"name": "web", "status": "running"}])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: ("ok", None, [{"name": "web", "status": "running"}]))
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: {"images_reclaimable": 700})
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert sample["docker_disk"] == {"images_reclaimable": 700}
|
||||
|
||||
|
||||
def test_build_sample_omits_disk_when_no_docker(monkeypatch):
|
||||
# No containers → the /system/df call is skipped entirely (cost guard). The
|
||||
# No Docker → the /system/df call is skipped entirely (cost guard). The
|
||||
# sentinel would land under docker_disk if the gate ever called it.
|
||||
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
monkeypatch.setattr(a, "collect_docker",
|
||||
lambda _s: ("unavailable", "no Docker socket at /s", []))
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: {"sentinel": True})
|
||||
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||
assert "docker_disk" not in sample
|
||||
@@ -632,8 +636,8 @@ def test_collect_docker_logs_forgets_gone_container_cursors(monkeypatch):
|
||||
|
||||
def test_build_sample_includes_logs_when_present(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker",
|
||||
lambda _s: [{"name": "web", "status": "running"}])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
lambda _s: ("ok", None, [{"name": "web", "status": "running"}]))
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||
monkeypatch.setattr(
|
||||
a, "collect_docker_logs",
|
||||
@@ -645,8 +649,8 @@ def test_build_sample_includes_logs_when_present(monkeypatch):
|
||||
|
||||
def test_build_sample_omits_logs_when_disabled(monkeypatch):
|
||||
monkeypatch.setattr(a, "collect_docker",
|
||||
lambda _s: [{"name": "web", "status": "running"}])
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||
lambda _s: ("ok", None, [{"name": "web", "status": "running"}]))
|
||||
monkeypatch.setattr(a, "collect_swarm", lambda _s: (None, None))
|
||||
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||
called = {"logs": False}
|
||||
|
||||
@@ -690,3 +694,15 @@ def test_read_config_parses_docker_log_exclude(tmp_path):
|
||||
p.write_text("url = x\ntoken = y\ndocker_log_exclude = watchtower, foo\n")
|
||||
cfg = a.read_config(str(p))
|
||||
assert cfg["docker_log_exclude"] == ["watchtower", "foo"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("insp, started", [
|
||||
({"State": {"StartedAt": "2026-10-06T12:00:01.5Z"}}, "2026-10-06T12:00:01.500000+00:00"),
|
||||
({"State": {"StartedAt": "2026-10-06T12:00:01Z"}}, "2026-10-06T12:00:01+00:00"),
|
||||
({"State": {"StartedAt": "0001-01-01T00:00:00Z"}}, None), # never started
|
||||
({"State": {}}, None),
|
||||
(None, None), # inspect failed
|
||||
({"State": {"StartedAt": "yesterday"}}, None),
|
||||
])
|
||||
def test_docker_started_at(insp, started):
|
||||
assert a._docker_started_at(insp) == started
|
||||
|
||||
Reference in New Issue
Block a user