From 3dca5c0288fb48c1b3216639b1b1ae154d0212a9 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 23 Oct 2026 09:34:01 +0100 Subject: [PATCH 1/5] fix(updater): install Happy-Hare mmu_server Moonraker component --- tests/updater/test_components_unit.py | 12 +++ tests/updater/test_hooks_unit.py | 144 +++++++++++++++++++++++++- updater/components.yaml | 11 ++ updater/hooks/Happy-Hare.sh | 62 +++++++++++ 4 files changed, 228 insertions(+), 1 deletion(-) create mode 100755 updater/hooks/Happy-Hare.sh diff --git a/tests/updater/test_components_unit.py b/tests/updater/test_components_unit.py index 8b6747a8..5214a6ff 100644 --- a/tests/updater/test_components_unit.py +++ b/tests/updater/test_components_unit.py @@ -545,3 +545,15 @@ def test_origin_prefixed_branch_is_stripped(caplog): bs = next(c for c in configs if c.name == "BlocksScreen") assert bs.branch == "Release-2.0" # stray origin/ prefix stripped assert any("origin/" in r.message for r in caplog.records) + + +def test_shipped_happy_hare_is_pinned_without_service(): + with patch("updater.components.OVERRIDE_PATH", Path("/nonexistent/override.yaml")): + configs, _ = load_components() + hh = next(c for c in configs if c.name == "Happy-Hare") + assert hh.install_if_missing and hh.url + assert hh.version == "d5cce9f96991b270ff570b7497bbf6b4463a82b9" + # A failed provision disables component.service; it must never be moonraker. + assert hh.service is None + names = [c.name for c in configs] + assert names.index("moonraker") < names.index("Happy-Hare") diff --git a/tests/updater/test_hooks_unit.py b/tests/updater/test_hooks_unit.py index d52c553d..76f40d93 100644 --- a/tests/updater/test_hooks_unit.py +++ b/tests/updater/test_hooks_unit.py @@ -11,7 +11,9 @@ HOOK = Path(__file__).resolve().parents[2] / "updater" / "hooks" / "DeviceDiscovery.sh" -def _run_hook(tmp_path: Path, repo: Path, load_state: str) -> subprocess.CompletedProcess: +def _run_hook( + tmp_path: Path, repo: Path, load_state: str +) -> subprocess.CompletedProcess: """Run the hook with a stub systemctl that reports load_state.""" stub_bin = tmp_path / "stub-bin" stub_bin.mkdir() @@ -66,3 +68,143 @@ def test_set_up_machine_rebuilds(self, tmp_path, repo): assert result.returncode == 0, result.stderr assert (repo / "built").exists() assert not (tmp_path / "run" / "sentinel").exists() + + +HH_HOOK = HOOK.parent / "Happy-Hare.sh" +_MOONRAKER_CONF = ( + "[server]\nhost: 0.0.0.0\n\n[mmu_server]\nenable_file_preprocessor: True\n" +) + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + *args, + ], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +@pytest.fixture +def hh_home(tmp_path: Path) -> Path: + home = tmp_path / "home" + hh = home / "Happy-Hare" + (hh / "components").mkdir(parents=True) + (hh / "components" / "mmu_server.py").write_text("v1\n") + _git(hh, "init", "-q") + _git(hh, "add", ".") + _git(hh, "commit", "-qm", "v1") + components = home / "moonraker" / "moonraker" / "components" + components.mkdir(parents=True) + _git(home / "moonraker", "init", "-q") + conf = home / "printer_data" / "config" / "moonraker.conf" + conf.parent.mkdir(parents=True) + conf.write_text(_MOONRAKER_CONF) + return home + + +def _run_hh_hook( + tmp_path: Path, home: Path, prev: str = "", new: str = "" +) -> subprocess.CompletedProcess: + """Run the Happy-Hare hook with a stub sudo that records its argv.""" + stub_bin = tmp_path / "stub-bin" + stub_bin.mkdir(exist_ok=True) + sudo = stub_bin / "sudo" + sudo.write_text(f'#!/bin/bash\necho "$*" >>{tmp_path}/sudo.log\n') + sudo.chmod(0o755) + env = { + "PATH": f"{stub_bin}:{os.environ['PATH']}", + "HOME": str(home), + "COMPONENT_PATH": str(home / "Happy-Hare"), + "PREV_HASH": prev, + "NEW_HASH": new, + } + return subprocess.run( + ["bash", str(HH_HOOK)], env=env, capture_output=True, text=True, check=False + ) + + +def _sudo_calls(tmp_path: Path) -> list[str]: + log = tmp_path / "sudo.log" + return log.read_text().splitlines() if log.exists() else [] + + +def _exclude(home: Path) -> list[str]: + return (home / "moonraker" / ".git" / "info" / "exclude").read_text().splitlines() + + +class TestHappyHareHook: + def test_provision_links_excludes_and_restarts(self, tmp_path, hh_home): + result = _run_hh_hook(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + assert dst.resolve() == hh_home / "Happy-Hare" / "components" / "mmu_server.py" + assert "/moonraker/components/mmu_server.py" in _exclude(hh_home) + assert _git(hh_home / "moonraker", "status", "--porcelain") == "" + assert _sudo_calls(tmp_path) == [ + "systemctl restart --no-block moonraker.service" + ] + + def test_unchanged_update_is_idempotent(self, tmp_path, hh_home): + _run_hh_hook(tmp_path, hh_home) + head = _git(hh_home / "Happy-Hare", "rev-parse", "HEAD") + result = _run_hh_hook(tmp_path, hh_home, head, head) + assert result.returncode == 0, result.stderr + assert _exclude(hh_home).count("/moonraker/components/mmu_server.py") == 1 + assert len(_sudo_calls(tmp_path)) == 1 + + def test_changed_mmu_server_restarts(self, tmp_path, hh_home): + _run_hh_hook(tmp_path, hh_home) + hh = hh_home / "Happy-Hare" + prev = _git(hh, "rev-parse", "HEAD") + (hh / "components" / "mmu_server.py").write_text("v2\n") + _git(hh, "commit", "-qam", "v2") + result = _run_hh_hook(tmp_path, hh_home, prev, _git(hh, "rev-parse", "HEAD")) + assert result.returncode == 0, result.stderr + assert len(_sudo_calls(tmp_path)) == 2 + + def test_no_mmu_server_section_skips_restart(self, tmp_path, hh_home): + (hh_home / "printer_data" / "config" / "moonraker.conf").write_text( + "[server]\n" + ) + result = _run_hh_hook(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert ( + hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + ).is_symlink() + assert _sudo_calls(tmp_path) == [] + + def test_replaces_copied_file(self, tmp_path, hh_home): + dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + dst.write_text("old copy\n") + result = _run_hh_hook(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert dst.is_symlink() + + def test_exclude_without_trailing_newline(self, tmp_path, hh_home): + exclude = hh_home / "moonraker" / ".git" / "info" / "exclude" + exclude.write_text("*.pyc") + _run_hh_hook(tmp_path, hh_home) + assert _exclude(hh_home) == ["*.pyc", "/moonraker/components/mmu_server.py"] + + def test_missing_moonraker_skips(self, tmp_path, hh_home): + subprocess.run(["rm", "-rf", str(hh_home / "moonraker")], check=True) + result = _run_hh_hook(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert _sudo_calls(tmp_path) == [] + + def test_missing_source_fails(self, tmp_path, hh_home): + (hh_home / "Happy-Hare" / "components" / "mmu_server.py").unlink() + result = _run_hh_hook(tmp_path, hh_home) + assert result.returncode == 1 diff --git a/updater/components.yaml b/updater/components.yaml index 8aef7f94..ab5efadc 100644 --- a/updater/components.yaml +++ b/updater/components.yaml @@ -21,6 +21,17 @@ components: reset_mode: hard order: 3 + # Only mmu_server.py is used (hook links it). No service: a failed provision would disable moonraker. + - name: Happy-Hare + type: git + path: ~/Happy-Hare + url: https://github.com/moggieuk/Happy-Hare.git + branch: v3 + version: d5cce9f96991b270ff570b7497bbf6b4463a82b9 + reset_mode: hard + order: 4 + install_if_missing: true + - name: RF50-Klipper type: git path: ~/RF50-Klipper diff --git a/updater/hooks/Happy-Hare.sh b/updater/hooks/Happy-Hare.sh new file mode 100755 index 00000000..c9a9e87b --- /dev/null +++ b/updater/hooks/Happy-Hare.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Link Happy-Hare's Moonraker component, as HH install.sh link_mmu_plugins does, without its printer.cfg edits. +set -euo pipefail + +if [ -z "${COMPONENT_PATH:-}" ]; then + echo "[hook:Happy-Hare] no COMPONENT_PATH" + exit 1 +fi + +_src="$COMPONENT_PATH/components/mmu_server.py" +if [ ! -f "$_src" ]; then + echo "[hook:Happy-Hare] $_src missing - bad checkout" + exit 1 +fi + +_home=$(dirname "$COMPONENT_PATH") +_moonraker="$_home/moonraker" +_components="$_moonraker/moonraker/components" +if [ ! -d "$_components" ]; then + echo "[hook:Happy-Hare] $_components not found - Moonraker not installed, skipping" + exit 0 +fi + +_dst="$_components/mmu_server.py" +_linked=false +if [ "$(readlink "$_dst" 2>/dev/null || true)" != "$_src" ]; then + ln -sfn "$_src" "$_dst" + _linked=true + echo "[hook:Happy-Hare] linked $_dst -> $_src" +fi + +# Moonraker reports untracked .py files as a repo anomaly; info/exclude is never committed or cleaned. +_exclude=$(git -C "$_moonraker" rev-parse --path-format=absolute --git-path info/exclude 2>/dev/null || true) +_pattern="/moonraker/components/mmu_server.py" +if [ -n "$_exclude" ] && ! grep -qxF "$_pattern" "$_exclude" 2>/dev/null; then + mkdir -p "$(dirname "$_exclude")" + if [ -s "$_exclude" ] && [ -n "$(tail -c1 "$_exclude")" ]; then + echo >>"$_exclude" + fi + echo "$_pattern" >>"$_exclude" + echo "[hook:Happy-Hare] added $_pattern to $_exclude" +fi + +if ! $_linked && [ -n "${PREV_HASH:-}" ] && [ -n "${NEW_HASH:-}" ] \ + && git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ + -- components/mmu_server.py 2>/dev/null; then + echo "[hook:Happy-Hare] mmu_server.py unchanged - no Moonraker restart" + exit 0 +fi + +# Moonraker only loads the component when moonraker.conf has the section. +_conf="$_home/printer_data/config/moonraker.conf" +if ! grep -q '^\[mmu_server\]' "$_conf" 2>/dev/null; then + echo "[hook:Happy-Hare] no [mmu_server] in $_conf - no Moonraker restart" + exit 0 +fi + +echo "[hook:Happy-Hare] restarting moonraker.service to load mmu_server" +sudo systemctl restart --no-block moonraker.service 2>/dev/null || { + echo "[hook:Happy-Hare] WARN: could not restart moonraker.service - loads on next restart" +} +echo "[hook:Happy-Hare] done" From 269cd390830d0199215ec0fa574bf0b3ce101853 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 23 Oct 2026 15:31:50 +0100 Subject: [PATCH 2/5] fix(updater): retry a failed follow-up up to 3 times --- tests/updater/test_service_unit.py | 90 +++++++++++++++++++++++++++++- updater/service.py | 65 +++++++++++++++++---- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 1e9d6b36..9ed8f593 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -2443,8 +2443,8 @@ async def targets(_self, _base): assert on_busy.call_args_list == [call(True), call(False)] @pytest.mark.asyncio - async def test_failed_update_is_not_retried(self, svc, env): - # One-shot: the marker is gone before update_all, so a crash cannot loop it. + async def test_crash_mid_update_still_counts_the_try(self, svc, env): + # The try is recorded before update_all, so a crash or reboot cannot loop it. self._mark(svc) env.side_effect = RuntimeError("boom") with ( @@ -2454,8 +2454,94 @@ async def test_failed_update_is_not_retried(self, svc, env): pytest.raises(RuntimeError), ): await svc.run_followup() + assert svc._followup_path.read_text() == f"{self.BASE}\n1\n" + + @pytest.mark.asyncio + async def test_failed_update_retries_then_leaves_it_to_update(self, svc, env): + self._mark(svc) + env.return_value = False + tries = updater_service._FOLLOWUP_TRIES + with patch.object( + UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) + ): + results = [await svc.run_followup() for _ in range(tries)] + assert results == [(True, False)] * (tries - 1) + [(False, True)] + assert env.await_count == tries + assert not svc._followup_path.exists() + + @pytest.mark.asyncio + async def test_retry_after_failure_succeeds_and_clears(self, svc, env): + self._mark(svc) + env.side_effect = [False, True] + with patch.object( + UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) + ): + assert await svc.run_followup() == (True, False) + assert await svc.run_followup() == (False, True) + assert not svc._followup_path.exists() + + @pytest.mark.asyncio + async def test_used_up_marker_is_dropped_without_updating(self, svc, env): + self._mark(svc, f"{self.BASE}\n{updater_service._FOLLOWUP_TRIES}") + with patch.object( + UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) + ): + assert await svc.run_followup() == (False, False) + env.assert_not_called() assert not svc._followup_path.exists() + @pytest.mark.asyncio + async def test_unwritable_marker_runs_once(self, svc, env): + self._mark(svc) + env.return_value = False + with ( + patch.object( + UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) + ), + patch.object(UpdateService, "_write_followup", return_value=False), + ): + assert await svc.run_followup() == (False, True) + env.assert_awaited_once() + assert not svc._followup_path.exists() + + @pytest.mark.asyncio + async def test_undeletable_unwritable_marker_is_not_retried(self, svc, env): + # The marker survives with its count unchanged, so a retry would loop forever. + self._mark(svc) + env.return_value = False + with ( + patch.object( + UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) + ), + patch.object(UpdateService, "_write_followup", return_value=False), + patch.object(UpdateService, "_clear_followup"), + ): + assert await svc.run_followup() == (False, True) + env.assert_awaited_once() + + def test_failed_marker_write_keeps_the_old_marker(self, svc): + self._mark(svc) + with patch.object(Path, "replace", side_effect=OSError("disk full")): + assert svc._write_followup(self.BASE, 1) is False + assert svc._followup_path.read_text().split() == [self.BASE] + assert not svc._followup_path.with_name( + svc._followup_path.name + ".tmp" + ).exists() + + @pytest.mark.parametrize( + ("text", "want"), + [ + ("a" * 40, ("a" * 40, 0)), + (f"{'a' * 40}\n2", ("a" * 40, 2)), + (f"{'a' * 40}\nx", ("", 0)), + (f"{'a' * 40}\n1\n2", ("", 0)), + ], + ) + def test_read_followup_formats(self, svc, text, want): + self._mark(svc, text) + assert svc._read_followup() == want + assert svc._followup_path.exists() == bool(want[0]) + @pytest.mark.asyncio async def test_nothing_dirty_clears_marker(self, svc, env): self._mark(svc) diff --git a/updater/service.py b/updater/service.py index 2c1abba8..55c944cf 100644 --- a/updater/service.py +++ b/updater/service.py @@ -178,6 +178,7 @@ def reset(self) -> None: _FORWARD_HEAL_BASE_S = 1800.0 _FORWARD_HEAL_JITTER_S = 300.0 _OFFLINE_PROVISION_TRIES = 6 # ~30 min of 300s polls, for late Wi-Fi +_FOLLOWUP_TRIES = 3 # counted in the marker, so a crash or reboot mid-try still counts # Watched by BlocksScreen-deploy.path: install-updater.sh runs in its own cgroup. _DEPLOY_FLAG = Path.home() / ".config" / "blockscreen" / ".run-install-updater" @@ -570,7 +571,7 @@ async def run_followup( self, on_busy: Callable[[bool], None] | None = None ) -> tuple[bool, bool]: """Apply manifest entries changed by the last BlocksScreen update; (retry, ran).""" - base = await asyncio.to_thread(self._read_followup) + base, tries = await asyncio.to_thread(self._read_followup) if not base: return False, False if await klipper_printing() or await self.deploy_running(): @@ -593,12 +594,34 @@ async def run_followup( # Still shown dirty, so a later Update applies it. self._log.info("followup: still offline, leaving it to Update") dirty = set() - # One-shot: a failing entry must not re-run on every boot. - await asyncio.to_thread(self._clear_followup) + if dirty and tries >= _FOLLOWUP_TRIES: + self._log.warning( + "followup: %d tries failed, leaving %s to Update", + tries, + sorted(dirty), + ) + dirty = set() + # Bounded, not one-shot: the try is recorded before it runs. + recorded = bool(dirty) and await asyncio.to_thread( + self._write_followup, base, tries + 1 + ) + if not recorded: + await asyncio.to_thread(self._clear_followup) if not dirty: return False, False - self._log.info("followup: applying %s", sorted(dirty)) - await self.update_all(dirty) + self._log.info( + "followup: applying %s (try %d/%d)", + sorted(dirty), + tries + 1, + _FOLLOWUP_TRIES, + ) + if not await self.update_all(dirty): + # An unrecorded try may not be retried: its count never advances. + if recorded and tries + 1 < _FOLLOWUP_TRIES: + self._log.warning("followup: update failed, retrying next poll") + return True, False + self._log.warning("followup: update failed, leaving it to Update") + await asyncio.to_thread(self._clear_followup) finally: if on_busy: on_busy(False) @@ -633,16 +656,34 @@ async def _followup_reachable(self, names: set[str]) -> bool: return False return True - def _read_followup(self) -> str: - """The follow-up base hash, or empty when absent or malformed.""" + def _read_followup(self) -> tuple[str, int]: + """The follow-up (base hash, tries so far); empty base when absent or malformed.""" try: - base = self._followup_path.read_text().strip() + fields = self._followup_path.read_text().split() except OSError: - return "" - if _GIT_SHA_RE.match(base): - return base + return "", 0 + # The hook writes the hash alone; _write_followup appends the try count. + if fields and _GIT_SHA_RE.match(fields[0]): + if len(fields) == 1: + return fields[0], 0 + if len(fields) == 2 and fields[1].isdigit(): + return fields[0], int(fields[1]) self._clear_followup() - return "" + return "", 0 + + def _write_followup(self, base: str, tries: int) -> bool: + """Record a try in the marker; False when it cannot be written.""" + # Atomic: a power cut mid-write must not leave a truncated, dropped marker. + tmp = self._followup_path.with_name(self._followup_path.name + ".tmp") + try: + tmp.write_text(f"{base}\n{tries}\n") + tmp.replace(self._followup_path) + except OSError: + with contextlib.suppress(OSError): + tmp.unlink() + self._log.warning("followup: cannot record try, running it once") + return False + return True def _clear_followup(self) -> None: """Remove the follow-up marker.""" From 6dde8839bc8b25b75ddbe947d0961635cb3b4821 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 23 Oct 2026 16:10:51 +0100 Subject: [PATCH 3/5] test(updater): Happy-Hare edits, v4 or any off-pin commit reset to the v3 pin --- tests/updater/test_components_unit.py | 2 + tests/updater/test_service_unit.py | 88 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/tests/updater/test_components_unit.py b/tests/updater/test_components_unit.py index 5214a6ff..2d413a9f 100644 --- a/tests/updater/test_components_unit.py +++ b/tests/updater/test_components_unit.py @@ -553,6 +553,8 @@ def test_shipped_happy_hare_is_pinned_without_service(): hh = next(c for c in configs if c.name == "Happy-Hare") assert hh.install_if_missing and hh.url assert hh.version == "d5cce9f96991b270ff570b7497bbf6b4463a82b9" + # v3 is enforced: edits, v4 or any other commit are reset back to the pin. + assert (hh.branch, hh.reset_mode) == ("v3", "hard") # A failed provision disables component.service; it must never be moonraker. assert hh.service is None names = [c.name for c in configs] diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 9ed8f593..3e2a27d4 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -1,6 +1,7 @@ import asyncio import json import os +import subprocess import time from contextlib import ExitStack, nullcontext from pathlib import Path @@ -3893,3 +3894,90 @@ async def fake_tree(path, ref, repo_path): assert result is False # BlocksScreen dropped done = {c.args[0]: c.args[1] for c in cb.on_component_done.call_args_list} assert done == {"klipper": True, "BlocksScreen": False} + + +def _git_run(repo: Path, *args: str) -> str: + """Run git in repo with a throwaway identity and no signing.""" + return subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@t"] + + ["-c", "commit.gpgsign=false", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +class TestPinnedCheckoutIsEnforced: + """A pinned repo (Happy-Hare v3) is put back on its pin whatever the user did to it.""" + + def _clone(self, tmp_path: Path) -> tuple[Path, dict[str, str]]: + """Clone of an origin with v3 (old -> pin -> v3tip) and main (old -> v4).""" + src = tmp_path / "src" + src.mkdir() + _git_run(src, "init", "-q", "-b", "v3") + shas = {} + for n in ("old", "pin", "v3tip"): + (src / "f.txt").write_text(n) + _git_run(src, "add", "f.txt") + _git_run(src, "commit", "-q", "-m", n) + shas[n] = _git_run(src, "rev-parse", "HEAD") + _git_run(src, "checkout", "-q", "-b", "main", shas["old"]) + (src / "f.txt").write_text("v4") + _git_run(src, "commit", "-q", "-am", "v4") + shas["v4"] = _git_run(src, "rev-parse", "HEAD") + _git_run(tmp_path, "clone", "-q", "-b", "v3", str(src), "Happy-Hare") + return tmp_path / "Happy-Hare", shas + + def _tamper(self, repo: Path, shas: dict[str, str], how: str) -> None: + """Leave the checkout the way a user might.""" + if how == "edited": + _git_run(repo, "reset", "-q", "--hard", shas["pin"]) + (repo / "f.txt").write_text("user edit") + elif how == "v4": + _git_run(repo, "checkout", "-q", "main") + else: + _git_run(repo, "reset", "-q", "--hard", shas[how]) + + def _comp(self, repo: Path, pin: str) -> ComponentConfig: + return ComponentConfig( + name="Happy-Hare", + kind="git", + path=repo, + branch="v3", + version=pin, + reset_mode="hard", + ) + + @pytest.mark.asyncio + async def test_clean_checkout_on_pin_is_current(self, tmp_path): + repo, shas = self._clone(tmp_path) + _git_run(repo, "reset", "-q", "--hard", shas["pin"]) + status = await updater_service.check_git_status( + "Happy-Hare", repo, "v3", shas["pin"], skip_fetch=True + ) + assert status.needs_update is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("how", ["edited", "v4", "v3tip", "old"]) + async def test_tampered_checkout_needs_update(self, tmp_path, how): + repo, shas = self._clone(tmp_path) + self._tamper(repo, shas, how) + status = await updater_service.check_git_status( + "Happy-Hare", repo, "v3", shas["pin"], skip_fetch=True + ) + assert status.needs_update is True + + @pytest.mark.asyncio + @pytest.mark.parametrize("how", ["edited", "v4", "v3tip", "old"]) + async def test_apply_puts_tampered_checkout_back_on_pin(self, tmp_path, how): + repo, shas = self._clone(tmp_path) + self._tamper(repo, shas, how) + svc = UpdateService(callback=MagicMock()) + ok, err = await svc._stage_apply_ref( + self._comp(repo, shas["pin"]), shas["v3tip"] + ) + assert (ok, err) == (True, "") + assert _git_run(repo, "rev-parse", "HEAD") == shas["pin"] + assert _git_run(repo, "branch", "--show-current") == "v3" + assert _git_run(repo, "status", "--porcelain", "--untracked-files=no") == "" From 86df63dd4768ac4b239af48da129b86fcb7817d3 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 23 Oct 2026 18:17:26 +0100 Subject: [PATCH 4/5] fix(updater): match sudoers restart argv and relink mmu_server after a Moonraker reclone --- tests/updater/test_components_unit.py | 2 - tests/updater/test_dbus_service_unit.py | 10 ++ tests/updater/test_executor_unit.py | 17 ++++ tests/updater/test_hooks_unit.py | 130 +++++++++++++++++++++++- tests/updater/test_service_unit.py | 27 ++++- updater/dbus_service.py | 3 +- updater/executor.py | 2 + updater/hooks/Happy-Hare.sh | 24 ++++- updater/hooks/moonraker.sh | 27 +++++ updater/service.py | 8 +- 10 files changed, 234 insertions(+), 16 deletions(-) create mode 100755 updater/hooks/moonraker.sh diff --git a/tests/updater/test_components_unit.py b/tests/updater/test_components_unit.py index 2d413a9f..e1ccaead 100644 --- a/tests/updater/test_components_unit.py +++ b/tests/updater/test_components_unit.py @@ -553,9 +553,7 @@ def test_shipped_happy_hare_is_pinned_without_service(): hh = next(c for c in configs if c.name == "Happy-Hare") assert hh.install_if_missing and hh.url assert hh.version == "d5cce9f96991b270ff570b7497bbf6b4463a82b9" - # v3 is enforced: edits, v4 or any other commit are reset back to the pin. assert (hh.branch, hh.reset_mode) == ("v3", "hard") - # A failed provision disables component.service; it must never be moonraker. assert hh.service is None names = [c.name for c in configs] assert names.index("moonraker") < names.index("Happy-Hare") diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 1c4d8295..f65b2503 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -647,6 +647,16 @@ async def test_followup_that_ran_sweeps_apt(self, svc): svc._svc.background_apt_upgrade.assert_awaited_once() assert svc._followed_up is True + @pytest.mark.asyncio + async def test_retrying_followup_emits_status_without_apt(self, svc): + svc._provisioned = True + svc._emit_status = AsyncMock() + svc._svc.run_followup = AsyncMock(return_value=(True, True)) + await svc._followup_pass() + svc._emit_status.assert_awaited_once() + svc._svc.background_apt_upgrade.assert_not_called() + assert svc._followed_up is False + @pytest.mark.asyncio async def test_followup_skips_apt_when_restart_pending(self, svc): svc._provisioned = True diff --git a/tests/updater/test_executor_unit.py b/tests/updater/test_executor_unit.py index c5d3987b..36fe24dd 100644 --- a/tests/updater/test_executor_unit.py +++ b/tests/updater/test_executor_unit.py @@ -1252,6 +1252,23 @@ async def test_run_hook_default_timeout(self, tmp_path, monkeypatch): await run_hook("comp", tmp_path, "n", "p") assert run.await_args.kwargs["timeout"] == 60.0 + @pytest.mark.asyncio + async def test_extra_env_never_overrides_component_vars( + self, tmp_path, monkeypatch + ): + import updater.executor as ex + + monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path) + (tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n") + (tmp_path / "comp.sh").chmod(0o755) + with patch.object(ex, "_run", new=AsyncMock(return_value=(True, ""))) as run: + await run_hook( + "comp", tmp_path, "n", "p", extra_env={"X": "1", "NEW_HASH": "bad"} + ) + env = run.await_args.kwargs["env"] + assert env["X"] == "1" + assert env["NEW_HASH"] == "n" + @pytest.mark.asyncio async def test_hook_path_traversal_rejected(self, tmp_path, monkeypatch): import updater.executor as ex diff --git a/tests/updater/test_hooks_unit.py b/tests/updater/test_hooks_unit.py index d6734d40..46aa7ac2 100644 --- a/tests/updater/test_hooks_unit.py +++ b/tests/updater/test_hooks_unit.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re import subprocess from pathlib import Path @@ -71,6 +72,7 @@ def test_set_up_machine_rebuilds(self, tmp_path, repo): HH_HOOK = HOOK.parent / "Happy-Hare.sh" +_NOT_ROOT = pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file modes") _MOONRAKER_CONF = ( "[server]\nhost: 0.0.0.0\n\n[mmu_server]\nenable_file_preprocessor: True\n" ) @@ -115,9 +117,14 @@ def hh_home(tmp_path: Path) -> Path: def _run_hh_hook( - tmp_path: Path, home: Path, prev: str = "", new: str = "" + tmp_path: Path, + home: Path, + prev: str = "", + new: str = "", + hook: Path = HH_HOOK, + **extra: str, ) -> subprocess.CompletedProcess: - """Run the Happy-Hare hook with a stub sudo that records its argv.""" + """Run a Happy-Hare/Moonraker hook with a stub sudo that records its argv.""" stub_bin = tmp_path / "stub-bin" stub_bin.mkdir(exist_ok=True) sudo = stub_bin / "sudo" @@ -129,9 +136,10 @@ def _run_hh_hook( "COMPONENT_PATH": str(home / "Happy-Hare"), "PREV_HASH": prev, "NEW_HASH": new, + **extra, } return subprocess.run( - ["bash", str(HH_HOOK)], env=env, capture_output=True, text=True, check=False + ["bash", str(hook)], env=env, capture_output=True, text=True, check=False ) @@ -153,9 +161,61 @@ def test_provision_links_excludes_and_restarts(self, tmp_path, hh_home): assert "/moonraker/components/mmu_server.py" in _exclude(hh_home) assert _git(hh_home / "moonraker", "status", "--porcelain") == "" assert _sudo_calls(tmp_path) == [ - "systemctl restart --no-block moonraker.service" + "-n systemctl --no-block restart moonraker.service" ] + def test_restart_argv_matches_sudoers_rule(self, tmp_path, hh_home): + _run_hh_hook(tmp_path, hh_home) + root = HOOK.parents[2] + rule = re.search( + r"NOPASSWD: /usr/bin/(systemctl --no-block restart) %s", + (root / "scripts" / "install-updater.sh").read_text(), + ) + assert rule is not None + manifest = (root / "updater" / "components.yaml").read_text() + assert re.search(r"^\s*service: moonraker\.service$", manifest, re.M) + assert _sudo_calls(tmp_path) == [f"-n {rule[1]} moonraker.service"] + + def test_pending_moonraker_restart_skips_restart(self, tmp_path, hh_home): + result = _run_hh_hook( + tmp_path, + hh_home, + BS_UPDATER_PENDING_RESTARTS="klipper.service moonraker.service", + ) + assert result.returncode == 0, result.stderr + assert _sudo_calls(tmp_path) == [] + + def test_other_pending_restarts_still_restart(self, tmp_path, hh_home): + result = _run_hh_hook( + tmp_path, hh_home, BS_UPDATER_PENDING_RESTARTS="klipper.service" + ) + assert result.returncode == 0, result.stderr + assert len(_sudo_calls(tmp_path)) == 1 + + def test_link_only_skips_restart(self, tmp_path, hh_home): + result = _run_hh_hook(tmp_path, hh_home, HH_LINK_ONLY="1") + assert result.returncode == 0, result.stderr + assert ( + hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + ).is_symlink() + assert _sudo_calls(tmp_path) == [] + + @_NOT_ROOT + def test_unwritable_exclude_warns_and_links(self, tmp_path, hh_home): + info = hh_home / "moonraker" / ".git" / "info" + info.mkdir(exist_ok=True) + (info / "exclude").write_text("*.pyc\n") + (info / "exclude").chmod(0o444) + try: + result = _run_hh_hook(tmp_path, hh_home) + finally: + (info / "exclude").chmod(0o644) + assert result.returncode == 0, result.stderr + assert "WARN" in result.stdout + assert ( + hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + ).is_symlink() + def test_unchanged_update_is_idempotent(self, tmp_path, hh_home): _run_hh_hook(tmp_path, hh_home) head = _git(hh_home / "Happy-Hare", "rev-parse", "HEAD") @@ -210,6 +270,68 @@ def test_missing_source_fails(self, tmp_path, hh_home): assert result.returncode == 1 +MR_HOOK = HOOK.with_name("moonraker.sh") +_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + + +class TestMoonrakerHook: + """A Moonraker reclone must get the mmu_server link back.""" + + def _run(self, tmp_path: Path, home: Path, prev: str = "abc"): + return _run_hh_hook( + tmp_path, + home, + prev, + hook=MR_HOOK, + COMPONENT_PATH=str(home / "moonraker"), + ) + + def _dst(self, home: Path) -> Path: + return home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + + def test_update_relinks_without_restart(self, tmp_path, hh_home): + result = self._run(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert self._dst(hh_home).is_symlink() + assert "/moonraker/components/mmu_server.py" in _exclude(hh_home) + assert _sudo_calls(tmp_path) == [] + + def test_boot_reclone_relinks_and_restarts(self, tmp_path, hh_home): + result = self._run(tmp_path, hh_home, _EMPTY_TREE) + assert result.returncode == 0, result.stderr + assert self._dst(hh_home).is_symlink() + assert _sudo_calls(tmp_path) == [ + "-n systemctl --no-block restart moonraker.service" + ] + + def test_intact_link_restores_exclude_without_restart(self, tmp_path, hh_home): + _run_hh_hook(tmp_path, hh_home) + exclude = hh_home / "moonraker" / ".git" / "info" / "exclude" + exclude.write_text("") + result = self._run(tmp_path, hh_home, _EMPTY_TREE) + assert result.returncode == 0, result.stderr + assert self._dst(hh_home).is_symlink() + assert "/moonraker/components/mmu_server.py" in _exclude(hh_home) + assert len(_sudo_calls(tmp_path)) == 1 + + def test_no_happy_hare_skips(self, tmp_path, hh_home): + subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True) + result = self._run(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert not self._dst(hh_home).exists() + + @_NOT_ROOT + def test_relink_failure_never_fails_hook(self, tmp_path, hh_home): + components = self._dst(hh_home).parent + components.chmod(0o555) + try: + result = self._run(tmp_path, hh_home) + finally: + components.chmod(0o755) + assert result.returncode == 0, result.stderr + assert "WARN" in result.stdout + + UI_HOOK = HOOK.with_name("BlocksScreen.sh") diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 3e2a27d4..4a95ce4c 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -924,6 +924,28 @@ async def test_shared_service_restarted_once(self, tmp_path): assert cb.on_component_done.call_count == 3 assert all(c.args[1] is True for c in cb.on_component_done.call_args_list) + @pytest.mark.asyncio + async def test_hooks_get_pending_restarts(self, tmp_path): + svc, comps = self._svc(tmp_path, MagicMock(), n=2, service="moonraker.service") + with ( + patch( + "updater.service.UpdateService._stage_component", + return_value=(True, ""), + ), + patch( + "updater.service.UpdateService._install_dependencies", + return_value=(True, ""), + ), + patch("updater.service.run_hook", return_value=(True, "")) as hook, + patch("updater.service.restart_service", return_value=(True, "")), + patch("updater.service.wait_for_service_active", return_value=True), + ): + await svc._run_git_batch(comps) + assert hook.call_count == 2 + for call in hook.call_args_list: + pending = call.kwargs["extra_env"]["BS_UPDATER_PENDING_RESTARTS"] + assert pending == "moonraker.service" + @pytest.mark.asyncio async def test_inflight_marker_written_then_cleared_on_success(self, tmp_path): cb = MagicMock() @@ -2445,7 +2467,6 @@ async def targets(_self, _base): @pytest.mark.asyncio async def test_crash_mid_update_still_counts_the_try(self, svc, env): - # The try is recorded before update_all, so a crash or reboot cannot loop it. self._mark(svc) env.side_effect = RuntimeError("boom") with ( @@ -2466,7 +2487,7 @@ async def test_failed_update_retries_then_leaves_it_to_update(self, svc, env): UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) ): results = [await svc.run_followup() for _ in range(tries)] - assert results == [(True, False)] * (tries - 1) + [(False, True)] + assert results == [(True, True)] * (tries - 1) + [(False, True)] assert env.await_count == tries assert not svc._followup_path.exists() @@ -2477,7 +2498,7 @@ async def test_retry_after_failure_succeeds_and_clears(self, svc, env): with patch.object( UpdateService, "_followup_targets", AsyncMock(return_value={"Spoolman"}) ): - assert await svc.run_followup() == (True, False) + assert await svc.run_followup() == (True, True) assert await svc.run_followup() == (False, True) assert not svc._followup_path.exists() diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 95a98016..251a832e 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -343,7 +343,8 @@ async def _followup_pass(self) -> None: _log.error("followup pass failed: %s", exc, exc_info=True) retry, ran = False, False self._followed_up = not retry - if not ran: + # A failed try that will retry sweeps apt once the follow-up settles. + if not ran or retry: return # The restart that led here skipped the Update's background apt sweep. if not self._svc.daemon_restart_pending: diff --git a/updater/executor.py b/updater/executor.py index aad41f2d..fa361aa0 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -976,6 +976,7 @@ async def run_hook( new_hash: str, prev_hash: str, timeout: float = 60.0, + extra_env: dict[str, str] | None = None, ) -> tuple[bool, str]: """Run the component's update hook if present, refusing paths outside hooks/.""" hook = (_HOOKS_DIR / f"{name}.sh").resolve() @@ -990,6 +991,7 @@ async def run_hook( env = _make_clean_env() env.update( { + **(extra_env or {}), "COMPONENT_NAME": name, "COMPONENT_PATH": str(path) if path else "", "NEW_HASH": new_hash, diff --git a/updater/hooks/Happy-Hare.sh b/updater/hooks/Happy-Hare.sh index c9a9e87b..69e0eff0 100755 --- a/updater/hooks/Happy-Hare.sh +++ b/updater/hooks/Happy-Hare.sh @@ -32,13 +32,26 @@ fi # Moonraker reports untracked .py files as a repo anomaly; info/exclude is never committed or cleaned. _exclude=$(git -C "$_moonraker" rev-parse --path-format=absolute --git-path info/exclude 2>/dev/null || true) _pattern="/moonraker/components/mmu_server.py" -if [ -n "$_exclude" ] && ! grep -qxF "$_pattern" "$_exclude" 2>/dev/null; then - mkdir -p "$(dirname "$_exclude")" +_add_exclude() { + mkdir -p "$(dirname "$_exclude")" || return 1 if [ -s "$_exclude" ] && [ -n "$(tail -c1 "$_exclude")" ]; then - echo >>"$_exclude" + echo >>"$_exclude" || return 1 fi echo "$_pattern" >>"$_exclude" - echo "[hook:Happy-Hare] added $_pattern to $_exclude" +} +# Cosmetic only, so a failure must not fail the hook: a failed provision deletes the checkout under the link. +if [ -n "$_exclude" ] && ! grep -qxF "$_pattern" "$_exclude" 2>/dev/null; then + if _add_exclude; then + echo "[hook:Happy-Hare] added $_pattern to $_exclude" + else + echo "[hook:Happy-Hare] WARN: could not write $_exclude - Moonraker may report its repo dirty" + fi +fi + +if [ -n "${HH_LINK_ONLY:-}" ] \ + || [[ " ${BS_UPDATER_PENDING_RESTARTS:-} " == *" moonraker.service "* ]]; then + echo "[hook:Happy-Hare] link only - caller restarts Moonraker" + exit 0 fi if ! $_linked && [ -n "${PREV_HASH:-}" ] && [ -n "${NEW_HASH:-}" ] \ @@ -56,7 +69,8 @@ if ! grep -q '^\[mmu_server\]' "$_conf" 2>/dev/null; then fi echo "[hook:Happy-Hare] restarting moonraker.service to load mmu_server" -sudo systemctl restart --no-block moonraker.service 2>/dev/null || { +# Argv order must match the install-updater.sh sudoers rule exactly. +sudo -n systemctl --no-block restart moonraker.service 2>/dev/null || { echo "[hook:Happy-Hare] WARN: could not restart moonraker.service - loads on next restart" } echo "[hook:Happy-Hare] done" diff --git a/updater/hooks/moonraker.sh b/updater/hooks/moonraker.sh new file mode 100755 index 00000000..14678117 --- /dev/null +++ b/updater/hooks/moonraker.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# A Moonraker reclone drops the mmu_server.py link or its info/exclude line, and Happy-Hare's hook only runs on HH changes. +set -uo pipefail + +if [ -z "${COMPONENT_PATH:-}" ]; then + echo "[hook:moonraker] no COMPONENT_PATH" + exit 0 +fi + +_hh="$(dirname "$COMPONENT_PATH")/Happy-Hare" +_src="$_hh/components/mmu_server.py" +_dst="$COMPONENT_PATH/moonraker/components/mmu_server.py" +if [ ! -f "$_src" ]; then + echo "[hook:moonraker] no Happy-Hare checkout - nothing to link" + exit 0 +fi + +# An update restarts moonraker.service after this hook; a boot reclone (empty-tree PREV_HASH) does not. +_link_only=1 +if [ "${PREV_HASH:-}" = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" ] \ + && [ "$(readlink "$_dst" 2>/dev/null || true)" != "$_src" ]; then + _link_only= +fi +# Never fail: a failed hook rolls back the Moonraker update over an optional link. +COMPONENT_PATH="$_hh" PREV_HASH="" NEW_HASH="" HH_LINK_ONLY="$_link_only" \ + bash "$(dirname "$0")/Happy-Hare.sh" || echo "[hook:moonraker] WARN: relinking mmu_server failed" +exit 0 diff --git a/updater/service.py b/updater/service.py index 55c944cf..256b5776 100644 --- a/updater/service.py +++ b/updater/service.py @@ -619,7 +619,7 @@ async def run_followup( # An unrecorded try may not be retried: its count never advances. if recorded and tries + 1 < _FOLLOWUP_TRIES: self._log.warning("followup: update failed, retrying next poll") - return True, False + return True, True self._log.warning("followup: update failed, leaving it to Update") await asyncio.to_thread(self._clear_followup) finally: @@ -836,6 +836,12 @@ async def _batch_hook_phase( new_hashes[c.name], prev[c.name], timeout=HOOK_TIMEOUT, + # Hooks leave these to the verified restart phase. + extra_env={ + "BS_UPDATER_PENDING_RESTARTS": " ".join( + sorted({a.service for a in alive if a.service}) + ) + }, ), c.name, 3, From 6027343b1b1cef927cbc5842472a5935c1f8f4ab Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 23 Oct 2026 18:40:59 +0100 Subject: [PATCH 5/5] fix(updater): defer provision restarts to the batch and skip HH restart mid-print --- tests/updater/test_hooks_unit.py | 22 ++++++ tests/updater/test_service_unit.py | 119 ++++++++++++++++++++++++++++- updater/hooks/Happy-Hare.sh | 5 ++ updater/hooks/moonraker.sh | 5 ++ updater/service.py | 32 +++++++- 5 files changed, 176 insertions(+), 7 deletions(-) diff --git a/tests/updater/test_hooks_unit.py b/tests/updater/test_hooks_unit.py index 46aa7ac2..fe9ced87 100644 --- a/tests/updater/test_hooks_unit.py +++ b/tests/updater/test_hooks_unit.py @@ -200,6 +200,14 @@ def test_link_only_skips_restart(self, tmp_path, hh_home): ).is_symlink() assert _sudo_calls(tmp_path) == [] + def test_printing_links_without_restart(self, tmp_path, hh_home): + result = _run_hh_hook(tmp_path, hh_home, BS_UPDATER_PRINTING="1") + assert result.returncode == 0, result.stderr + assert ( + hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py" + ).is_symlink() + assert _sudo_calls(tmp_path) == [] + @_NOT_ROOT def test_unwritable_exclude_warns_and_links(self, tmp_path, hh_home): info = hh_home / "moonraker" / ".git" / "info" @@ -320,6 +328,20 @@ def test_no_happy_hare_skips(self, tmp_path, hh_home): assert result.returncode == 0, result.stderr assert not self._dst(hh_home).exists() + def test_removes_link_left_by_failed_provision(self, tmp_path, hh_home): + _run_hh_hook(tmp_path, hh_home) + subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True) + result = self._run(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert not self._dst(hh_home).is_symlink() + + def test_keeps_foreign_link_without_happy_hare(self, tmp_path, hh_home): + subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True) + self._dst(hh_home).symlink_to(tmp_path / "elsewhere.py") + result = self._run(tmp_path, hh_home) + assert result.returncode == 0, result.stderr + assert self._dst(hh_home).is_symlink() + @_NOT_ROOT def test_relink_failure_never_fails_hook(self, tmp_path, hh_home): components = self._dst(hh_home).parent diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 4a95ce4c..7f7405be 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -808,7 +808,7 @@ async def test_provision_runs_before_git_batch(self, tmp_path): ), patch( "updater.service.UpdateService._provision_component", - side_effect=lambda _c: seen.append("provision"), + side_effect=lambda _c, _p: seen.append("provision"), ), ): svc = UpdateService() @@ -816,6 +816,62 @@ async def test_provision_runs_before_git_batch(self, tmp_path): await svc.update_all() assert seen == ["provision", "batch"] + def _provision_case(self, tmp_path: Path) -> list[ComponentConfig]: + moonraker = self._git(tmp_path, "moonraker", 3) + moonraker.service = "moonraker.service" + ui = self._git(tmp_path, "BlocksScreen", 99) + ui.service = "BlocksScreen.service" + hh = ComponentConfig( + name="Happy-Hare", + kind="git", + path=tmp_path / "absent", + order=4, + url="https://github.com/x/y", + install_if_missing=True, + ) + return [moonraker, ui, hh] + + @pytest.mark.asyncio + async def test_provision_defers_batch_restarts_and_batch_does_them(self, tmp_path): + svc = UpdateService() + + async def batch(_b): + svc._verified_restarts.add("moonraker.service") + return True + + with ( + patch("updater.service.UpdateService._preflight_fetch", return_value=set()), + patch("updater.service.UpdateService._run_git_batch", side_effect=batch), + patch( + "updater.service.UpdateService._provision_component", + return_value=True, + ) as mock_provision, + patch("updater.service.UpdateService._restart_one") as mock_restart, + ): + svc._components = self._provision_case(tmp_path) + await svc.update_all() + # The fire-and-forget UI restart is never verified, so it is never pending. + assert mock_provision.call_args[0][1] == "moonraker.service" + mock_restart.assert_not_called() + + @pytest.mark.asyncio + async def test_provision_restarts_what_a_failed_batch_skipped(self, tmp_path): + with ( + patch("updater.service.UpdateService._preflight_fetch", return_value=set()), + patch("updater.service.UpdateService._run_git_batch", return_value=False), + patch( + "updater.service.UpdateService._provision_component", + return_value=True, + ), + patch( + "updater.service.UpdateService._restart_one", return_value=True + ) as mock_restart, + ): + svc = UpdateService() + svc._components = self._provision_case(tmp_path) + await svc.update_all() + mock_restart.assert_awaited_once_with("moonraker.service") + @pytest.mark.asyncio async def test_empty_batch_skips_git(self, tmp_path): with ( @@ -942,10 +998,50 @@ async def test_hooks_get_pending_restarts(self, tmp_path): ): await svc._run_git_batch(comps) assert hook.call_count == 2 - for call in hook.call_args_list: - pending = call.kwargs["extra_env"]["BS_UPDATER_PENDING_RESTARTS"] + for hook_call in hook.call_args_list: + pending = hook_call.kwargs["extra_env"]["BS_UPDATER_PENDING_RESTARTS"] assert pending == "moonraker.service" + @pytest.mark.asyncio + async def test_dropped_component_leaves_pending_restarts(self, tmp_path): + """A Happy-Hare hook after a failed Moonraker hook must restart Moonraker itself.""" + comps = [ + self._git(tmp_path, "klipper", 2, "klipper.service"), + self._git(tmp_path, "moonraker", 3, "moonraker.service"), + self._git(tmp_path, "Happy-Hare", 4, "klipper.service"), + ] + svc = UpdateService(callback=MagicMock()) + svc._components = comps + svc._state_path = tmp_path / "state.json" + with ( + patch( + "updater.service.UpdateService._stage_component", + return_value=(True, ""), + ), + patch( + "updater.service.UpdateService._install_dependencies", + return_value=(True, ""), + ), + patch("updater.service.git_reset_to_hash", return_value=(True, "")), + patch( + "updater.service.run_hook", + side_effect=[(True, ""), (False, "boom"), (True, "")], + ) as hook, + patch("updater.service.restart_service", return_value=(True, "")), + patch("updater.service.wait_for_service_active", return_value=True), + patch.object(UpdateService, "_apply_deferred_restart", new=AsyncMock()), + ): + await svc._run_git_batch(comps) + pending = [ + c.kwargs["extra_env"]["BS_UPDATER_PENDING_RESTARTS"] + for c in hook.call_args_list + ] + assert pending == [ + "klipper.service moonraker.service", + "klipper.service moonraker.service", + "klipper.service", + ] + @pytest.mark.asyncio async def test_inflight_marker_written_then_cleared_on_success(self, tmp_path): cb = MagicMock() @@ -1805,6 +1901,9 @@ async def test_missing_opted_in_routes_to_provision(self, tmp_path): from updater.service import _GIT_EMPTY_TREE assert mock_hook.call_args[0][3] == _GIT_EMPTY_TREE + assert mock_hook.call_args.kwargs["extra_env"] == { + "BS_UPDATER_PENDING_RESTARTS": "" + } cb.on_component_done.assert_called_once_with("newcomp", True) @pytest.mark.asyncio @@ -2903,6 +3002,18 @@ async def test_crashing_heal_does_not_rerun(self): await svc.reconcile_if_pending() mock_heal.assert_awaited_once() + @pytest.mark.asyncio + @pytest.mark.parametrize(("printing", "flag"), [(True, "1"), (False, "")]) + async def test_reclone_hook_flags_running_print(self, tmp_path, printing, flag): + comp = self._git_comp(tmp_path) + with ( + patch("updater.service.git_get_hash", return_value="a" * 40), + patch("updater.service.klipper_printing", return_value=printing), + patch("updater.service.run_hook", return_value=(True, "")) as hook, + ): + await UpdateService()._boot_reclone_hook(comp) + assert hook.call_args.kwargs["extra_env"] == {"BS_UPDATER_PRINTING": flag} + class TestWriteStateDurability: """_write_state fsyncs file + parent dir (Major 4).""" @@ -3543,7 +3654,7 @@ async def test_update_all_quarantines_installable_and_provisions(self, tmp_path) svc._history_path = tmp_path / "history.jsonl" svc._components = [comp] await svc.update_all() - mock_prov.assert_awaited_once_with(comp) + mock_prov.assert_awaited_once_with(comp, "") cb.on_error.assert_not_called() assert not comp.path.exists() # moved aside moved = list(tmp_path.glob("Spoolman.pre-updater-*")) diff --git a/updater/hooks/Happy-Hare.sh b/updater/hooks/Happy-Hare.sh index 69e0eff0..c6f82f20 100755 --- a/updater/hooks/Happy-Hare.sh +++ b/updater/hooks/Happy-Hare.sh @@ -13,6 +13,7 @@ if [ ! -f "$_src" ]; then exit 1 fi +# RF50 layout: Happy-Hare, moonraker and printer_data are siblings in one home. _home=$(dirname "$COMPONENT_PATH") _moonraker="$_home/moonraker" _components="$_moonraker/moonraker/components" @@ -53,6 +54,10 @@ if [ -n "${HH_LINK_ONLY:-}" ] \ echo "[hook:Happy-Hare] link only - caller restarts Moonraker" exit 0 fi +if [ -n "${BS_UPDATER_PRINTING:-}" ]; then + echo "[hook:Happy-Hare] print running - mmu_server loads on the next Moonraker restart" + exit 0 +fi if ! $_linked && [ -n "${PREV_HASH:-}" ] && [ -n "${NEW_HASH:-}" ] \ && git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ diff --git a/updater/hooks/moonraker.sh b/updater/hooks/moonraker.sh index 14678117..501f9581 100755 --- a/updater/hooks/moonraker.sh +++ b/updater/hooks/moonraker.sh @@ -7,10 +7,15 @@ if [ -z "${COMPONENT_PATH:-}" ]; then exit 0 fi +# RF50 layout: Happy-Hare and moonraker are siblings in one home. _hh="$(dirname "$COMPONENT_PATH")/Happy-Hare" _src="$_hh/components/mmu_server.py" _dst="$COMPONENT_PATH/moonraker/components/mmu_server.py" if [ ! -f "$_src" ]; then + # A failed Happy-Hare provision removes the checkout but leaves its link behind. + if [ -L "$_dst" ] && [ "$(readlink "$_dst")" = "$_src" ]; then + rm -f "$_dst" && echo "[hook:moonraker] removed dangling $_dst" + fi echo "[hook:moonraker] no Happy-Hare checkout - nothing to link" exit 0 fi diff --git a/updater/service.py b/updater/service.py index 256b5776..692dab89 100644 --- a/updater/service.py +++ b/updater/service.py @@ -271,6 +271,7 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._reconciled = False self._offline_provision_tries = 0 self._offline_followup_tries = 0 + self._verified_restarts: set[str] = set() @property def daemon_restart_pending(self) -> bool: @@ -435,10 +436,24 @@ async def _run_update_phases( ok = True for c in apt: ok = await self._run_apt_update(c) and ok + # Provision hooks leave these to the batch, which restarts each one once. + pending = sorted( + { + c.service + for c in batch + if c.service and c.service not in _FIRE_AND_FORGET_SERVICES + } + ) + self._verified_restarts.clear() for c in provision: - ok = await self._provision_component(c) and ok + ok = await self._provision_component(c, " ".join(pending)) and ok if batch: ok = await self._run_git_batch(batch) and ok + if provision: + # A batch that dropped or aborted never restarted these for the provision. + for svc in pending: + if svc not in self._verified_restarts: + ok = await self._restart_one(svc) and ok return ok def _check_disk_space(self, components: list[ComponentConfig]) -> bool: @@ -1691,8 +1706,15 @@ async def _boot_reclone_hook(self, c: ComponentConfig) -> None: return try: new_hash = await git_get_hash(c.path) + # A daemon restart can land mid-print; hooks then skip service restarts. + printing = "1" if await klipper_printing() else "" hook_ok, hook_err = await run_hook( - c.name, c.path, new_hash, _GIT_EMPTY_TREE, timeout=HOOK_TIMEOUT + c.name, + c.path, + new_hash, + _GIT_EMPTY_TREE, + timeout=HOOK_TIMEOUT, + extra_env={"BS_UPDATER_PRINTING": printing}, ) if not hook_ok: self._log.warning("%s: post-reclone hook failed: %s", c.name, hook_err) @@ -1965,7 +1987,9 @@ async def _provision_restart_service( ) return None - async def _provision_component(self, component: ComponentConfig) -> bool: + async def _provision_component( + self, component: ComponentConfig, pending_restarts: str = "" + ) -> bool: """Clone and set up a missing opted-in component.""" if not component.url: return self._cb_error_done(component.name, "no clone url") @@ -2001,6 +2025,7 @@ async def _provision_component(self, component: ComponentConfig) -> bool: new_hash, _GIT_EMPTY_TREE, timeout=HOOK_TIMEOUT, + extra_env={"BS_UPDATER_PENDING_RESTARTS": pending_restarts}, ), component.name, 3, @@ -2248,6 +2273,7 @@ async def _restart_one(self, service: str, health_url: str | None = None) -> boo self._log.error("%s active but health check failed", service) return False self._log.info("%s active after restart", service) + self._verified_restarts.add(service) return True async def _preflight_git_update(self, component: ComponentConfig) -> bool | None: