fix(hub): derive Restart required from boot default vs running system

NixOS already knows whether a reboot is pending: /nix/var/nix/profiles/
system vs /run/current-system. Marker files only the Hub's own updater
wrote desynced for terminal-updated machines (and markers from older
updaters could never clear), pinning the badge on forever. Reconcile
REBOOT_REQUIRED against live state on every read; the stale marker
self-heals to IDLE. The .generation marker write is now informational.
This commit is contained in:
2026-08-19 11:31:29 -05:00
parent 48dacbeef3
commit a1fa40cacf
5 changed files with 213 additions and 129 deletions
+13 -11
View File
@@ -55,7 +55,7 @@ from .security_helpers import (
load_session_store,
save_session_store,
)
from .update_state import staged_generation_is_active
from .update_state import effective_update_status
logger = logging.getLogger(__name__)
@@ -1648,11 +1648,14 @@ def _write_update_status(status: str):
def _read_update_status() -> str:
"""Read and reconcile the persistent update status.
``REBOOT_REQUIRED`` intentionally survives Hub/browser restarts before the
reboot. Once the staged generation is the running ``/run/current-system``,
clear that marker so a completed reboot cannot leave the Hub asking for
another reboot forever. The generation helper can recover older updates
from the final nixos-rebuild log line when no explicit marker exists.
``REBOOT_REQUIRED`` survives Hub/browser restarts before the reboot, but
it is a CLAIM about live NixOS state, not the source of truth: the boot
default (``/nix/var/nix/profiles/system``) versus the running
``/run/current-system``. Re-validating on every read keeps the Hub
correct when the system was updated from a terminal or support session
(which never writes Hub markers), and lets an old marker that predates
the reconciliation feature self-heal instead of demanding reboots
forever. The stale marker file is removed once cleared.
"""
try:
with open(UPDATE_STATUS, "r") as f:
@@ -1660,15 +1663,14 @@ def _read_update_status() -> str:
except FileNotFoundError:
return "IDLE"
if status == "REBOOT_REQUIRED" and staged_generation_is_active(
UPDATE_GENERATION, UPDATE_LOG
):
_write_update_status("IDLE")
effective = effective_update_status(status)
if effective != status:
_write_update_status(effective)
try:
os.remove(UPDATE_GENERATION)
except OSError:
pass
return "IDLE"
return effective
return status
+70 -63
View File
@@ -1,81 +1,88 @@
"""Persistent update-state helpers for the Sovran Hub.
"""Update-state helpers for the Sovran Hub.
The full-system updater stages a NixOS generation with ``nixos-rebuild boot``.
That generation is not active until the machine reboots. These helpers let the
Hub distinguish a genuinely pending reboot from an old REBOOT_REQUIRED marker
that survived the reboot.
That generation is not active until the machine reboots — and the same is true
for updates started from a terminal or an SSH support session, which never go
near the Hub's status files.
This module deliberately has no FastAPI or systemd dependencies so its state
reconciliation can be tested without importing the Hub server.
The ONLY reliable indicator that a reboot is pending is NixOS itself: the
system profile (``/nix/var/nix/profiles/system``), which ``nixos-rebuild``
points at the newest generation on every ``boot`` AND every ``switch``, versus
``/run/current-system``, the generation actually running since the last boot.
When the two differ, a staged generation has not been booted yet.
Earlier revisions reconstructed this from a marker file and log tails written
by the Hub's own updater. Any system updated by other means — or whose
``REBOOT_REQUIRED`` status was written by an updater older than the marker
feature — left the Hub showing "Restart required" forever: the recorded
generation could never equal the (since advanced) running one, so the marker
could never be cleared.
This module has no FastAPI or systemd dependencies so the policy can be tested
without importing the Hub server.
"""
from __future__ import annotations
import os
import re
# The NixOS system profile. ``nixos-rebuild boot`` and ``nixos-rebuild
# switch`` both add a generation here; ``boot`` additionally makes it the
# bootloader default. The path is a symlink chain (``system`` ->
# ``system-N-link`` -> ``/nix/store/...-nixos-system-...``).
BOOT_PROFILE_PATH = "/nix/var/nix/profiles/system"
CURRENT_SYSTEM_PATH = "/run/current-system"
# Nix store hashes use the lower-case Nix base32 alphabet. Keep the output
# name deliberately conservative: a system generation has no path separators.
_SYSTEM_GENERATION_RE = re.compile(
r"^/nix/store/[0-9a-z]{32}-nixos-system-[A-Za-z0-9._+\-]+$"
)
_LOG_GENERATION_RE = re.compile(
r"The new configuration is "
r"(/nix/store/[0-9a-z]{32}-nixos-system-[A-Za-z0-9._+\-]+)"
)
def reboot_is_pending(
boot_profile_path: str = BOOT_PROFILE_PATH,
current_system_path: str = CURRENT_SYSTEM_PATH,
) -> bool:
"""Return whether a staged NixOS generation has not been booted yet.
This is deliberately independent of how the update was started — Hub
"Update System", terminal ``nixos-rebuild boot``, or a support session all
move the system profile the same way:
def _valid_generation(value: str) -> str | None:
"""Return a normalized NixOS generation path, or ``None`` if invalid."""
candidate = value.strip()
if _SYSTEM_GENERATION_RE.fullmatch(candidate):
return candidate
return None
* after ``nixos-rebuild boot``: profile -> new, current -> old → pending
* after rebooting: both -> new → cleared
* after ``nixos-rebuild switch``: both move together → no reboot
ever needed (switch activates immediately)
* after a rollback: both point at the rollback target → cleared
def read_staged_generation(marker_path: str, log_path: str) -> str | None:
"""Read the generation staged by the last successful Hub update.
New updater versions write ``marker_path`` explicitly. For an update that
started with an older updater, recover the same value from the final
``nixos-rebuild`` log line. Only the tail is needed and bounding the read
avoids loading a potentially large build log during every status poll.
Unreadable or missing paths are treated as "not pending": the Hub must
never demand a reboot it cannot substantiate.
"""
try:
with open(marker_path, "r", encoding="utf-8") as marker:
generation = _valid_generation(marker.read())
if generation:
return generation
except OSError:
pass
try:
with open(log_path, "rb") as log:
log.seek(0, os.SEEK_END)
size = log.tell()
log.seek(max(0, size - 131_072), os.SEEK_SET)
tail = log.read().decode("utf-8", errors="replace")
except OSError:
return None
matches = list(_LOG_GENERATION_RE.finditer(tail))
if not matches:
return None
return _valid_generation(matches[-1].group(1))
def staged_generation_is_active(
marker_path: str,
log_path: str,
current_system_path: str = "/run/current-system",
) -> bool:
"""Return whether the staged update generation is now the running system."""
staged = read_staged_generation(marker_path, log_path)
if not staged:
return False
try:
boot_default = os.path.realpath(boot_profile_path)
current = os.path.realpath(current_system_path)
except OSError:
return False
return current == staged
if not os.path.exists(boot_default) or not os.path.exists(current):
return False
return boot_default != current
def effective_update_status(
status: str,
boot_profile_path: str = BOOT_PROFILE_PATH,
current_system_path: str = CURRENT_SYSTEM_PATH,
) -> str:
"""Map a persisted Hub status to the one that reflects live NixOS state.
Only ``REBOOT_REQUIRED`` is re-validated: it means "the update staged a
generation the machine has not booted into", a claim that must stay true
no matter which tool performed the last update. When the boot default IS
the running system the claim is stale — the staged generation booted, was
superseded by a newer update, or the marker was written by an updater that
could never clear it — so the effective status is ``IDLE``.
All other statuses (``RUNNING``, ``FAILED``, ``SUCCESS``, ``IDLE``) pass
through unchanged; RUNNING staleness is handled separately against the
systemd unit itself.
"""
if status == "REBOOT_REQUIRED" and not reboot_is_pending(
boot_profile_path, current_system_path
):
return "IDLE"
return status