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
+2
View File
@@ -13,6 +13,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning).
### Fixed
- **System page now reports the container's uptime / boot time, not the host's (#1690, reported by @IndividualGhost1905)** — Reporter on Proxmox LXC observed that System → Uptime / Boot Time matched the Proxmox host's values, not the container's. **Root cause:** `psutil.boot_time()` reads `/proc/stat:btime`, which on shared-kernel containers (Docker, LXC) is the host kernel's boot time — leaking the host's lifecycle into Bambuddy's UI. **Fix:** read PID 1's create_time instead — `psutil.Process(1).create_time()` returns the POSIX timestamp of the init/entrypoint process, which in a container is the container's start time, and on bare metal / VMs is the host init (effectively identical to `psutil.boot_time()` within a sub-second). Defensive `psutil.Error` / `OSError` fallback to the old `psutil.boot_time()` for the rare case where /proc/1/stat is unreadable (locked-down container, custom seccomp policy). No frontend / i18n change — the field shape is unchanged, only the value is now correct on container installs. **Tests:** 2 new integration cases — one pins that the route reads `Process(1).create_time` and that the response uses that timestamp (not `boot_time`), the other pins the fallback path via a real `psutil.NoSuchProcess(1)` so the endpoint still returns 200 with the best-available answer. All 8 pre-existing system-info tests updated to also mock the new code path; full system API suite 20/20 green; ruff clean.
- **Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by @Bgabor997)** — Creating or editing a filament preset on the Profiles page (BL Cloud, Orca Cloud, and Local Profiles all open the same shared editor) only offered 11 base materials (PLA, ABS, PETG, TPU, PA, PA-CF, PET-CF, PC, ASA, PVA, HIPS). Reporter on P1S wanted to tag a custom preset as PLA-CF — the dropdown source had no entry, so the saved preset's `filament_type` was wrong and the printer received the wrong material code at dispatch. **Root cause:** `backend/app/data/filament_fields.json` (served by `GET /cloud/fields/filament` and consumed by `ProfilesPage` via `getCloudFields`) shipped a curated subset that pre-dated Bambu's CF/GF lineup expansion. Other surfaces in the codebase already named the canonical list (`utils/filament_ids.py` `GENERIC_FILAMENT_IDS`, `spool-form/utils.ts` MATERIALS, the Bambu filament-id catalog in `cloud.py`), so the gap was specifically in the editor's allowed-values JSON. **Fix:** expanded the `filament_type` select to 25 BambuStudio-aligned options grouped by family — PLA (+ CF/GF/AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS. No frontend, no i18n (material codes are universal). K-profiles editor unaffected — it picks `filament_id`, not `filament_type`. **Tests:** 15 unit cases in `test_filament_fields_options.py` pin every newly-added variant (PLA-CF, PLA-GF, PLA-AERO, PETG-CF, ABS-GF, ASA-CF, ASA-GF, PCTG, PAHT-CF, PA6-CF, PA6-GF, PPS, PPS-CF, PPS-GF) plus the baseline-must-still-be-present guard so a future curation pass can't silently drop them.
- **Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by @Geoff-S)** — `bambuddy.service` shipped with `ProtectHome=true`, which makes `/home/*` invisible to the service namespace. When the user installed into `/home/bambuddy/` (instead of the default `/opt/bambuddy/`), the `ExecStart=/home/bambuddy/venv/bin/uvicorn` path couldn't be resolved at exec time and the unit failed with `status=203/EXEC: Unable to locate executable`. The `ReadWritePaths=$INSTALL_PATH` directive doesn't reliably re-expose `/home/*` subpaths for executable resolution. **Fix:** `install/install.sh` now detects `INSTALL_PATH == /home/*` and emits `ProtectHome=read-only` for that case; the default `/opt/bambuddy/` install keeps the stricter `ProtectHome=true`. The manual `deploy/bambuddy.service` template defaults to `ProtectHome=read-only` with a comment explaining when to tighten it to `true`. `read-only` keeps `/home` immutable to the service (no security regression — the service can read its venv but not write anywhere outside the `ReadWritePaths` allowlist).
+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."""