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
+9
View File
@@ -83,6 +83,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Successful staged updates now record the exact NixOS generation. The Hub - Successful staged updates now record the exact NixOS generation. The Hub
keeps showing "Restart required" until that generation is active, then keeps showing "Restart required" until that generation is active, then
clears the marker after reboot (with log-based recovery for older updates). clears the marker after reboot (with log-based recovery for older updates).
- Pending-reboot state is now derived from NixOS itself — the boot default
(`/nix/var/nix/profiles/system`) versus the running `/run/current-system`
— instead of from Hub-written marker files. Updates performed from a
terminal or support session (which never touch the Hub's status files)
previously left a stale `REBOOT_REQUIRED` marker the Hub could never
clear, pinning the "Restart required" badge forever even after many
reboots. The marker self-heals to `IDLE` on the next status read whenever
boot default and running system agree, and recording the informational
`.generation` marker can no longer fail an otherwise successful update.
--- ---
+13 -11
View File
@@ -55,7 +55,7 @@ from .security_helpers import (
load_session_store, load_session_store,
save_session_store, save_session_store,
) )
from .update_state import staged_generation_is_active from .update_state import effective_update_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1648,11 +1648,14 @@ def _write_update_status(status: str):
def _read_update_status() -> str: def _read_update_status() -> str:
"""Read and reconcile the persistent update status. """Read and reconcile the persistent update status.
``REBOOT_REQUIRED`` intentionally survives Hub/browser restarts before the ``REBOOT_REQUIRED`` survives Hub/browser restarts before the reboot, but
reboot. Once the staged generation is the running ``/run/current-system``, it is a CLAIM about live NixOS state, not the source of truth: the boot
clear that marker so a completed reboot cannot leave the Hub asking for default (``/nix/var/nix/profiles/system``) versus the running
another reboot forever. The generation helper can recover older updates ``/run/current-system``. Re-validating on every read keeps the Hub
from the final nixos-rebuild log line when no explicit marker exists. 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: try:
with open(UPDATE_STATUS, "r") as f: with open(UPDATE_STATUS, "r") as f:
@@ -1660,15 +1663,14 @@ def _read_update_status() -> str:
except FileNotFoundError: except FileNotFoundError:
return "IDLE" return "IDLE"
if status == "REBOOT_REQUIRED" and staged_generation_is_active( effective = effective_update_status(status)
UPDATE_GENERATION, UPDATE_LOG if effective != status:
): _write_update_status(effective)
_write_update_status("IDLE")
try: try:
os.remove(UPDATE_GENERATION) os.remove(UPDATE_GENERATION)
except OSError: except OSError:
pass pass
return "IDLE" return effective
return status 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``. 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 That generation is not active until the machine reboots — and the same is true
Hub distinguish a genuinely pending reboot from an old REBOOT_REQUIRED marker for updates started from a terminal or an SSH support session, which never go
that survived the reboot. near the Hub's status files.
This module deliberately has no FastAPI or systemd dependencies so its state The ONLY reliable indicator that a reboot is pending is NixOS itself: the
reconciliation can be tested without importing the Hub server. 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 from __future__ import annotations
import os 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 def reboot_is_pending(
# name deliberately conservative: a system generation has no path separators. boot_profile_path: str = BOOT_PROFILE_PATH,
_SYSTEM_GENERATION_RE = re.compile( current_system_path: str = CURRENT_SYSTEM_PATH,
r"^/nix/store/[0-9a-z]{32}-nixos-system-[A-Za-z0-9._+\-]+$" ) -> bool:
) """Return whether a staged NixOS generation has not been booted yet.
_LOG_GENERATION_RE = re.compile(
r"The new configuration is "
r"(/nix/store/[0-9a-z]{32}-nixos-system-[A-Za-z0-9._+\-]+)"
)
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: * after ``nixos-rebuild boot``: profile -> new, current -> old → pending
"""Return a normalized NixOS generation path, or ``None`` if invalid.""" * after rebooting: both -> new → cleared
candidate = value.strip() * after ``nixos-rebuild switch``: both move together → no reboot
if _SYSTEM_GENERATION_RE.fullmatch(candidate): ever needed (switch activates immediately)
return candidate * after a rollback: both point at the rollback target → cleared
return None
Unreadable or missing paths are treated as "not pending": the Hub must
def read_staged_generation(marker_path: str, log_path: str) -> str | None: never demand a reboot it cannot substantiate.
"""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.
""" """
try: try:
with open(marker_path, "r", encoding="utf-8") as marker: boot_default = os.path.realpath(boot_profile_path)
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:
current = os.path.realpath(current_system_path) current = os.path.realpath(current_system_path)
except OSError: except OSError:
return False 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
+4 -2
View File
@@ -199,9 +199,11 @@ let
echo "[ERROR] nixos-rebuild boot failed" echo "[ERROR] nixos-rebuild boot failed"
RC=1 RC=1
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
echo "[ERROR] update was built but its staged generation could not be recorded" # The marker is informational only. The Hub derives pending-reboot
# state from the NixOS system profile itself, so failing to record
# the marker must not fail an otherwise successful update.
echo "[WARNING] update succeeded but its staged generation could not be recorded"
rm -f "$GENERATION" rm -f "$GENERATION"
RC=1
fi fi
echo "" echo ""
fi fi
+117 -53
View File
@@ -17,80 +17,144 @@ if str(_APP_PARENT) not in sys.path:
sys.path.insert(0, str(_APP_PARENT)) sys.path.insert(0, str(_APP_PARENT))
from sovran_systemsos_web.update_state import ( # noqa: E402 from sovran_systemsos_web.update_state import ( # noqa: E402
read_staged_generation, effective_update_status,
staged_generation_is_active, reboot_is_pending,
) )
GENERATION = ( # Real store paths observed on the incident machine that prompted this rework.
"/nix/store/rmi0g35cd8w60k0ig7pm6kb8kzws8b7x-" RUNNING_GENERATION = (
"nixos-system-nixos-26.11.20260817.ec2d622" "84rsiqi66nc68jbikd26ms50ap831xf8-nixos-system-nixos-26.11.20260817.ec2d622"
) )
OTHER_GENERATION = ( PREVIOUS_GENERATION = (
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-nixos-26.11.20260816.old"
"nixos-system-nixos-26.11.20260816.old" )
# What the stale Hub log still claimed was staged: a two-week-old update cycle.
HUB_LOG_GENERATION = (
"yis0saq6p8fhqcaii0h2yzqf0blhdwns-nixos-system-nixos-26.11.20260804.e72e4f2"
) )
class TestStagedGenerationState(unittest.TestCase): class TestRebootPendingState(unittest.TestCase):
"""Reboot-pending state is derived from NixOS, not from Hub marker files.
The system profile (what boots next) is compared against
/run/current-system (what is running). This stays correct no matter
which tool performed the update: Hub "Update System", a terminal
``nixos-rebuild``, or an SSH support session.
"""
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup) self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name) root = Path(self.tmp.name)
self.marker = root / "update.generation" self.store = root / "store"
self.log = root / "update.log" self.store.mkdir()
# /nix/var/nix/profiles/system is a two-hop chain: the diagnostics on
# the incident box showed ``system`` -> ``system-148-link`` -> store.
self.profile = root / "system"
self.profile_entry = root / "system-148-link"
# /run/current-system links straight to the running store path.
self.current = root / "current-system" self.current = root / "current-system"
def test_explicit_generation_marker_is_read(self): def _store_dir(self, name: str) -> str:
self.marker.write_text(GENERATION + "\n", encoding="utf-8") path = self.store / name
self.assertEqual( path.mkdir(exist_ok=True)
read_staged_generation(str(self.marker), str(self.log)), GENERATION return str(path)
def _stage(self, booted: str, running: str) -> None:
"""Stage ``booted`` as the boot default with ``running`` live."""
os.symlink(self._store_dir(booted), self.profile_entry)
os.symlink(self.profile_entry, self.profile)
os.symlink(self._store_dir(running), self.current)
def test_hub_update_staged_and_not_yet_rebooted_is_pending(self):
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
self.assertTrue(
reboot_is_pending(str(self.profile), str(self.current))
) )
def test_legacy_updater_generation_is_recovered_from_log_tail(self): def test_staged_generation_booted_is_no_longer_pending(self):
self.log.write_text( self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
"building the system configuration...\n" self.assertFalse(
f"Done. The new configuration is {GENERATION}\n" reboot_is_pending(str(self.profile), str(self.current))
)
def test_terminal_switch_needs_no_reboot(self):
# nixos-rebuild switch moves the profile AND current-system together.
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_rollback_leaves_nothing_pending(self):
# nixos-rebuild switch --rollback points both at the rollback target.
self._stage(booted=PREVIOUS_GENERATION, running=PREVIOUS_GENERATION)
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_unverifiable_state_is_never_a_reboot_demand(self):
# No profile and no running system readable: the Hub must not nag
# about a reboot it cannot substantiate.
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_stale_hub_marker_clears_after_terminal_updates(self):
"""The exact incident: terminal-updated machine, frozen Hub marker.
The user's last Hub update (old updater, weeks prior) left
REBOOT_REQUIRED behind. Every update since ran in a terminal and
never touched the Hub's files, so the log still records a staged
generation from 2026-08-04 while the machine runs a 2026-08-17
build. With the profile and the running system in agreement, the
stale claim must reconcile to IDLE regardless of anything the old
marker/log files say.
"""
root = Path(self.tmp.name)
log = root / "sovran-hub-update.log"
log.write_text(
"Done. The new configuration is "
f"/nix/store/{HUB_LOG_GENERATION}\n"
"✓ Update staged successfully\n", "✓ Update staged successfully\n",
encoding="utf-8", encoding="utf-8",
) )
self.assertEqual( # Deliberately no sovran-hub-update.generation marker: the updater
read_staged_generation(str(self.marker), str(self.log)), GENERATION # that produced this state predates the marker feature.
)
def test_latest_generation_line_wins(self):
self.log.write_text(
f"Done. The new configuration is {OTHER_GENERATION}\n"
f"Done. The new configuration is {GENERATION}\n",
encoding="utf-8",
)
self.assertEqual(
read_staged_generation(str(self.marker), str(self.log)), GENERATION
)
def test_invalid_marker_is_ignored(self):
self.marker.write_text("/tmp/not-a-generation\n", encoding="utf-8")
self.log.write_text("no completed generation\n", encoding="utf-8")
self.assertIsNone(read_staged_generation(str(self.marker), str(self.log)))
def test_staged_generation_is_active_after_reboot(self):
self.marker.write_text(GENERATION + "\n", encoding="utf-8")
os.symlink(GENERATION, self.current)
self.assertTrue(
staged_generation_is_active(
str(self.marker), str(self.log), str(self.current)
)
)
def test_staged_generation_remains_pending_before_reboot(self):
self.marker.write_text(GENERATION + "\n", encoding="utf-8")
os.symlink(OTHER_GENERATION, self.current)
self.assertFalse( self.assertFalse(
staged_generation_is_active( (root / "sovran-hub-update.generation").exists()
str(self.marker), str(self.log), str(self.current)
)
) )
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
self.assertEqual(
effective_update_status(
"REBOOT_REQUIRED", str(self.profile), str(self.current)
),
"IDLE",
)
def test_genuine_pending_reboot_claim_survives(self):
# A staged generation that has NOT been booted yet: the claim is
# true and must keep surfacing until the reboot really happens.
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
self.assertEqual(
effective_update_status(
"REBOOT_REQUIRED", str(self.profile), str(self.current)
),
"REBOOT_REQUIRED",
)
def test_other_statuses_pass_through_unchanged(self):
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
for status in ("RUNNING", "FAILED", "SUCCESS", "IDLE"):
self.assertEqual(
effective_update_status(
status, str(self.profile), str(self.current)
),
status,
)
class TestUpdatePollingWiring(unittest.TestCase): class TestUpdatePollingWiring(unittest.TestCase):
"""Guard the browser failure modes that caused a permanent spinner.""" """Guard the browser failure modes that caused a permanent spinner."""