Browse Source

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.
maziggy 2 months ago
parent
commit
4dcd37bc87

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.5b1] - Unreleased
 ## [0.2.5b1] - Unreleased
 
 
 ### Added
 ### Added
+- **NTP-gate state exposed on the appliance endpoint** — `GET /api/v1/system/appliance` gains a `time_synced` field returning `"ok"`, `"warning"`, or `null`. Source: `/run/bambuddy/time-synced`, written by the appliance's `ntp-gate.sh` once chronyd reports sync (or after a 3-minute timeout with a `"warning"` marker). 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. New `backend/app/core/local_config.py::read_ntp_gate` is defensive on every failure mode (file absent → `None`, OSError → `None` + warning log, empty / unknown content → `None`, binary garbage survives via `errors="replace"`). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once `"ok"` comes through. 8 new unit cases for `read_ntp_gate` (absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (`time_synced` is `null`) — the appliance is the only consumer for now.
 - **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
 - **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
 
 
 ### Security
 ### Security

+ 16 - 6
backend/app/api/routes/system.py

@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.core.local_config import read_local_toml
+from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
@@ -608,16 +608,26 @@ async def get_system_health(
 
 
 @router.get("/appliance")
 @router.get("/appliance")
 async def get_appliance_defaults():
 async def get_appliance_defaults():
-    """Expose the hostname/timezone/locale the appliance setup wizard collected.
+    """Expose appliance-set state for the SPA's bootstrap surface.
 
 
-    Read from /etc/bambuddy/local.toml; absent on non-appliance installs, in
-    which case all fields are null. No auth required — the frontend i18n
-    bootstrap reads this BEFORE auth might be set up, and the contents are
-    purely user-set defaults (no secrets).
+    Two file sources, both optional and silently degraded when absent:
+
+    - ``/etc/bambuddy/local.toml`` — hostname / timezone / locale the
+      firstboot wizard collected.
+    - ``/run/bambuddy/time-synced`` — chrony NTP gate state. The RPi 5 has
+      no battery-backed RTC, so on a fresh boot the clock is wrong until
+      ntp-gate.sh writes "ok" (or "warning" if 3-minute timeout elapsed).
+      A warning state means JWT expiries and TLS validity windows may be
+      misaligned; the UI should surface this.
+
+    No auth required — the frontend bootstrap reads this BEFORE auth might
+    be set up, and the contents are user-set defaults plus a public sync
+    flag (no secrets).
     """
     """
     config = read_local_toml()
     config = read_local_toml()
     return {
     return {
         "hostname": config.get("hostname"),
         "hostname": config.get("hostname"),
         "timezone": config.get("timezone"),
         "timezone": config.get("timezone"),
         "locale": config.get("locale"),
         "locale": config.get("locale"),
+        "time_synced": read_ntp_gate(),
     }
     }

+ 52 - 14
backend/app/core/local_config.py

@@ -1,33 +1,43 @@
 """
 """
-Read /etc/bambuddy/local.toml — the file the appliance setup wizard writes
-during firstboot to capture the user's hostname, timezone, and locale.
+Small readers for appliance-set state files.
+
+Two distinct surfaces, same shape (defensive, silent on missing files,
+side-effect-free):
+
+- ``read_local_toml`` reads ``/etc/bambuddy/local.toml`` (the file the
+  appliance setup wizard writes during firstboot with the user's hostname,
+  timezone, and locale).
+- ``read_ntp_gate`` reads ``/run/bambuddy/time-synced`` (the appliance's
+  ntp-gate.sh signals time-sync state here once chrony reports sync, or
+  when the 3-minute timeout elapses with a "warning" marker).
 
 
 Universal across install shapes:
 Universal across install shapes:
 
 
-- On the Bambuddy Appliance: the wizard writes this file before bambuddy.service
-  starts; we read it on every startup to surface defaults to the frontend.
-- On Docker / manual installs: the file is absent; we degrade silently. An
-  operator who wants to seed defaults can drop their own local.toml into the
-  expected path or override via DATA_DIR.
-
-The reader is read-only and side-effect-free. It does NOT call hostnamectl
-or timedatectl — that's the appliance's firstboot.sh responsibility (it has
-the root privileges to do so and runs before this process exists). What we
-do here is expose the values the wizard collected so the frontend i18n
-bootstrap can pick the right initial language.
+- On the Bambuddy Appliance: both files exist by the time bambuddy.service
+  starts; we surface their values to the frontend.
+- On Docker / manual installs: both files are absent; we degrade silently.
+
+These readers are read-only and side-effect-free. They do NOT call
+hostnamectl / timedatectl / chronyc — system-state changes are the
+appliance's firstboot.sh responsibility (root, runs before this process
+exists). Here we just expose state so the frontend can render accordingly.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
 import logging
 import logging
 from pathlib import Path
 from pathlib import Path
-from typing import TypedDict
+from typing import Literal, TypedDict
 
 
 import tomllib
 import tomllib
 
 
 log = logging.getLogger(__name__)
 log = logging.getLogger(__name__)
 
 
 DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
 DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
+DEFAULT_NTP_GATE_PATH = Path("/run/bambuddy/time-synced")
+
+# Three states: synced ("ok"), gated-and-timed-out ("warning"), or unknown (None).
+TimeSyncState = Literal["ok", "warning"] | None
 
 
 
 
 class LocalConfig(TypedDict, total=False):
 class LocalConfig(TypedDict, total=False):
@@ -62,3 +72,31 @@ def read_local_toml(path: Path = DEFAULT_PATH) -> LocalConfig:
             continue
             continue
         result[key] = value  # type: ignore[literal-required]
         result[key] = value  # type: ignore[literal-required]
     return result
     return result
+
+
+def read_ntp_gate(path: Path = DEFAULT_NTP_GATE_PATH) -> TimeSyncState:
+    """Read the appliance NTP gate file. Returns "ok", "warning", or None.
+
+    Wire contract with bambuddy-appliance/firstboot/ntp-gate.sh:
+      - File absent: gate hasn't been evaluated yet, or this isn't an appliance
+        install. Caller should treat as "unknown / don't gate."
+      - File content starts with "ok": chrony reported sync within 3 minutes.
+      - File content starts with "warning": 3-minute timeout elapsed without
+        sync. The user has already waited and the wizard proceeded with a
+        degraded clock — auth tokens may have incorrect expiry, TLS certs may
+        fail validation. UI should surface this.
+      - Anything else: defensive fall-through to None.
+    """
+    try:
+        body = path.read_text(errors="replace").strip()
+    except FileNotFoundError:
+        return None
+    except OSError as exc:
+        log.warning("ntp-gate file at %s could not be read: %s", path, exc)
+        return None
+
+    if body.startswith("ok"):
+        return "ok"
+    if body.startswith("warning"):
+        return "warning"
+    return None

+ 61 - 6
backend/tests/integration/test_system_api.py

@@ -497,7 +497,7 @@ class TestSystemApplianceAPI:
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         body = response.json()
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -513,11 +513,12 @@ class TestSystemApplianceAPI:
         response = await async_client.get("/api/v1/system/appliance")
         response = await async_client.get("/api/v1/system/appliance")
 
 
         assert response.status_code == 200
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -548,3 +549,57 @@ class TestSystemApplianceAPI:
         """
         """
         response = await async_client.get("/api/v1/system/appliance")
         response = await async_client.get("/api/v1/system/appliance")
         assert response.status_code == 200
         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

+ 57 - 1
backend/tests/unit/test_local_config.py

@@ -12,7 +12,7 @@ from pathlib import Path
 
 
 import pytest
 import pytest
 
 
-from backend.app.core.local_config import read_local_toml
+from backend.app.core.local_config import read_local_toml, read_ntp_gate
 
 
 
 
 def test_missing_file_returns_empty(tmp_path: Path):
 def test_missing_file_returns_empty(tmp_path: Path):
@@ -90,3 +90,59 @@ def test_escaped_characters_round_trip(tmp_path: Path):
     path.write_text('hostname = "with\\"quote"\n')
     path.write_text('hostname = "with\\"quote"\n')
     result = read_local_toml(path)
     result = read_local_toml(path)
     assert result == {"hostname": 'with"quote'}
     assert result == {"hostname": 'with"quote'}
+
+
+# ---------------------------------------------------------------------------
+# read_ntp_gate
+# ---------------------------------------------------------------------------
+
+
+def test_ntp_gate_missing_returns_none(tmp_path: Path):
+    assert read_ntp_gate(tmp_path / "absent") is None
+
+
+def test_ntp_gate_ok(tmp_path: Path):
+    path = tmp_path / "time-synced"
+    path.write_text("ok\n")
+    assert read_ntp_gate(path) == "ok"
+
+
+def test_ntp_gate_warning(tmp_path: Path):
+    path = tmp_path / "time-synced"
+    path.write_text("warning: ntp sync timed out\n")
+    assert read_ntp_gate(path) == "warning"
+
+
+def test_ntp_gate_warning_no_suffix(tmp_path: Path):
+    """Just 'warning' on its own is also accepted."""
+    path = tmp_path / "time-synced"
+    path.write_text("warning\n")
+    assert read_ntp_gate(path) == "warning"
+
+
+def test_ntp_gate_empty_returns_none(tmp_path: Path):
+    """Empty / surprise content is treated as unknown rather than misclassified."""
+    path = tmp_path / "time-synced"
+    path.write_text("")
+    assert read_ntp_gate(path) is None
+
+
+def test_ntp_gate_unknown_marker_returns_none(tmp_path: Path):
+    path = tmp_path / "time-synced"
+    path.write_text("synced via remote NTP\n")  # neither 'ok' nor 'warning'
+    assert read_ntp_gate(path) is None
+
+
+def test_ntp_gate_strips_whitespace(tmp_path: Path):
+    """Leading whitespace shouldn't trick a startswith check."""
+    path = tmp_path / "time-synced"
+    path.write_text("   ok\n")
+    assert read_ntp_gate(path) == "ok"
+
+
+def test_ntp_gate_binary_garbage_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture):
+    """Defensive read mode survives non-utf8 content without crashing."""
+    path = tmp_path / "time-synced"
+    path.write_bytes(b"\xff\xfe\x00\x01ok\n")
+    # errors="replace" maps the bytes through but the prefix is no longer 'ok'.
+    assert read_ntp_gate(path) is None