fix(system): use PID 1 create_time for uptime/boot time on containers (#1690)

System -> Uptime / Boot Time read psutil.boot_time(), which on shared-kernel
  containers (Docker, LXC, Proxmox containers) is /proc/stat:btime - the host
  kernel's boot time, not the container's. Reporter on Proxmox LXC saw the
  Proxmox node's uptime instead of the Bambuddy container's.

  PID 1 is the container's entrypoint (or the host init on bare metal), and
  its create_time is the POSIX wall-clock timestamp of when it started.
  Switching to psutil.Process(1).create_time() reports the right value on
  containers and matches host boot within a sub-second on bare metal.

  Defensive fallback to psutil.boot_time() on psutil.Error / OSError so the
  endpoint still returns 200 with the best-available answer if /proc/1/stat
  is unreadable (locked-down container, custom seccomp policy).
This commit is contained in:
maziggy
2026-06-09 08:09:02 +02:00
parent 40729da013
commit 58d1b1eb74
3 changed files with 76 additions and 1 deletions
+9 -1
View File
@@ -498,7 +498,15 @@ async def get_system_info(
# System info
memory = psutil.virtual_memory()
boot_time = datetime.fromtimestamp(psutil.boot_time())
# PID 1's create_time is the right uptime anchor in containerised installs
# (Docker, LXC) — psutil.boot_time() reads /proc/stat:btime which on a
# shared-kernel container is the host's boot time, not the container's
# (#1690). On bare metal / VMs PID 1 is the host init, which starts at
# boot, so the value matches psutil.boot_time() within a sub-second.
try:
boot_time = datetime.fromtimestamp(psutil.Process(1).create_time())
except (psutil.Error, OSError):
boot_time = datetime.fromtimestamp(psutil.boot_time())
uptime_seconds = (datetime.now() - boot_time).total_seconds()
# Python and system info
@@ -29,6 +29,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -58,6 +59,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -82,6 +84,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -114,6 +117,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -144,6 +148,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -170,6 +175,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -200,6 +206,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
@@ -257,6 +264,7 @@ class TestSystemAPI:
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
mock_pm._clients = {}
@@ -271,6 +279,63 @@ class TestSystemAPI:
assert db_info["archives_failed"] >= 1
assert db_info["total_print_time_seconds"] >= 5400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_boot_time_uses_pid1_create_time(self, async_client: AsyncClient):
"""#1690: container installs (Docker/LXC) share the host kernel, so
psutil.boot_time() returns the host's boot time instead of the
container's. Reading PID 1's create_time gives the container start
time on containers and matches host boot on bare metal."""
with patch("backend.app.api.routes.system.psutil") as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
# Host boot is FOUR DAYS earlier than the container's PID 1 start.
# The route must report the PID 1 value, not the host value.
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.Process.return_value.create_time.return_value = 1700345600.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
assert response.status_code == 200
result = response.json()
assert result["system"]["boot_time"].startswith("2023-11-18T") # 1700345600 UTC
# PID 1 was queried with pid=1 (not the worker pid).
mock_psutil.Process.assert_called_with(1)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_boot_time_falls_back_to_psutil_boot_time_on_pid1_failure(self, async_client: AsyncClient):
"""If PID 1 is unreadable (rare — locked-down container, /proc not
mounted), fall back to psutil.boot_time() so the endpoint still
returns 200 with the best available answer."""
import psutil as real_psutil
with patch("backend.app.api.routes.system.psutil") as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
# Use the real exception classes so the route's except clause matches.
mock_psutil.Error = real_psutil.Error
mock_psutil.Process.side_effect = real_psutil.NoSuchProcess(1)
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
assert response.status_code == 200
result = response.json()
assert result["system"]["boot_time"].startswith("2023-11-14T") # 1700000000 UTC
class TestSystemHelperFunctions:
"""Tests for system info helper functions."""