mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
feat(system): NTP-gate state on /api/v1/system/appliance
Extends the appliance endpoint that landed in the previous commit with a
time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
marker after the 3-minute timeout). The RPi 5 has no battery-backed RTC,
so on a fresh boot the system clock is wrong until NTP catches up -- JWT
expiries and TLS certificate validity windows depend on this being right.
Exposing the gate lets the SPA render a "time not synced" indicator while
that's still true and clear it once "ok" comes through.
backend/app/core/local_config.py
New read_ntp_gate(path) function alongside read_local_toml. Three states:
"ok" chrony reported sync within the 3-minute window
"warning" 3-minute timeout elapsed without sync; user already waited
and the wizard proceeded with a degraded clock
None file absent (non-appliance install), OSError, empty content,
unknown marker, or binary garbage -- "unknown / don't gate"
Defensive read mode (errors="replace") survives non-utf8 content without
crashing. Module docstring broadened from "local.toml reader" to "small
readers for appliance-set state files".
backend/app/api/routes/system.py
/system/appliance now returns:
{hostname, timezone, locale, time_synced}
with the same no-auth posture: bootstrap surfaces (i18n init, time-sync
banner) read this before auth might be set up, and the contents are
non-secret (user-set defaults + a public sync flag). The endpoint
docstring expands to explain the RTC motivation -- otherwise the
time_synced field reads like a leftover.
This commit is contained in:
@@ -497,7 +497,7 @@ class TestSystemApplianceAPI:
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {"hostname": None, "timezone": None, "locale": None}
|
||||
assert body == {"hostname": None, "timezone": None, "locale": None, "time_synced": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -513,11 +513,12 @@ class TestSystemApplianceAPI:
|
||||
response = await async_client.get("/api/v1/system/appliance")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"hostname": "workshop-pi",
|
||||
"timezone": "Europe/Berlin",
|
||||
"locale": "de",
|
||||
}
|
||||
body = response.json()
|
||||
assert body["hostname"] == "workshop-pi"
|
||||
assert body["timezone"] == "Europe/Berlin"
|
||||
assert body["locale"] == "de"
|
||||
# time_synced state is host-dependent in this test; just assert the field exists.
|
||||
assert "time_synced" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -548,3 +549,57 @@ class TestSystemApplianceAPI:
|
||||
"""
|
||||
response = await async_client.get("/api/v1/system/appliance")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_appliance_endpoint_time_synced_ok(self, async_client: AsyncClient, tmp_path, monkeypatch):
|
||||
"""NTP gate written by ntp-gate.sh with 'ok' surfaces as time_synced='ok'."""
|
||||
from backend.app.api.routes import system as system_routes
|
||||
from backend.app.core import local_config
|
||||
|
||||
gate = tmp_path / "time-synced"
|
||||
gate.write_text("ok\n")
|
||||
monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(gate))
|
||||
|
||||
response = await async_client.get("/api/v1/system/appliance")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["time_synced"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_appliance_endpoint_time_synced_warning(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""3-minute NTP timeout marker surfaces as time_synced='warning'."""
|
||||
from backend.app.api.routes import system as system_routes
|
||||
from backend.app.core import local_config
|
||||
|
||||
gate = tmp_path / "time-synced"
|
||||
gate.write_text("warning: ntp sync timed out\n")
|
||||
monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(gate))
|
||||
|
||||
response = await async_client.get("/api/v1/system/appliance")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["time_synced"] == "warning"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_appliance_endpoint_time_synced_absent(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Non-appliance install: no gate file -> time_synced is null."""
|
||||
from backend.app.api.routes import system as system_routes
|
||||
from backend.app.core import local_config
|
||||
|
||||
absent = tmp_path / "no-gate-here"
|
||||
monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(absent))
|
||||
|
||||
response = await async_client.get("/api/v1/system/appliance")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["time_synced"] is None
|
||||
|
||||
Reference in New Issue
Block a user