From 176f5c4fc36372bfcd527e22f3e4f88c5450f1cd Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 14:47:26 +0100 Subject: [PATCH 1/4] fix(updater): hold update overlay across install-updater and daemon restart --- BlocksScreen/lib/panels/mainWindow.py | 3 + .../panels/widgets/MainWindow/updatePage.py | 17 +++ BlocksScreen/lib/updater_worker.py | 7 +- tests/lib/test_updater_worker_unit.py | 18 +++ tests/updater/conftest.py | 3 + tests/updater/test_dbus_service_unit.py | 124 +++++++++++++++++- tests/widgets/test_update_page_unit.py | 32 ++++- updater/dbus_service.py | 46 +++++++ updater/service.py | 7 + 9 files changed, 251 insertions(+), 6 deletions(-) diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 802c9dc9..c059e046 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -300,6 +300,9 @@ def __init__(self): self.updater_worker.daemon_unavailable.connect( self.update_page.handle_daemon_unavailable ) + self.updater_worker.daemon_restarting.connect( + self.update_page.handle_daemon_restarting + ) self.controlPanel.lock_ui.connect(self.set_ui_lock) self.updater_worker.request_reconnect.connect(self._on_post_update_reconnect) self.updater_worker.proxy_connected.connect( diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index ea8b8c4c..c57336c8 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -375,6 +375,10 @@ def handle_busy_changed(self, busy: bool) -> None: if busy: if self._provisioning: self._show_provisioning_overlay() + elif not self._overlay_shown: + # Busy this UI did not start: it restarted mid-update, block it too. + self._overlay_shown = True + self.call_load_panel.emit(True, "Update in progress ...", False) self._restart_pending = False self._restart_grace_timer.stop() self._stale_overlay_timer.stop() @@ -409,11 +413,24 @@ def handle_provisioning_changed(self, provisioning: bool) -> None: if provisioning and self._busy: self._cancel_btn.hide() self._show_provisioning_overlay() + elif self._busy: + # Still busy after the clone: the daemon holds it for install-updater. + self._show_finishing_overlay() def _show_provisioning_overlay(self) -> None: self._overlay_shown = True self.call_load_panel.emit(True, "Missing component, installing ...", False) + def _show_finishing_overlay(self) -> None: + self._overlay_shown = True + self.call_load_panel.emit(True, "Finishing installation ...", False) + + @QtCore.pyqtSlot(name="handle-daemon-restarting") + def handle_daemon_restarting(self) -> None: + """Daemon left mid-busy to restart: keep the overlay until the new one reports.""" + self._cancel_btn.hide() + self._show_finishing_overlay() + def _dismiss_stale_overlay(self) -> None: """Drop the overlay if the post-update status never arrived.""" if self._overlay_shown and not self._busy: diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index 9091963e..42a7e0e1 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -53,6 +53,7 @@ class UpdaterWorker(QtCore.QObject): busy_changed = QtCore.pyqtSignal(bool) provisioning_changed = QtCore.pyqtSignal(bool) daemon_unavailable = QtCore.pyqtSignal() + daemon_restarting = QtCore.pyqtSignal() update_rejected = QtCore.pyqtSignal() request_reconnect = QtCore.pyqtSignal() proxy_connected = QtCore.pyqtSignal() @@ -337,7 +338,11 @@ async def _watch_daemon_owner(self) -> None: self._daemon_owner = "" # Retry bus-activates a stopped unit; only mask keeps it off. _log.error("updater daemon left the bus - awaiting restart") - self.daemon_unavailable.emit() + # Busy exit = install-updater restarting it; a failed retry still reports. + if self._last_busy: + self.daemon_restarting.emit() + else: + self.daemon_unavailable.emit() self._schedule_reconnect() continue _log.warning( diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index e68b8e69..2d0fc35a 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -273,6 +273,24 @@ async def test_owner_lost_emits_unavailable_and_schedules_reconnect( worker._async_initialize.assert_not_awaited() worker._schedule_reconnect.assert_called_once_with() + @pytest.mark.asyncio + async def test_owner_lost_while_busy_emits_restarting(self, worker): + """install-updater restarts a busy daemon: the overlay must stay, not drop.""" + restarting, unavailable = [], [] + worker.daemon_restarting.connect(lambda: restarting.append(True)) + worker.daemon_unavailable.connect(lambda: unavailable.append(True)) + worker._last_busy = True + worker._async_initialize = AsyncMock() + worker._schedule_reconnect = MagicMock() + _, dbus = self._subscribe( + worker, [(_BUS, ":1.5", ""), self._stop(worker)], owner=":1.5" + ) + with dbus: + await worker._watch_daemon_owner() + assert restarting == [True] + assert unavailable == [] + worker._schedule_reconnect.assert_called_once_with() + @pytest.mark.asyncio async def test_other_names_and_repeat_owner_ignored(self, worker): worker._async_initialize = AsyncMock() diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index 2532ef84..1f0d7405 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -60,6 +60,7 @@ def svc(): mock_svc.provision_missing = AsyncMock(return_value=False) mock_svc.reconcile = AsyncMock(return_value=True) mock_svc.reconcile_if_pending = AsyncMock() + mock_svc.deploy_pending = MagicMock(return_value=False) mock_svc._components = [ ComponentConfig(name="moonraker", kind="git"), ComponentConfig(name="klipper", kind="git"), @@ -76,6 +77,8 @@ def svc(): s._busy = False s._provisioning = False s._provisioned = False + s._boot_pass_done = True + s._deploy_hold = None s._background_tasks = set() s._closing = False s._status_check_in_progress = False diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 5db63514..95e9cae1 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -352,21 +352,137 @@ async def test_periodic_check_never_lengthens_a_short_poll_interval(self, svc): class TestBootNotBusy: - def test_idle_at_construction(self): - """Offline boots must not open on the install overlay: busy waits for a real clone.""" + @staticmethod + def _build(deploy_pending: bool): from updater import dbus_service + mock_svc = MagicMock() + mock_svc.deploy_pending = MagicMock(return_value=deploy_pending) with ( - patch.object(dbus_service, "UpdateService", return_value=MagicMock()), + patch.object(dbus_service, "UpdateService", return_value=mock_svc), patch.object( dbus_service.UpdaterDbusService, "_spawn", MagicMock(side_effect=lambda coro, **_: coro.close()), ), ): - built = dbus_service.UpdaterDbusService() + return dbus_service.UpdaterDbusService() + + def test_idle_at_construction(self): + """Offline boots must not open on the install overlay: busy waits for a real clone.""" + built = self._build(deploy_pending=False) assert (built._busy, built._provisioning) == (False, False) + def test_busy_at_construction_while_deploy_runs(self): + """install-updater restarts the daemon mid-run: the new one resumes the hold.""" + built = self._build(deploy_pending=True) + assert built._busy is True + assert built._boot_pass_done is False + + +class TestDeployHold: + @pytest.mark.asyncio + async def test_busy_held_until_flag_clears(self, svc, monkeypatch): + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._busy = True + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + await asyncio.sleep(0.05) + assert svc._busy is True + svc.busy_changed.emit.assert_not_called() + + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + assert svc._busy is False + svc.busy_changed.emit.assert_called_once_with((False,)) + assert svc._deploy_hold is None + + @pytest.mark.asyncio + async def test_hold_released_at_cap(self, svc, monkeypatch): + """A flag nothing clears must not lock the screen forever.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + monkeypatch.setattr(dbus_service, "_DEPLOY_HOLD_CAP_S", 0.03) + svc._busy = True + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + await asyncio.sleep(0.1) + assert svc._busy is False + + @pytest.mark.asyncio + async def test_hold_spawned_once(self, svc, monkeypatch): + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + first = svc._deploy_hold + svc._set_busy(False) + assert svc._deploy_hold is first + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + + @pytest.mark.asyncio + async def test_hold_end_leaves_running_provision_busy(self, svc, monkeypatch): + """A clone that started under the hold drops busy itself when it finishes.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + svc._provision_busy(True) + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + assert svc._busy is True + svc._provision_busy(False) + assert svc._busy is False + + @pytest.mark.asyncio + async def test_update_rejected_during_hold(self, svc): + """install-updater restarts moonraker: no new batch may start under it.""" + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + assert await svc.update_all() is False + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_cancel_keeps_hold(self, svc): + svc._busy = True + svc._svc.deploy_pending.return_value = True + await svc.cancel() + assert svc._busy is True + svc._svc.deploy_pending.return_value = False + + +class TestStatusAfterBootPass: + @pytest.mark.asyncio + async def test_request_status_deferred_until_boot_pass(self, svc): + """A status before provisioning would let the UI drop its overlay mid-install.""" + svc._boot_pass_done = False + await svc.request_status() + await asyncio.sleep(0) + svc.status_ready.emit.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.usefixtures("reconciled") + async def test_boot_pass_enables_request_status(self, svc, monkeypatch): + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_BOOT_DELAY_S", 0.0) + svc._boot_pass_done = False + svc._svc.poll_interval = 3600.0 + task = asyncio.create_task(svc._periodic_status_check()) + await asyncio.sleep(0.05) + assert svc._boot_pass_done is True + svc.status_ready.emit.assert_called_once() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + @pytest.mark.usefixtures("reconciled") class TestBootProvision: diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 15292bda..848e3131 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -555,10 +555,40 @@ def test_provisioning_after_busy_still_shows_message(self, page, qtbot): def test_undeclared_busy_is_not_an_install(self, page, qtbot): page.show_loading = MagicMock() - with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_busy_changed(True) + assert blocker.args == [True, "Update in progress ...", False] assert page._provisioning is False + def test_busy_with_own_overlay_keeps_its_message(self, page, qtbot): + page.show_loading = MagicMock() + page._overlay_shown = True + with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + page.handle_busy_changed(True) + + def test_provision_end_while_busy_shows_finishing(self, page, qtbot): + page.show_loading = MagicMock() + page.handle_provisioning_changed(True) + page.handle_busy_changed(True) + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_provisioning_changed(False) + assert blocker.args == [True, "Finishing installation ...", False] + + def test_provision_end_while_idle_emits_nothing(self, page, qtbot): + page.show_loading = MagicMock() + with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + page.handle_provisioning_changed(False) + + def test_daemon_restarting_keeps_overlay_and_hides_cancel(self, page, qtbot): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + page._cancel_btn.reset_mock() + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_daemon_restarting() + assert blocker.args == [True, "Finishing installation ...", False] + assert page._overlay_shown is True + page._cancel_btn.hide.assert_called_once() + def test_provision_steps_name_the_component(self, page, qtbot): page.show_loading = MagicMock() page.handle_provisioning_changed(True) diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 7b8071e5..cf31c87d 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -22,6 +22,9 @@ _RECONCILE_RETRY_S = 5.0 _BOOT_DELAY_S = 3.0 _SHUTDOWN_DRAIN_S = 60.0 # < systemd's 90s stop timeout +_DEPLOY_POLL_S = 1.0 +# Failed runs clear the flag via ExecStopPost; only a hung one reaches this (~40s typical). +_DEPLOY_HOLD_CAP_S = 180.0 class DbusProgressCallback: @@ -112,6 +115,9 @@ def __init__(self) -> None: self._busy: bool = False self._provisioning: bool = False self._provisioned: bool = False + # request_status waits for the boot pass, so a client never sees pre-install status. + self._boot_pass_done: bool = False + self._deploy_hold: asyncio.Task | None = None self._background_tasks: set[asyncio.Task] = set() self._closing: bool = False self._status_check_in_progress: bool = False @@ -124,6 +130,11 @@ def __init__(self) -> None: self._spawn(self._periodic_status_check(), name="periodic_status_check") self._spawn(self._svc.supervise_ui(), name="supervise_ui") self._spawn(self._svc.forward_heal_ui(), name="forward_heal_ui") + # install-updater restarts us before its run ends: resume the hold. + # Silent: not exported yet, clients read it via get_busy on connect. + if self._svc.deploy_pending(): + self._busy = True + self._deploy_hold = self._spawn(self._hold_for_deploy(), name="deploy_hold") def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task: """Create a task and hold a strong reference so GC cannot cancel it.""" @@ -187,12 +198,42 @@ def _set_provisioning(self, provisioning: bool) -> None: self.provisioning_changed.emit((provisioning,)) def _set_busy(self, busy: bool) -> None: + """Busy stays up while install-updater.sh runs out-of-band, so the UI keeps its overlay.""" + if not busy and self._svc.deploy_pending(): + if self._deploy_hold is None: + self._deploy_hold = self._spawn( + self._hold_for_deploy(), name="deploy_hold" + ) + busy = True + self._emit_busy(busy) + + def _emit_busy(self, busy: bool) -> None: """Emit busy_changed only on state transitions to avoid redundant signals.""" if busy != self._busy: self._busy = busy _log.info("busy_changed -> %s", busy) self.busy_changed.emit((busy,)) + async def _hold_for_deploy(self) -> None: + """Poll the deploy flag; release busy when install-updater ends or the cap hits.""" + _log.info("deploy flag set - holding busy until install-updater finishes") + loop = asyncio.get_running_loop() + deadline = loop.time() + _DEPLOY_HOLD_CAP_S + try: + while self._svc.deploy_pending(): + if loop.time() >= deadline: + _log.warning( + "deploy flag still set after %.0fs - releasing busy", + _DEPLOY_HOLD_CAP_S, + ) + break + await asyncio.sleep(_DEPLOY_POLL_S) + finally: + self._deploy_hold = None + # A provisioning pass that started under the hold still owns busy. + if not self._provisioning: + self._emit_busy(busy=False) + def _validate_component_name(self, name: str) -> bool: """SEC: verify component exists to prevent abuse on unknown names.""" is_valid = self._svc.has_component(name) @@ -256,9 +297,11 @@ async def _periodic_status_check(self) -> None: deferred = await self._svc.provision_missing(self._provision_busy) self._provisioned = not deferred _log.info("provisioning pass done (deferred=%s)", deferred) + self._boot_pass_done = True await self._emit_status() except Exception as exc: # noqa: BLE001 _log.error("periodic_check failed: %s", exc) + self._boot_pass_done = True interval = self._svc.poll_interval if self._svc.has_fetch_failures(): interval = min(_FETCH_RETRY_INTERVAL_S, interval) @@ -380,6 +423,9 @@ async def _run_recover(self, name: str, hard: bool) -> None: async def request_status(self) -> None: """D-Bus method: reply immediately, status arrives via status_ready signal.""" _log.info("request_status called") + if not self._boot_pass_done: + _log.info("request_status: boot pass pending, its status follows") + return self._spawn(self._emit_status(force=True), name="request_status") @sdbus.dbus_method_async(result_signature="b") diff --git a/updater/service.py b/updater/service.py index e88ca72c..d974fca7 100644 --- a/updater/service.py +++ b/updater/service.py @@ -178,6 +178,7 @@ def reset(self) -> None: # Watched by BlocksScreen-deploy.path: install-updater.sh runs in its own cgroup. _DEPLOY_FLAG = Path.home() / ".config" / "blockscreen" / ".run-install-updater" +_DEPLOY_PATH_UNIT = Path("/etc/systemd/system/BlocksScreen-deploy.path") class ProgressCallback(Protocol): @@ -1095,6 +1096,12 @@ def _touch_deploy_flag(self) -> None: _DEPLOY_FLAG.touch() self._fsync_dir(_DEPLOY_FLAG.parent) + @staticmethod + def deploy_pending() -> bool: + """True while install-updater.sh is queued or running; its ExecStopPost clears the flag.""" + # Without the path unit nothing ever clears the flag (bs-deploy-check.sh test). + return _DEPLOY_FLAG.exists() and _DEPLOY_PATH_UNIT.exists() + async def recover(self, name: str, hard: bool = False) -> bool: """Reset a component to its last known-good hash.""" component = next((c for c in self._components if c.name == name), None) From e0f140daf7969bb168bdef2eecd551d1f173c6d0 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 14:47:35 +0100 Subject: [PATCH 2/4] test(updater): add end-to-end integration suite --- Makefile | 7 +- tests/integration/updater/conftest.py | 477 ++++++++++++++++++ .../updater/test_updater_daemon_flow.py | 177 +++++++ .../updater/test_updater_git_flow.py | 333 ++++++++++++ 4 files changed, 992 insertions(+), 2 deletions(-) create mode 100644 tests/integration/updater/conftest.py create mode 100644 tests/integration/updater/test_updater_daemon_flow.py create mode 100644 tests/integration/updater/test_updater_git_flow.py diff --git a/Makefile b/Makefile index d5dc51f9..2d0ca8f3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all init init-dev venv run lint format-check security check \ - test test-all test-unit test-network test-ui test-integration test-fast \ + test test-all test-unit test-network test-ui test-integration test-integration-nm test-fast \ coverage coverage-all coverage-network clean clean-venv \ docstrcov rcc rcc-all pr help @@ -121,7 +121,10 @@ test-ui: ## UI tests only (*_ui.py) test-%: ## Tests all files inside the // $(PYTHON) -m pytest $(PYTEST_FLAGS) $(PYTEST_IGNORE) $(TESTS)/$*/ -test-integration: ## D-Bus integration tests (requires live NetworkManager) +test-integration: ## End-to-end suites in tests/integration// (hermetic, no hardware) + $(PYTHON) -m pytest $(PYTEST_FLAGS) $(TESTS)/integration/ + +test-integration-nm: ## D-Bus integration tests (requires live NetworkManager) $(NM_INTEGRATION) $(PYTHON) -m pytest $(PYTEST_FLAGS) $(TESTS)/*/*_integration.py test-all: ## All tests including D-Bus integration (requires NetworkManager) diff --git a/tests/integration/updater/conftest.py b/tests/integration/updater/conftest.py new file mode 100644 index 00000000..16ad1732 --- /dev/null +++ b/tests/integration/updater/conftest.py @@ -0,0 +1,477 @@ +"""Updater integration harness: real git, state files, locking and D-Bus layer. + +Only the root-owned edges are faked (systemctl, sudo, apt, dpkg, pip, klippy.sock). +Remotes are local bare repos reached through an https:// insteadOf rewrite, so +clone/fetch/ls-remote run for real against URLs that pass the https-only checks. +""" + +import asyncio +import json +import os +import shutil +import subprocess +import sys +from collections.abc import Mapping +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +REMOTE_BASE = "https://git.test/" + + +class _FakeDbusBase: + """Real Python base class so inheritance does not raise TypeError.""" + + def __init__(self, *a, **kw) -> None: + pass + + def __init_subclass__(cls, **kw) -> None: + super().__init_subclass__() + + +@pytest.fixture(scope="module", autouse=True) +def mock_sdbus(): + """Swap sdbus for a stub and force a fresh updater.dbus_service import.""" + mock = MagicMock() + mock.DbusInterfaceCommonAsync = _FakeDbusBase + mock.dbus_signal_async = lambda *a, **kw: lambda fn: fn + mock.dbus_method_async = lambda *a, **kw: lambda fn: fn + with pytest.MonkeyPatch.context() as mp: + for key in ("sdbus", "updater", "updater.dbus_service"): + mp.delitem(sys.modules, key, raising=False) + mp.setitem(sys.modules, "sdbus", mock) + yield mock + + +def _script(path: Path, body: str) -> Path: + """Write an executable bash script.""" + path.write_text("#!/bin/bash\n" + body) + path.chmod(0o755) + return path + + +_SYSTEMCTL = """\ +echo "systemctl $*" >>"{state}/calls.log" +unit="${{!#}}" +case "$1" in + is-active) [ -e "{state}/inactive-$unit" ] && exit 3; exit 0 ;; + show) + if [ "$2" = "--property=LoadState" ]; then + if [ -e "{state}/missing-$unit" ]; then echo not-found; else echo loaded; fi + else + echo 0 + fi + exit 0 ;; + restart) + if [ -e "{state}/fail-restart-$unit" ]; then + echo "Job for $unit failed" >&2 + exit 1 + fi + exit 0 ;; + *) exit 0 ;; +esac +""" + +# Only our fakes run as "root": a hook's bare `sudo cp` into /etc is denied, as on a box. +_SUDO = """\ +echo "sudo $*" >>"{state}/calls.log" +case "$1" in + "{bin}"/*) exec "$@" ;; + *) echo "sudo: not allowed" >&2; exit 1 ;; +esac +""" + +_APT = """\ +echo "apt $*" >>"{state}/calls.log" +if [ "$1" = "list" ]; then + echo "Listing..." + [ -e "{state}/upgradable" ] && cat "{state}/upgradable" +fi +exit 0 +""" + +_LOGGED = 'echo "{name} $*" >>"{state}/calls.log"\nexit 0\n' + + +class Signal: + """Stand-in for an sdbus signal: records every emit in a shared log.""" + + def __init__(self, name: str, log: list) -> None: + self._name = name + self._log = log + + def emit(self, args: tuple) -> None: + """Record (signal name, args).""" + self._log.append((self._name, args)) + + +class Recorder: + """ProgressCallback that keeps every call, in order.""" + + def __init__(self) -> None: + self.events: list[tuple] = [] + + def on_step(self, name: str, step: int, total: int) -> None: + """Record a progress step.""" + self.events.append(("on_step", name, step, total)) + + def on_component_done(self, name: str, success: bool) -> None: + """Record a component outcome.""" + self.events.append(("on_component_done", name, success)) + + def on_error(self, name: str, reason: str) -> None: + """Record an error.""" + self.events.append(("on_error", name, reason)) + + def on_rollback(self, name: str, success: bool) -> None: + """Record a rollback.""" + self.events.append(("on_rollback", name, success)) + + def on_recover(self, name: str, success: bool) -> None: + """Record a recover.""" + self.events.append(("on_recover", name, success)) + + def of(self, method: str) -> list[tuple]: + """Arguments of every call to one callback method.""" + return [e[1:] for e in self.events if e[0] == method] + + +class Remote: + """A bare origin plus a scratch worktree used to author upstream commits.""" + + def __init__(self, harness: "Harness", name: str) -> None: + self.name = name + self.url = f"{REMOTE_BASE}{name}.git" + self._h = harness + self.bare = harness.remotes / f"{name}.git" + self.work = harness.root / "authoring" / name + harness.git("init", "-q", "--bare", "--initial-branch=master", str(self.bare)) + harness.git("init", "-q", "--initial-branch=master", str(self.work)) + harness.git("remote", "add", "origin", str(self.bare), cwd=self.work) + + def commit( + self, + files: Mapping[str, str | None], + msg: str = "change", + branch: str = "master", + ) -> str: + """Write (or delete, for None) files, commit on branch, push; return the sha.""" + h = self._h + # symbolic-ref works on the unborn initial branch, where switch would fail. + if h.git("symbolic-ref", "--short", "HEAD", cwd=self.work) != branch: + known = h.git("branch", "--format=%(refname:short)", cwd=self.work) + create = [] if branch in known.splitlines() else ["-c"] + h.git("switch", "-q", *create, branch, cwd=self.work) + for rel, content in files.items(): + target = self.work / rel + if content is None: + target.unlink() + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + h.git("add", "-A", cwd=self.work) + h.git("commit", "-q", "-m", msg, cwd=self.work) + h.git("push", "-q", "origin", f"HEAD:refs/heads/{branch}", cwd=self.work) + return h.git("rev-parse", "HEAD", cwd=self.work) + + +class Harness: + """One isolated device: tmp HOME, fake root tools, origins and components.""" + + def __init__(self, root: Path, home: Path, mp: pytest.MonkeyPatch) -> None: + self.root = root + self.home = home + self.remotes = root / "remotes" + self.state = root / "fakeroot" + self.bin = root / "bin" + self.hooks = root / "hooks" + self.run_dir = root / "run" + self.etc = root / "etc" + for d in (self.remotes, self.state, self.bin, self.hooks, self.etc): + d.mkdir(parents=True) + self.run_dir.mkdir(mode=0o700) + self.components: list[dict] = [] + self.printing = AsyncMock(return_value=False) + self.apt_upgrade = AsyncMock() + self.dbus_g: dict = {} + self.mp = mp + + # -- git ----------------------------------------------------------------- + + def git(self, *args: str, cwd: Path | None = None) -> str: + """Run git as the test author; return stripped stdout.""" + out = subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ) + return out.stdout.strip() + + def remote(self, name: str, files: dict[str, str] | None = None) -> Remote: + """Create an origin with one initial commit.""" + r = Remote(self, name) + r.commit(files or {"README": f"{name}\n"}, msg="initial") + return r + + def install(self, remote: Remote, branch: str = "master") -> Path: + """Clone the origin into HOME over its https URL, as the installer does.""" + path = self.home / remote.name + self.git("clone", "-q", "--branch", branch, remote.url, str(path)) + return path + + def head(self, name: str) -> str: + """HEAD sha of the installed component.""" + return self.git("rev-parse", "HEAD", cwd=self.home / name) + + def branch(self, name: str) -> str: + """Checked-out branch of the installed component.""" + return self.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.home / name) + + # -- components.yaml ----------------------------------------------------- + + def component(self, name: str, **fields: object) -> dict: + """Register a git component pointing at HOME/ and its origin.""" + entry = { + "name": name, + "type": "git", + "path": str(self.home / name), + "url": f"{REMOTE_BASE}{name}.git", + "branch": "master", + "reset_mode": "hard", + "order": 10 + len(self.components), + } | fields + self.components.append(entry) + return entry + + # -- hooks, fakes, observations ------------------------------------------ + + def hook(self, name: str, body: str) -> None: + """Install a post-update hook for a component.""" + _script(self.hooks / f"{name}.sh", body) + + def real_hook(self, name: str) -> None: + """Install the shipped hook for a component.""" + dst = self.hooks / f"{name}.sh" + shutil.copy(REPO_ROOT / "updater" / "hooks" / f"{name}.sh", dst) + dst.chmod(0o755) + + def venv_pip(self, name: str, body: str = "exit 0\n") -> Path: + """Give a component a logging venv pip (the sibling .-env layout).""" + pip_dir = self.home / f".{name}-env" / "bin" + pip_dir.mkdir(parents=True) + log = f'echo "venv-pip $*" >>"{self.state}/calls.log"\n' + return _script(pip_dir / "pip", log + body) + + def fail_restart(self, unit: str) -> None: + """Make `systemctl restart ` fail.""" + (self.state / f"fail-restart-{unit}").touch() + + def calls(self) -> list[str]: + """Every fake root-tool invocation, in order.""" + log = self.state / "calls.log" + return log.read_text().splitlines() if log.exists() else [] + + def history(self) -> list[dict]: + """Parsed update_history.jsonl.""" + path = self.home / ".cache" / "blockscreen" / "update_history.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + def events(self, component: str) -> list[str]: + """History event names for one component.""" + return [e["event"] for e in self.history() if e["component"] == component] + + def state_file(self) -> dict: + """Parsed updater_state.json.""" + return json.loads( + (self.home / ".cache/blockscreen/updater_state.json").read_text() + ) + + @property + def inflight(self) -> Path: + """The in-flight batch marker.""" + return self.home / ".cache" / "blockscreen" / "updater_inflight.json" + + @property + def deploy_flag(self) -> Path: + """The flag BlocksScreen-deploy.path watches.""" + return self.home / ".config" / "blockscreen" / ".run-install-updater" + + @property + def sentinel(self) -> Path: + """The deferred-restart sentinel a self-update hook writes.""" + return self.run_dir / "updater-restart-needed" + + def service(self): + """A fresh UpdateService over the registered components.""" + import updater.dbus_service as dbus_mod + + recorder = Recorder() + svc = dbus_mod.UpdateService(callback=recorder) + return svc, recorder + + +def _write_fakes(h: Harness) -> None: + """Drop the fake root tools into h.bin.""" + fmt = {"state": h.state, "bin": h.bin} + _script(h.bin / "systemctl", _SYSTEMCTL.format(**fmt)) + _script(h.bin / "sudo", _SUDO.format(**fmt)) + _script(h.bin / "apt", _APT.format(**fmt)) + for tool in ("apt-mark", "dpkg", "bs-apt-helper", "pip3"): + _script(h.bin / tool, _LOGGED.format(name=tool, **fmt)) + # The shipped hook's daemon-reload fallback must not reach the host bus. + _script(h.bin / "dbus-send", "exit 1\n") + + +def _patch(mp: pytest.MonkeyPatch, g: dict, **values: object) -> None: + """Patch module globals, failing loudly if a name was renamed upstream.""" + for key, value in values.items(): + assert key in g, f"harness patches a missing global {key!r}" + mp.setitem(g, key, value) + + +@pytest.fixture +def harness(tmp_path, monkeypatch, mock_sdbus) -> Harness: + """Isolated device with every updater path and root tool redirected.""" + import updater.dbus_service as dbus_mod + + root = tmp_path.resolve() + home = root / "home" + home.mkdir() + h = Harness(root, home, monkeypatch) + _write_fakes(h) + + for var in ("XDG_CONFIG_HOME", "GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG_GLOBAL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("PATH", f"{h.bin}{os.pathsep}{os.environ['PATH']}") + (home / ".gitconfig").write_text( + "[user]\n\tname = Integration\n\temail = integration@example.invalid\n" + "[commit]\n\tgpgsign = false\n" + "[init]\n\tdefaultBranch = master\n" + "[advice]\n\tdetachedHead = false\n" + f'[url "file://{h.remotes}/"]\n\tinsteadOf = {REMOTE_BASE}\n' + ) + + # sdbus mock re-imports updater; resolve the live modules through the class. + svc_g = dbus_mod.UpdateService.__init__.__globals__ + exec_g = svc_g["git_fetch"].__globals__ + lock_g = exec_g["restart_sentinel_path"].__globals__ + comp_g = svc_g["load_components"].__globals__ + cache = home / ".cache" / "blockscreen" + deploy_unit = h.etc / "BlocksScreen-deploy.path" + deploy_unit.touch() + + _patch( + monkeypatch, + svc_g, + _STATE_PATH=cache / "updater_state.json", + _INFLIGHT_PATH=cache / "updater_inflight.json", + _FAULT_MARKER_PATH=cache / "selfheal_fault.json", + _HISTORY_PATH=cache / "update_history.jsonl", + _APT_STATUS_CACHE=cache / "apt_status_cache.json", + _DEPLOY_FLAG=h.deploy_flag, + _DEPLOY_PATH_UNIT=deploy_unit, + PIP=str(h.bin / "pip3"), + klipper_printing=h.printing, + load_components=lambda: (comp_g["_build_configs"](h.components), 86_400.0), + ) + + async def _configured_origin(path: Path) -> str: + # get-url expands insteadOf to file://; the raw config keeps the https URL. + ok, out = await exec_g["_run"]( + [exec_g["GIT"], "config", "--get", "remote.origin.url"], + cwd=path, + timeout=10.0, + ) + return out.strip() if ok else "" + + _patch( + monkeypatch, + exec_g, + SUDO=str(h.bin / "sudo"), + SYSTEMCTL=str(h.bin / "systemctl"), + APT=str(h.bin / "apt"), + APT_MARK=str(h.bin / "apt-mark"), + DPKG=str(h.bin / "dpkg"), + APT_HELPER=h.bin / "bs-apt-helper", + PIP=str(h.bin / "pip3"), + KLIPPY_SOCK=root / "no-klippy.sock", + _HOOKS_DIR=h.hooks, + git_remote_url=_configured_origin, + ) + _patch(monkeypatch, lock_g, _runtime_dir=lambda: h.run_dir) + h.dbus_g = dbus_mod.UpdaterDbusService.__init__.__globals__ + _patch( + monkeypatch, + h.dbus_g, + _STATUS_PATH=h.run_dir / "updater_status.json", + _BOOT_DELAY_S=0.0, + _DEPLOY_POLL_S=0.05, + ) + # Self-heal supervisors poll systemd on minute timers: out of scope here. + for method in ("supervise_ui", "forward_heal_ui", "background_prime_nrestarts"): + monkeypatch.setattr(dbus_mod.UpdateService, method, AsyncMock()) + # Recorded, not run: the daemon must skip it when its own restart is pending. + monkeypatch.setattr(dbus_mod.UpdateService, "background_apt_upgrade", h.apt_upgrade) + return h + + +class Daemon: + """A live UpdaterDbusService plus its recorded signals.""" + + def __init__(self, iface) -> None: + self.iface = iface + self.log: list[tuple[str, tuple]] = [] + for name in ( + "step_complete", + "component_done", + "error", + "rollback", + "recover_done", + "status_ready", + "busy_changed", + "provisioning_changed", + ): + setattr(iface, name, Signal(name, self.log)) + + def signals(self, name: str) -> list[tuple]: + """Args of every emit of one signal.""" + return [args for sig, args in self.log if sig == name] + + def busy_trace(self) -> list[bool]: + """Every busy_changed value, in order.""" + return [args[0] for args in self.signals("busy_changed")] + + def statuses(self) -> list[dict]: + """Every status_ready payload, decoded.""" + return [json.loads(args[0]) for args in self.signals("status_ready")] + + +@pytest.fixture +async def daemon(harness): + """Factory for live daemons; each is shut down at teardown.""" + import updater.dbus_service as dbus_mod + + started: list[Daemon] = [] + + def start() -> Daemon: + d = Daemon(dbus_mod.UpdaterDbusService()) + started.append(d) + return d + + yield start + for d in started: + await d.iface.shutdown() + + +async def wait_for(predicate, timeout: float = 15.0, what: str = "condition") -> None: + """Poll predicate until true; fail with `what` on timeout.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + if loop.time() >= deadline: + pytest.fail(f"timed out waiting for {what}") + await asyncio.sleep(0.02) diff --git a/tests/integration/updater/test_updater_daemon_flow.py b/tests/integration/updater/test_updater_daemon_flow.py new file mode 100644 index 00000000..2405f305 --- /dev/null +++ b/tests/integration/updater/test_updater_daemon_flow.py @@ -0,0 +1,177 @@ +"""UpdaterDbusService end to end: busy/overlay contract, boot pass, deploy hold, cancel.""" + +import asyncio + +from conftest import wait_for + +UI_FILES = { + "updater/dbus_service.py": "# marker\n", + "scripts/install-updater.sh": "v1\n", +} + + +def _idle(d) -> bool: + """No update/recover task is still running on the daemon.""" + return not any( + t.get_name().startswith(("update_", "recover_")) and not t.done() + for t in d.iface._background_tasks + ) + + +async def _booted(daemon): + """Start a daemon and wait for its first status emit.""" + d = daemon() + await wait_for(lambda: d.statuses(), what="boot status") + return d + + +async def test_update_all_round_trip(harness, daemon): + origin = harness.remote("klipper") + harness.install(origin) + new = origin.commit({"klippy.py": "v2\n"}) + harness.component("klipper", service="klipper.service") + d = await _booted(daemon) + assert d.statuses()[0]["klipper"]["commits_behind"] == 1 + + assert await d.iface.update_all() is True + assert await d.iface.update_all() is False # busy rejects a second run + await wait_for(lambda: d.busy_trace() == [True, False], what="busy release") + + assert harness.head("klipper") == new + steps = [a for a in d.signals("step_complete") if a[0] == "klipper"] + assert steps == [("klipper", n, 4) for n in (1, 2, 3, 4)] + assert d.signals("component_done") == [("klipper", True)] + await wait_for(lambda: harness.apt_upgrade.await_count == 1, what="apt upgrade") + + seen = len(d.statuses()) + await d.iface.request_status() + await wait_for(lambda: len(d.statuses()) > seen, what="fresh status") + assert d.statuses()[-1]["klipper"]["commits_behind"] == 0 + + +async def test_nothing_dirty_still_releases_busy(harness, daemon): + harness.install(harness.remote("klipper")) + harness.component("klipper", service="klipper.service") + d = await _booted(daemon) + + assert await d.iface.update_all() is True + await wait_for(lambda: d.busy_trace() == [True, False], what="busy release") + + assert d.signals("step_complete") == [] + + +async def test_failed_update_releases_busy_and_reports(harness, daemon): + origin = harness.remote("klipper") + harness.install(origin) + old = harness.head("klipper") + origin.commit({"klippy.py": "v2\n"}) + harness.component("klipper", service="klipper.service") + harness.hook("klipper", "exit 1\n") + d = await _booted(daemon) + + assert await d.iface.update_all() is True + await wait_for(lambda: d.busy_trace() == [True, False], what="busy release") + + assert harness.head("klipper") == old + assert d.signals("error") == [("klipper", "hook")] + assert d.signals("rollback") == [("klipper", True)] + assert d.signals("component_done") == [("klipper", False)] + + +async def test_install_update_holds_overlay_until_deploy_finishes(harness, daemon): + origin = harness.remote("BlocksScreen", UI_FILES) + harness.install(origin) + origin.commit({"scripts/install-updater.sh": "v2\n"}) + harness.component("BlocksScreen", service="BlocksScreen.service") + harness.real_hook("BlocksScreen") + d = await _booted(daemon) + + assert await d.iface.update_all() is True + await wait_for(harness.deploy_flag.exists, what="deploy flag") + await wait_for(lambda: _idle(d), what="batch end") + await asyncio.sleep(0.3) # several hold polls + + # Batch done, install-updater still running: the overlay must not drop. + assert d.busy_trace() == [True] + assert await d.iface.get_busy() is True + assert await d.iface.update_all() is False + harness.apt_upgrade.assert_not_awaited() + + harness.deploy_flag.unlink() # install-updater's ExecStopPost + await wait_for(lambda: d.busy_trace() == [True, False], what="hold release") + + +async def test_deploy_hold_is_capped(harness, daemon): + harness.mp.setitem(harness.dbus_g, "_DEPLOY_HOLD_CAP_S", 0.3) + harness.deploy_flag.parent.mkdir(parents=True) + harness.deploy_flag.touch() # a hung install-updater never clears it + + d = daemon() + assert await d.iface.get_busy() is True + + await wait_for(lambda: d.busy_trace() == [False], timeout=5.0, what="cap") + assert harness.deploy_flag.exists() + + +async def test_restarted_daemon_resumes_hold_silently(harness, daemon): + harness.deploy_flag.parent.mkdir(parents=True) + harness.deploy_flag.touch() # install-updater restarted us mid-run + + d = daemon() + # Not exported yet at __init__: clients read it via get_busy, no signal. + assert d.busy_trace() == [] + assert await d.iface.get_busy() is True + + harness.deploy_flag.unlink() + await wait_for(lambda: d.busy_trace() == [False], what="hold release") + + +async def test_boot_provisioning_raises_overlay(harness, daemon): + origin = harness.remote("klipper") + harness.component("klipper", service="klipper.service", install_if_missing=True) + + d = await _booted(daemon) + + assert [a[0] for a in d.signals("provisioning_changed")] == [True, False] + assert d.busy_trace() == [True, False] + assert await d.iface.get_provisioning() is False + assert harness.head("klipper") == harness.git("rev-parse", "HEAD", cwd=origin.bare) + assert d.statuses()[0]["klipper"]["commits_behind"] == 0 + + +async def test_boot_reconcile_reverts_power_cut(harness, daemon): + origin = harness.remote("klipper") + harness.install(origin) + old = harness.head("klipper") + origin.commit({"klippy.py": "v2\n"}) + harness.git("pull", "-q", cwd=harness.home / "klipper") + harness.inflight.parent.mkdir(parents=True, exist_ok=True) + harness.inflight.write_text(f'{{"klipper": "{old}"}}') + harness.component("klipper", service="klipper.service") + + await _booted(daemon) + + assert harness.head("klipper") == old + assert "boot_rollback" in harness.events("klipper") + assert not harness.inflight.exists() + + +async def test_cancel_mid_hook_reverts_and_releases(harness, daemon): + origin = harness.remote("klipper") + harness.install(origin) + old = harness.head("klipper") + origin.commit({"klippy.py": "v2\n"}) + harness.component("klipper", service="klipper.service") + harness.hook("klipper", "sleep 20\n") + d = await _booted(daemon) + + assert await d.iface.update_all() is True + await wait_for( + lambda: ("klipper", 3, 4) in d.signals("step_complete"), what="hook start" + ) + await d.iface.cancel() + + assert harness.head("klipper") == old + assert d.signals("rollback") == [("klipper", True)] + assert d.busy_trace() == [True, False] + assert not harness.inflight.exists() diff --git a/tests/integration/updater/test_updater_git_flow.py b/tests/integration/updater/test_updater_git_flow.py new file mode 100644 index 00000000..919e3bf7 --- /dev/null +++ b/tests/integration/updater/test_updater_git_flow.py @@ -0,0 +1,333 @@ +"""UpdateService end to end against real git repos: update, rollback, heal, install.""" + +UI_FILES = { + "updater/dbus_service.py": "# marker\n", + "scripts/install-updater.sh": "v1\n", +} + + +def _klipper(harness, **fields: object): + """Installed klipper one commit behind its origin; returns (origin, old, new).""" + origin = harness.remote("klipper") + harness.install(origin) + old = harness.head("klipper") + new = origin.commit({"klippy.py": "v2\n"}) + harness.component("klipper", service="klipper.service", **fields) + return origin, old, new + + +def _rollbacks(harness, name: str) -> list[dict]: + return [ + e + for e in harness.history() + if e["component"] == name and e["event"] == "rollback" + ] + + +async def test_update_moves_to_origin_tip_and_restarts(harness): + _, old, new = _klipper(harness) + harness.venv_pip("klipper") + svc, rec = harness.service() + + assert await svc.update_all({"klipper"}) is True + + assert harness.head("klipper") == new + assert harness.state_file()["klipper"]["prev_hash"] == old + assert harness.events("klipper") == ["update_start", "update_success"] + assert not harness.inflight.exists() + assert [s[1] for s in rec.of("on_step")] == [1, 2, 3, 4] + assert rec.of("on_component_done") == [("klipper", True)] + calls = harness.calls() + assert "systemctl restart klipper.service" in calls + assert "systemctl is-active klipper.service" in calls + # No requirements.txt: the venv is found but pip is never asked to install. + assert not [c for c in calls if c.startswith("venv-pip")] + + +async def test_deps_install_runs_component_venv_pip(harness): + origin, _, _ = _klipper(harness) + req = origin.commit({"requirements.txt": "pyserial\n"}) + harness.venv_pip("klipper") + svc, _ = harness.service() + + assert await svc.update_all({"klipper"}) is True + + assert harness.head("klipper") == req + req_path = harness.home / "klipper" / "requirements.txt" + assert f"venv-pip install -r {req_path} --quiet" in harness.calls() + + +async def test_hook_failure_reverts(harness): + _, old, _ = _klipper(harness) + harness.hook("klipper", "exit 3\n") + svc, rec = harness.service() + + assert await svc.update_all({"klipper"}) is False + + assert harness.head("klipper") == old + [rb] = _rollbacks(harness, "klipper") + assert (rb["reason"], rb["reverted_to"], rb["ok"]) == ("hook", old[:12], True) + assert rec.of("on_error") == [("klipper", "hook")] + assert rec.of("on_rollback") == [("klipper", True)] + assert "systemctl restart klipper.service" not in harness.calls() + assert not harness.inflight.exists() + + +async def test_hook_receives_hashes(harness): + _, old, new = _klipper(harness) + out = harness.root / "hook.env" + harness.hook( + "klipper", + f'echo "$COMPONENT_NAME $PREV_HASH $NEW_HASH $COMPONENT_PATH" >"{out}"\n', + ) + svc, _ = harness.service() + + assert await svc.update_all({"klipper"}) is True + + assert out.read_text().split() == [ + "klipper", + old, + new, + str(harness.home / "klipper"), + ] + + +async def test_restart_failure_reverts(harness): + _, old, _ = _klipper(harness) + harness.fail_restart("klipper.service") + svc, rec = harness.service() + + assert await svc.update_all({"klipper"}) is False + + assert harness.head("klipper") == old + assert [rb["reason"] for rb in _rollbacks(harness, "klipper")] == ["restart"] + assert rec.of("on_component_done") == [("klipper", False)] + + +async def test_deps_failure_reverts(harness): + origin, old, _ = _klipper(harness) + origin.commit({"requirements.txt": "broken-dep\n"}) + harness.venv_pip("klipper", 'case "$*" in *"-r "*) exit 1 ;; esac\nexit 0\n') + svc, _ = harness.service() + + assert await svc.update_all({"klipper"}) is False + + assert harness.head("klipper") == old + assert [rb["reason"] for rb in _rollbacks(harness, "klipper")] == ["deps"] + + +async def test_one_failure_does_not_block_the_rest(harness): + _, k_old, _ = _klipper(harness) + moon = harness.remote("moonraker") + harness.install(moon) + m_new = moon.commit({"moonraker.py": "v2\n"}) + harness.component("moonraker", service="moonraker.service") + harness.hook("klipper", "exit 1\n") + svc, rec = harness.service() + + assert await svc.update_all({"klipper", "moonraker"}) is False + + assert harness.head("klipper") == k_old + assert harness.head("moonraker") == m_new + assert sorted(rec.of("on_component_done")) == [ + ("klipper", False), + ("moonraker", True), + ] + assert harness.events("moonraker") == ["update_start", "update_success"] + + +async def test_dead_branch_is_skipped_untouched(harness): + origin = harness.remote("klipper") + harness.install(origin) + old = harness.head("klipper") + harness.component("klipper", service="klipper.service", branch="gone") + svc, rec = harness.service() + + assert await svc.update_all({"klipper"}) is False + + assert harness.head("klipper") == old + assert rec.of("on_error") == [ + ("klipper", "branch origin/gone not found - fix components.yaml") + ] + assert harness.history() == [] + + +async def test_branch_switch_follows_components_yaml(harness): + origin = harness.remote("klipper") + harness.install(origin) + feature = origin.commit({"feature.py": "x\n"}, branch="feature") + harness.component("klipper", service="klipper.service", branch="feature") + svc, _ = harness.service() + + assert await svc.update_all({"klipper"}) is True + + assert harness.branch("klipper") == "feature" + assert harness.head("klipper") == feature + + +async def test_hard_reset_discards_local_edits(harness): + _, _, new = _klipper(harness) + (harness.home / "klipper" / "README").write_text("edited on the printer\n") + svc, _ = harness.service() + + status = await svc.check_status(force=True) + assert status["klipper"].has_local_changes + assert status["klipper"].commits_behind == 1 + assert await svc.update_all({"klipper"}) is True + + assert harness.head("klipper") == new + assert (harness.home / "klipper" / "README").read_text() == "klipper\n" + + +async def test_non_https_remote_is_refused(harness): + origin, old, _ = _klipper(harness) + harness.git( + "remote", "set-url", "origin", str(origin.bare), cwd=harness.home / "klipper" + ) + svc, rec = harness.service() + + assert await svc.update_all({"klipper"}) is False + + assert harness.head("klipper") == old + assert rec.of("on_error") == [("klipper", "insecure remote")] + assert not harness.inflight.exists() + + +async def test_ui_tip_without_updater_is_refused(harness): + origin = harness.remote("BlocksScreen", UI_FILES) + harness.install(origin) + old = harness.head("BlocksScreen") + origin.commit({"updater/dbus_service.py": None}, msg="drop updater") + harness.component("BlocksScreen", service="BlocksScreen.service") + svc, rec = harness.service() + + assert await svc.update_all({"BlocksScreen"}) is False + + assert harness.head("BlocksScreen") == old + assert rec.of("on_error") == [("BlocksScreen", "refusing downgrade past updater")] + + +async def test_install_file_change_hands_off_to_deploy(harness): + origin = harness.remote("BlocksScreen", UI_FILES) + harness.install(origin) + new = origin.commit({"scripts/install-updater.sh": "v2\n"}) + harness.component("BlocksScreen", service="BlocksScreen.service") + harness.real_hook("BlocksScreen") + svc, _ = harness.service() + + assert await svc.update_all({"BlocksScreen"}) is True + + assert harness.head("BlocksScreen") == new + assert harness.deploy_flag.exists() + assert not harness.sentinel.exists() + assert svc.deploy_pending() + assert svc.daemon_restart_pending + calls = harness.calls() + assert "systemctl --no-block restart BlocksScreen.service" in calls + # The UI is fire-and-forget: never a blocking restart of our own screen. + assert "systemctl restart BlocksScreen.service" not in calls + + +async def test_plain_ui_change_sets_no_deploy_flag(harness): + origin = harness.remote("BlocksScreen", UI_FILES) + harness.install(origin) + origin.commit({"README": "docs\n"}) + harness.component("BlocksScreen", service="BlocksScreen.service") + harness.real_hook("BlocksScreen") + svc, _ = harness.service() + + assert await svc.update_all({"BlocksScreen"}) is True + + assert not harness.deploy_flag.exists() + assert not svc.daemon_restart_pending + assert "systemctl --no-block restart BlocksScreen.service" in harness.calls() + + +async def test_reconcile_reverts_power_cut_batch(harness): + _, old, new = _klipper(harness) + path = harness.home / "klipper" + # Power cut mid-batch: the tree moved, the marker still names the rollback point. + harness.git("pull", "-q", cwd=path) + assert harness.head("klipper") == new + harness.inflight.parent.mkdir(parents=True, exist_ok=True) + harness.inflight.write_text(f'{{"klipper": "{old}"}}') + svc, _ = harness.service() + + assert await svc.reconcile() is True + + assert harness.head("klipper") == old + assert "boot_rollback" in harness.events("klipper") + assert not harness.inflight.exists() + + +async def test_recover_returns_to_prev_hash(harness): + _, old, new = _klipper(harness) + svc, rec = harness.service() + assert await svc.update_all({"klipper"}) is True + assert harness.head("klipper") == new + restarts_before = harness.calls().count("systemctl restart klipper.service") + + assert await svc.recover("klipper", hard=True) is True + + assert harness.head("klipper") == old + assert rec.of("on_recover") == [("klipper", True)] + assert ( + harness.calls().count("systemctl restart klipper.service") + == restarts_before + 1 + ) + + +async def test_single_component_update_and_rollback(harness): + _, old, new = _klipper(harness) + svc, _ = harness.service() + assert await svc.update_component("klipper") is True + assert harness.head("klipper") == new + assert not harness.inflight.exists() + + harness.hook("klipper", "exit 1\n") + harness.git("reset", "-q", "--hard", old, cwd=harness.home / "klipper") + svc2, rec = harness.service() + + assert await svc2.update_component("klipper") is False + + assert harness.head("klipper") == old + assert rec.of("on_rollback") == [("klipper", True)] + + +async def test_provision_missing_clones_and_enables(harness): + origin = harness.remote("klipper") + harness.component("klipper", service="klipper.service", install_if_missing=True) + svc, rec = harness.service() + busy: list[bool] = [] + + assert await svc.provision_missing(busy.append) is False + + assert busy == [True, False] + assert harness.head("klipper") == harness.git("rev-parse", "HEAD", cwd=origin.bare) + assert harness.events("klipper") == ["install_start", "install_success"] + assert rec.of("on_component_done") == [("klipper", True)] + assert "systemctl enable klipper.service" in harness.calls() + + +async def test_provision_waits_while_printing(harness): + harness.remote("klipper") + harness.component("klipper", install_if_missing=True) + harness.printing.return_value = True + svc, _ = harness.service() + busy: list[bool] = [] + + assert await svc.provision_missing(busy.append) is True + + assert busy == [] + assert not (harness.home / "klipper").exists() + + +async def test_provision_offline_never_raises_busy(harness): + harness.component("klipper", install_if_missing=True) # no origin exists + svc, _ = harness.service() + busy: list[bool] = [] + + assert await svc.provision_missing(busy.append) is True + + assert busy == [] + assert not (harness.home / "klipper").exists() From 4b396cc6d5ae8d09f4fa8bab4bbc227573a91cf1 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 17:24:07 +0100 Subject: [PATCH 3/4] fix(updater): follow deploy unit state for the overlay hold, 15 min cap --- tests/integration/updater/conftest.py | 7 +++ .../updater/test_updater_daemon_flow.py | 20 +++++++ tests/updater/conftest.py | 4 ++ tests/updater/test_dbus_service_unit.py | 52 +++++++++++++++++++ tests/updater/test_service_unit.py | 44 ++++++++++++++++ tests/widgets/test_update_page_unit.py | 2 +- updater/dbus_service.py | 29 ++++++++--- updater/service.py | 22 ++++++++ 8 files changed, 171 insertions(+), 9 deletions(-) diff --git a/tests/integration/updater/conftest.py b/tests/integration/updater/conftest.py index 16ad1732..d8d4b822 100644 --- a/tests/integration/updater/conftest.py +++ b/tests/integration/updater/conftest.py @@ -60,6 +60,12 @@ def _script(path: Path, body: str) -> Path: show) if [ "$2" = "--property=LoadState" ]; then if [ -e "{state}/missing-$unit" ]; then echo not-found; else echo loaded; fi + elif [[ " $* " == *" ActiveState "* ]]; then + for u in "${{@:2}}"; do + case "$u" in -*|ActiveState) continue ;; esac + if [ -e "{state}/inactive-$u" ]; then echo inactive; else echo active; fi + echo + done else echo 0 fi @@ -375,6 +381,7 @@ def harness(tmp_path, monkeypatch, mock_sdbus) -> Harness: _DEPLOY_FLAG=h.deploy_flag, _DEPLOY_PATH_UNIT=deploy_unit, PIP=str(h.bin / "pip3"), + SYSTEMCTL=str(h.bin / "systemctl"), klipper_printing=h.printing, load_components=lambda: (comp_g["_build_configs"](h.components), 86_400.0), ) diff --git a/tests/integration/updater/test_updater_daemon_flow.py b/tests/integration/updater/test_updater_daemon_flow.py index 2405f305..38d37203 100644 --- a/tests/integration/updater/test_updater_daemon_flow.py +++ b/tests/integration/updater/test_updater_daemon_flow.py @@ -112,6 +112,26 @@ async def test_deploy_hold_is_capped(harness, daemon): await wait_for(lambda: d.busy_trace() == [False], timeout=5.0, what="cap") assert harness.deploy_flag.exists() + origin = harness.remote("klipper") + harness.install(origin) + origin.commit({"klippy.py": "v2\n"}) + harness.component("klipper", service="klipper.service") + assert await d.iface.update_all() is True + # The same stuck flag must not re-hold at the end of every update. + await wait_for(lambda: d.busy_trace() == [False, True, False], what="no re-hold") + assert d.iface._deploy_hold is None + + +async def test_stale_flag_under_dead_deploy_units_does_not_hold(harness, daemon): + harness.deploy_flag.parent.mkdir(parents=True) + harness.deploy_flag.touch() + for unit in ("BlocksScreen-deploy.path", "BlocksScreen-deploy.service"): + (harness.state / f"inactive-{unit}").touch() # path unit failed/stopped + + d = daemon() + await wait_for(lambda: d.busy_trace() == [False], what="stale flag release") + assert harness.deploy_flag.exists() + async def test_restarted_daemon_resumes_hold_silently(harness, daemon): harness.deploy_flag.parent.mkdir(parents=True) diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index 1f0d7405..b6bdab52 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -61,6 +61,9 @@ def svc(): mock_svc.reconcile = AsyncMock(return_value=True) mock_svc.reconcile_if_pending = AsyncMock() mock_svc.deploy_pending = MagicMock(return_value=False) + # Units stay live while the flag is set unless a test says otherwise. + mock_svc.deploy_running = AsyncMock(side_effect=lambda: mock_svc.deploy_pending()) + mock_svc.deploy_flag_stamp = MagicMock(return_value=1) mock_svc._components = [ ComponentConfig(name="moonraker", kind="git"), ComponentConfig(name="klipper", kind="git"), @@ -79,6 +82,7 @@ def svc(): s._provisioned = False s._boot_pass_done = True s._deploy_hold = None + s._capped_flag = None s._background_tasks = set() s._closing = False s._status_check_in_progress = False diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 95e9cae1..11e190ef 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -412,6 +412,39 @@ async def test_hold_released_at_cap(self, svc, monkeypatch): await asyncio.sleep(0.1) assert svc._busy is False + @pytest.mark.asyncio + async def test_capped_flag_never_holds_again(self, svc, monkeypatch): + """A stuck flag costs the cap once, not again at the end of every update.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + monkeypatch.setattr(dbus_service, "_DEPLOY_HOLD_CAP_S", 0.03) + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + await asyncio.sleep(0.1) + svc._set_busy(True) + svc._set_busy(False) + assert (svc._busy, svc._deploy_hold) == (False, None) + + svc._svc.deploy_flag_stamp.return_value = 2 + svc._set_busy(False) + assert svc._busy is True + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + + @pytest.mark.asyncio + async def test_dead_deploy_units_release_at_once(self, svc, monkeypatch): + """A failed or stopped path unit never runs install-updater: its flag is stale.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._busy = True + svc._svc.deploy_pending.return_value = True + svc._svc.deploy_running = AsyncMock(return_value=False) + svc._set_busy(False) + await asyncio.sleep(0.02) + assert (svc._busy, svc._deploy_hold) == (False, None) + @pytest.mark.asyncio async def test_hold_spawned_once(self, svc, monkeypatch): from updater import dbus_service @@ -483,6 +516,25 @@ async def test_boot_pass_enables_request_status(self, svc, monkeypatch): with pytest.raises(asyncio.CancelledError): await task + @pytest.mark.asyncio + @pytest.mark.usefixtures("reconciled") + async def test_failed_boot_pass_still_emits_status(self, svc, monkeypatch): + """A raising provision pass must not leave the UI without status for a poll.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_BOOT_DELAY_S", 0.0) + svc._boot_pass_done = False + svc._provisioned = False + svc._svc.provision_missing = AsyncMock(side_effect=RuntimeError("boom")) + svc._svc.poll_interval = 3600.0 + task = asyncio.create_task(svc._periodic_status_check()) + await asyncio.sleep(0.05) + assert svc._boot_pass_done is True + svc.status_ready.emit.assert_called_once() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + @pytest.mark.usefixtures("reconciled") class TestBootProvision: diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 3622f745..8b5bc204 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -1,5 +1,6 @@ import asyncio import json +import os import time from contextlib import ExitStack, nullcontext from pathlib import Path @@ -20,6 +21,8 @@ def units_installed(): "updater.service.service_unit_missing", AsyncMock(return_value=False) ) as mock: yield mock + + def _isolate_inflight(tmp_path_factory, monkeypatch): """Keep the in-flight marker, state and history out of the real cache.""" marker = tmp_path_factory.mktemp("inflight") / "updater_inflight.json" @@ -2827,6 +2830,47 @@ def test_touch_deploy_flag_rejects_planted_symlink(self, tmp_path: Path): assert flag.exists() and not flag.is_symlink() assert target.read_text() == "keep" # symlink not followed + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("ok", "out", "running"), + [ + (True, "active\n\ninactive\n", True), + (True, "inactive\n\nactivating\n", True), + (True, "failed\n\ninactive\n", False), + (True, "inactive\n\ninactive\n", False), + (False, "", True), + ], + ) + async def test_deploy_running_follows_unit_state( + self, tmp_path: Path, ok: bool, out: str, running: bool + ): + """A failed/stopped path unit never fires; an unreadable state trusts the flag.""" + flag = tmp_path / ".run-install-updater" + flag.touch() + with ( + patch("updater.service._DEPLOY_FLAG", flag), + patch("updater.service._DEPLOY_PATH_UNIT", flag), + patch("updater.service._run", new=AsyncMock(return_value=(ok, out))), + ): + assert await UpdateService().deploy_running() is running + + @pytest.mark.asyncio + async def test_deploy_running_skips_systemctl_without_flag(self, tmp_path: Path): + with ( + patch("updater.service._DEPLOY_FLAG", tmp_path / "nope"), + patch("updater.service._run", new=AsyncMock()) as mock_run, + ): + assert await UpdateService().deploy_running() is False + mock_run.assert_not_called() + + def test_deploy_flag_stamp_tracks_each_touch(self, tmp_path: Path): + flag = tmp_path / ".run-install-updater" + with patch("updater.service._DEPLOY_FLAG", flag): + assert UpdateService.deploy_flag_stamp() is None + flag.touch() + os.utime(flag, ns=(1, 1)) + assert UpdateService.deploy_flag_stamp() == 1 + class TestInflightRollback: """Cross-reboot revert: an update cut off before commit is undone on boot.""" diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 848e3131..c6a05cf1 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -553,7 +553,7 @@ def test_provisioning_after_busy_still_shows_message(self, page, qtbot): page.handle_provisioning_changed(True) assert blocker.args == [True, "Missing component, installing ...", False] - def test_undeclared_busy_is_not_an_install(self, page, qtbot): + def test_undeclared_busy_shows_update_overlay(self, page, qtbot): page.show_loading = MagicMock() with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_busy_changed(True) diff --git a/updater/dbus_service.py b/updater/dbus_service.py index cf31c87d..b7286319 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -23,8 +23,8 @@ _BOOT_DELAY_S = 3.0 _SHUTDOWN_DRAIN_S = 60.0 # < systemd's 90s stop timeout _DEPLOY_POLL_S = 1.0 -# Failed runs clear the flag via ExecStopPost; only a hung one reaches this (~40s typical). -_DEPLOY_HOLD_CAP_S = 180.0 +# oneshot has no start timeout: bounds a hung install-updater (first DeviceDiscovery build takes minutes). +_DEPLOY_HOLD_CAP_S = 900.0 class DbusProgressCallback: @@ -118,6 +118,8 @@ def __init__(self) -> None: # request_status waits for the boot pass, so a client never sees pre-install status. self._boot_pass_done: bool = False self._deploy_hold: asyncio.Task | None = None + # Flag stamp that already used up the cap: never hold for it again. + self._capped_flag: int | None = None self._background_tasks: set[asyncio.Task] = set() self._closing: bool = False self._status_check_in_progress: bool = False @@ -199,7 +201,7 @@ def _set_provisioning(self, provisioning: bool) -> None: def _set_busy(self, busy: bool) -> None: """Busy stays up while install-updater.sh runs out-of-band, so the UI keeps its overlay.""" - if not busy and self._svc.deploy_pending(): + if not busy and self._hold_wanted(): if self._deploy_hold is None: self._deploy_hold = self._spawn( self._hold_for_deploy(), name="deploy_hold" @@ -214,18 +216,26 @@ def _emit_busy(self, busy: bool) -> None: _log.info("busy_changed -> %s", busy) self.busy_changed.emit((busy,)) + def _hold_wanted(self) -> bool: + """A deploy flag is set and has not already used up the hold cap.""" + return ( + self._svc.deploy_pending() + and self._svc.deploy_flag_stamp() != self._capped_flag + ) + async def _hold_for_deploy(self) -> None: - """Poll the deploy flag; release busy when install-updater ends or the cap hits.""" + """Hold busy while install-updater runs; release on exit, dead units or the cap.""" _log.info("deploy flag set - holding busy until install-updater finishes") loop = asyncio.get_running_loop() deadline = loop.time() + _DEPLOY_HOLD_CAP_S try: - while self._svc.deploy_pending(): + while await self._svc.deploy_running(): if loop.time() >= deadline: _log.warning( - "deploy flag still set after %.0fs - releasing busy", + "install-updater still running after %.0fs - releasing busy", _DEPLOY_HOLD_CAP_S, ) + self._capped_flag = self._svc.deploy_flag_stamp() break await asyncio.sleep(_DEPLOY_POLL_S) finally: @@ -297,11 +307,14 @@ async def _periodic_status_check(self) -> None: deferred = await self._svc.provision_missing(self._provision_busy) self._provisioned = not deferred _log.info("provisioning pass done (deferred=%s)", deferred) - self._boot_pass_done = True + except Exception as exc: # noqa: BLE001 + _log.error("provisioning pass failed: %s", exc) + # Emitted even after a failed pass: request_status stays silent until then. + self._boot_pass_done = True + try: await self._emit_status() except Exception as exc: # noqa: BLE001 _log.error("periodic_check failed: %s", exc) - self._boot_pass_done = True interval = self._svc.poll_interval if self._svc.has_fetch_failures(): interval = min(_FETCH_RETRY_INTERVAL_S, interval) diff --git a/updater/service.py b/updater/service.py index d974fca7..254c7a67 100644 --- a/updater/service.py +++ b/updater/service.py @@ -179,6 +179,9 @@ def reset(self) -> None: # Watched by BlocksScreen-deploy.path: install-updater.sh runs in its own cgroup. _DEPLOY_FLAG = Path.home() / ".config" / "blockscreen" / ".run-install-updater" _DEPLOY_PATH_UNIT = Path("/etc/systemd/system/BlocksScreen-deploy.path") +_DEPLOY_UNITS = ("BlocksScreen-deploy.path", "BlocksScreen-deploy.service") +# A failed/stopped path unit never fires, so its stale flag is not a running deploy. +_DEPLOY_LIVE_STATES = frozenset({"active", "activating", "deactivating", "reloading"}) class ProgressCallback(Protocol): @@ -1102,6 +1105,25 @@ def deploy_pending() -> bool: # Without the path unit nothing ever clears the flag (bs-deploy-check.sh test). return _DEPLOY_FLAG.exists() and _DEPLOY_PATH_UNIT.exists() + @staticmethod + def deploy_flag_stamp() -> int | None: + """The deploy flag's mtime in ns, identifying one queued run; None when unset.""" + try: + return _DEPLOY_FLAG.stat().st_mtime_ns + except OSError: + return None + + async def deploy_running(self) -> bool: + """True while the flag is set and the path unit is armed or install-updater runs.""" + if not self.deploy_pending(): + return False + ok, out = await _run( + [SYSTEMCTL, "show", *_DEPLOY_UNITS, "-p", "ActiveState", "--value"], + timeout=10.0, + ) + # Unknown state falls back to the flag; the hold cap still bounds it. + return not ok or not _DEPLOY_LIVE_STATES.isdisjoint(out.split()) + async def recover(self, name: str, hard: bool = False) -> bool: """Reset a component to its last known-good hash.""" component = next((c for c in self._components if c.name == name), None) From b137b4c94979d68571830a6c5816171a323c26af Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Mon, 19 Oct 2026 11:24:53 +0100 Subject: [PATCH 4/4] test(updater): move the end-to-end suite to its own PR --- Makefile | 7 +- tests/integration/updater/conftest.py | 484 ------------------ .../updater/test_updater_daemon_flow.py | 197 ------- .../updater/test_updater_git_flow.py | 333 ------------ 4 files changed, 2 insertions(+), 1019 deletions(-) delete mode 100644 tests/integration/updater/conftest.py delete mode 100644 tests/integration/updater/test_updater_daemon_flow.py delete mode 100644 tests/integration/updater/test_updater_git_flow.py diff --git a/Makefile b/Makefile index 2d0ca8f3..d5dc51f9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all init init-dev venv run lint format-check security check \ - test test-all test-unit test-network test-ui test-integration test-integration-nm test-fast \ + test test-all test-unit test-network test-ui test-integration test-fast \ coverage coverage-all coverage-network clean clean-venv \ docstrcov rcc rcc-all pr help @@ -121,10 +121,7 @@ test-ui: ## UI tests only (*_ui.py) test-%: ## Tests all files inside the // $(PYTHON) -m pytest $(PYTEST_FLAGS) $(PYTEST_IGNORE) $(TESTS)/$*/ -test-integration: ## End-to-end suites in tests/integration// (hermetic, no hardware) - $(PYTHON) -m pytest $(PYTEST_FLAGS) $(TESTS)/integration/ - -test-integration-nm: ## D-Bus integration tests (requires live NetworkManager) +test-integration: ## D-Bus integration tests (requires live NetworkManager) $(NM_INTEGRATION) $(PYTHON) -m pytest $(PYTEST_FLAGS) $(TESTS)/*/*_integration.py test-all: ## All tests including D-Bus integration (requires NetworkManager) diff --git a/tests/integration/updater/conftest.py b/tests/integration/updater/conftest.py deleted file mode 100644 index d8d4b822..00000000 --- a/tests/integration/updater/conftest.py +++ /dev/null @@ -1,484 +0,0 @@ -"""Updater integration harness: real git, state files, locking and D-Bus layer. - -Only the root-owned edges are faked (systemctl, sudo, apt, dpkg, pip, klippy.sock). -Remotes are local bare repos reached through an https:// insteadOf rewrite, so -clone/fetch/ls-remote run for real against URLs that pass the https-only checks. -""" - -import asyncio -import json -import os -import shutil -import subprocess -import sys -from collections.abc import Mapping -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[3] -REMOTE_BASE = "https://git.test/" - - -class _FakeDbusBase: - """Real Python base class so inheritance does not raise TypeError.""" - - def __init__(self, *a, **kw) -> None: - pass - - def __init_subclass__(cls, **kw) -> None: - super().__init_subclass__() - - -@pytest.fixture(scope="module", autouse=True) -def mock_sdbus(): - """Swap sdbus for a stub and force a fresh updater.dbus_service import.""" - mock = MagicMock() - mock.DbusInterfaceCommonAsync = _FakeDbusBase - mock.dbus_signal_async = lambda *a, **kw: lambda fn: fn - mock.dbus_method_async = lambda *a, **kw: lambda fn: fn - with pytest.MonkeyPatch.context() as mp: - for key in ("sdbus", "updater", "updater.dbus_service"): - mp.delitem(sys.modules, key, raising=False) - mp.setitem(sys.modules, "sdbus", mock) - yield mock - - -def _script(path: Path, body: str) -> Path: - """Write an executable bash script.""" - path.write_text("#!/bin/bash\n" + body) - path.chmod(0o755) - return path - - -_SYSTEMCTL = """\ -echo "systemctl $*" >>"{state}/calls.log" -unit="${{!#}}" -case "$1" in - is-active) [ -e "{state}/inactive-$unit" ] && exit 3; exit 0 ;; - show) - if [ "$2" = "--property=LoadState" ]; then - if [ -e "{state}/missing-$unit" ]; then echo not-found; else echo loaded; fi - elif [[ " $* " == *" ActiveState "* ]]; then - for u in "${{@:2}}"; do - case "$u" in -*|ActiveState) continue ;; esac - if [ -e "{state}/inactive-$u" ]; then echo inactive; else echo active; fi - echo - done - else - echo 0 - fi - exit 0 ;; - restart) - if [ -e "{state}/fail-restart-$unit" ]; then - echo "Job for $unit failed" >&2 - exit 1 - fi - exit 0 ;; - *) exit 0 ;; -esac -""" - -# Only our fakes run as "root": a hook's bare `sudo cp` into /etc is denied, as on a box. -_SUDO = """\ -echo "sudo $*" >>"{state}/calls.log" -case "$1" in - "{bin}"/*) exec "$@" ;; - *) echo "sudo: not allowed" >&2; exit 1 ;; -esac -""" - -_APT = """\ -echo "apt $*" >>"{state}/calls.log" -if [ "$1" = "list" ]; then - echo "Listing..." - [ -e "{state}/upgradable" ] && cat "{state}/upgradable" -fi -exit 0 -""" - -_LOGGED = 'echo "{name} $*" >>"{state}/calls.log"\nexit 0\n' - - -class Signal: - """Stand-in for an sdbus signal: records every emit in a shared log.""" - - def __init__(self, name: str, log: list) -> None: - self._name = name - self._log = log - - def emit(self, args: tuple) -> None: - """Record (signal name, args).""" - self._log.append((self._name, args)) - - -class Recorder: - """ProgressCallback that keeps every call, in order.""" - - def __init__(self) -> None: - self.events: list[tuple] = [] - - def on_step(self, name: str, step: int, total: int) -> None: - """Record a progress step.""" - self.events.append(("on_step", name, step, total)) - - def on_component_done(self, name: str, success: bool) -> None: - """Record a component outcome.""" - self.events.append(("on_component_done", name, success)) - - def on_error(self, name: str, reason: str) -> None: - """Record an error.""" - self.events.append(("on_error", name, reason)) - - def on_rollback(self, name: str, success: bool) -> None: - """Record a rollback.""" - self.events.append(("on_rollback", name, success)) - - def on_recover(self, name: str, success: bool) -> None: - """Record a recover.""" - self.events.append(("on_recover", name, success)) - - def of(self, method: str) -> list[tuple]: - """Arguments of every call to one callback method.""" - return [e[1:] for e in self.events if e[0] == method] - - -class Remote: - """A bare origin plus a scratch worktree used to author upstream commits.""" - - def __init__(self, harness: "Harness", name: str) -> None: - self.name = name - self.url = f"{REMOTE_BASE}{name}.git" - self._h = harness - self.bare = harness.remotes / f"{name}.git" - self.work = harness.root / "authoring" / name - harness.git("init", "-q", "--bare", "--initial-branch=master", str(self.bare)) - harness.git("init", "-q", "--initial-branch=master", str(self.work)) - harness.git("remote", "add", "origin", str(self.bare), cwd=self.work) - - def commit( - self, - files: Mapping[str, str | None], - msg: str = "change", - branch: str = "master", - ) -> str: - """Write (or delete, for None) files, commit on branch, push; return the sha.""" - h = self._h - # symbolic-ref works on the unborn initial branch, where switch would fail. - if h.git("symbolic-ref", "--short", "HEAD", cwd=self.work) != branch: - known = h.git("branch", "--format=%(refname:short)", cwd=self.work) - create = [] if branch in known.splitlines() else ["-c"] - h.git("switch", "-q", *create, branch, cwd=self.work) - for rel, content in files.items(): - target = self.work / rel - if content is None: - target.unlink() - continue - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) - h.git("add", "-A", cwd=self.work) - h.git("commit", "-q", "-m", msg, cwd=self.work) - h.git("push", "-q", "origin", f"HEAD:refs/heads/{branch}", cwd=self.work) - return h.git("rev-parse", "HEAD", cwd=self.work) - - -class Harness: - """One isolated device: tmp HOME, fake root tools, origins and components.""" - - def __init__(self, root: Path, home: Path, mp: pytest.MonkeyPatch) -> None: - self.root = root - self.home = home - self.remotes = root / "remotes" - self.state = root / "fakeroot" - self.bin = root / "bin" - self.hooks = root / "hooks" - self.run_dir = root / "run" - self.etc = root / "etc" - for d in (self.remotes, self.state, self.bin, self.hooks, self.etc): - d.mkdir(parents=True) - self.run_dir.mkdir(mode=0o700) - self.components: list[dict] = [] - self.printing = AsyncMock(return_value=False) - self.apt_upgrade = AsyncMock() - self.dbus_g: dict = {} - self.mp = mp - - # -- git ----------------------------------------------------------------- - - def git(self, *args: str, cwd: Path | None = None) -> str: - """Run git as the test author; return stripped stdout.""" - out = subprocess.run( - ["git", *args], cwd=cwd, check=True, capture_output=True, text=True - ) - return out.stdout.strip() - - def remote(self, name: str, files: dict[str, str] | None = None) -> Remote: - """Create an origin with one initial commit.""" - r = Remote(self, name) - r.commit(files or {"README": f"{name}\n"}, msg="initial") - return r - - def install(self, remote: Remote, branch: str = "master") -> Path: - """Clone the origin into HOME over its https URL, as the installer does.""" - path = self.home / remote.name - self.git("clone", "-q", "--branch", branch, remote.url, str(path)) - return path - - def head(self, name: str) -> str: - """HEAD sha of the installed component.""" - return self.git("rev-parse", "HEAD", cwd=self.home / name) - - def branch(self, name: str) -> str: - """Checked-out branch of the installed component.""" - return self.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.home / name) - - # -- components.yaml ----------------------------------------------------- - - def component(self, name: str, **fields: object) -> dict: - """Register a git component pointing at HOME/ and its origin.""" - entry = { - "name": name, - "type": "git", - "path": str(self.home / name), - "url": f"{REMOTE_BASE}{name}.git", - "branch": "master", - "reset_mode": "hard", - "order": 10 + len(self.components), - } | fields - self.components.append(entry) - return entry - - # -- hooks, fakes, observations ------------------------------------------ - - def hook(self, name: str, body: str) -> None: - """Install a post-update hook for a component.""" - _script(self.hooks / f"{name}.sh", body) - - def real_hook(self, name: str) -> None: - """Install the shipped hook for a component.""" - dst = self.hooks / f"{name}.sh" - shutil.copy(REPO_ROOT / "updater" / "hooks" / f"{name}.sh", dst) - dst.chmod(0o755) - - def venv_pip(self, name: str, body: str = "exit 0\n") -> Path: - """Give a component a logging venv pip (the sibling .-env layout).""" - pip_dir = self.home / f".{name}-env" / "bin" - pip_dir.mkdir(parents=True) - log = f'echo "venv-pip $*" >>"{self.state}/calls.log"\n' - return _script(pip_dir / "pip", log + body) - - def fail_restart(self, unit: str) -> None: - """Make `systemctl restart ` fail.""" - (self.state / f"fail-restart-{unit}").touch() - - def calls(self) -> list[str]: - """Every fake root-tool invocation, in order.""" - log = self.state / "calls.log" - return log.read_text().splitlines() if log.exists() else [] - - def history(self) -> list[dict]: - """Parsed update_history.jsonl.""" - path = self.home / ".cache" / "blockscreen" / "update_history.jsonl" - if not path.exists(): - return [] - return [json.loads(line) for line in path.read_text().splitlines()] - - def events(self, component: str) -> list[str]: - """History event names for one component.""" - return [e["event"] for e in self.history() if e["component"] == component] - - def state_file(self) -> dict: - """Parsed updater_state.json.""" - return json.loads( - (self.home / ".cache/blockscreen/updater_state.json").read_text() - ) - - @property - def inflight(self) -> Path: - """The in-flight batch marker.""" - return self.home / ".cache" / "blockscreen" / "updater_inflight.json" - - @property - def deploy_flag(self) -> Path: - """The flag BlocksScreen-deploy.path watches.""" - return self.home / ".config" / "blockscreen" / ".run-install-updater" - - @property - def sentinel(self) -> Path: - """The deferred-restart sentinel a self-update hook writes.""" - return self.run_dir / "updater-restart-needed" - - def service(self): - """A fresh UpdateService over the registered components.""" - import updater.dbus_service as dbus_mod - - recorder = Recorder() - svc = dbus_mod.UpdateService(callback=recorder) - return svc, recorder - - -def _write_fakes(h: Harness) -> None: - """Drop the fake root tools into h.bin.""" - fmt = {"state": h.state, "bin": h.bin} - _script(h.bin / "systemctl", _SYSTEMCTL.format(**fmt)) - _script(h.bin / "sudo", _SUDO.format(**fmt)) - _script(h.bin / "apt", _APT.format(**fmt)) - for tool in ("apt-mark", "dpkg", "bs-apt-helper", "pip3"): - _script(h.bin / tool, _LOGGED.format(name=tool, **fmt)) - # The shipped hook's daemon-reload fallback must not reach the host bus. - _script(h.bin / "dbus-send", "exit 1\n") - - -def _patch(mp: pytest.MonkeyPatch, g: dict, **values: object) -> None: - """Patch module globals, failing loudly if a name was renamed upstream.""" - for key, value in values.items(): - assert key in g, f"harness patches a missing global {key!r}" - mp.setitem(g, key, value) - - -@pytest.fixture -def harness(tmp_path, monkeypatch, mock_sdbus) -> Harness: - """Isolated device with every updater path and root tool redirected.""" - import updater.dbus_service as dbus_mod - - root = tmp_path.resolve() - home = root / "home" - home.mkdir() - h = Harness(root, home, monkeypatch) - _write_fakes(h) - - for var in ("XDG_CONFIG_HOME", "GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG_GLOBAL"): - monkeypatch.delenv(var, raising=False) - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") - monkeypatch.setenv("PATH", f"{h.bin}{os.pathsep}{os.environ['PATH']}") - (home / ".gitconfig").write_text( - "[user]\n\tname = Integration\n\temail = integration@example.invalid\n" - "[commit]\n\tgpgsign = false\n" - "[init]\n\tdefaultBranch = master\n" - "[advice]\n\tdetachedHead = false\n" - f'[url "file://{h.remotes}/"]\n\tinsteadOf = {REMOTE_BASE}\n' - ) - - # sdbus mock re-imports updater; resolve the live modules through the class. - svc_g = dbus_mod.UpdateService.__init__.__globals__ - exec_g = svc_g["git_fetch"].__globals__ - lock_g = exec_g["restart_sentinel_path"].__globals__ - comp_g = svc_g["load_components"].__globals__ - cache = home / ".cache" / "blockscreen" - deploy_unit = h.etc / "BlocksScreen-deploy.path" - deploy_unit.touch() - - _patch( - monkeypatch, - svc_g, - _STATE_PATH=cache / "updater_state.json", - _INFLIGHT_PATH=cache / "updater_inflight.json", - _FAULT_MARKER_PATH=cache / "selfheal_fault.json", - _HISTORY_PATH=cache / "update_history.jsonl", - _APT_STATUS_CACHE=cache / "apt_status_cache.json", - _DEPLOY_FLAG=h.deploy_flag, - _DEPLOY_PATH_UNIT=deploy_unit, - PIP=str(h.bin / "pip3"), - SYSTEMCTL=str(h.bin / "systemctl"), - klipper_printing=h.printing, - load_components=lambda: (comp_g["_build_configs"](h.components), 86_400.0), - ) - - async def _configured_origin(path: Path) -> str: - # get-url expands insteadOf to file://; the raw config keeps the https URL. - ok, out = await exec_g["_run"]( - [exec_g["GIT"], "config", "--get", "remote.origin.url"], - cwd=path, - timeout=10.0, - ) - return out.strip() if ok else "" - - _patch( - monkeypatch, - exec_g, - SUDO=str(h.bin / "sudo"), - SYSTEMCTL=str(h.bin / "systemctl"), - APT=str(h.bin / "apt"), - APT_MARK=str(h.bin / "apt-mark"), - DPKG=str(h.bin / "dpkg"), - APT_HELPER=h.bin / "bs-apt-helper", - PIP=str(h.bin / "pip3"), - KLIPPY_SOCK=root / "no-klippy.sock", - _HOOKS_DIR=h.hooks, - git_remote_url=_configured_origin, - ) - _patch(monkeypatch, lock_g, _runtime_dir=lambda: h.run_dir) - h.dbus_g = dbus_mod.UpdaterDbusService.__init__.__globals__ - _patch( - monkeypatch, - h.dbus_g, - _STATUS_PATH=h.run_dir / "updater_status.json", - _BOOT_DELAY_S=0.0, - _DEPLOY_POLL_S=0.05, - ) - # Self-heal supervisors poll systemd on minute timers: out of scope here. - for method in ("supervise_ui", "forward_heal_ui", "background_prime_nrestarts"): - monkeypatch.setattr(dbus_mod.UpdateService, method, AsyncMock()) - # Recorded, not run: the daemon must skip it when its own restart is pending. - monkeypatch.setattr(dbus_mod.UpdateService, "background_apt_upgrade", h.apt_upgrade) - return h - - -class Daemon: - """A live UpdaterDbusService plus its recorded signals.""" - - def __init__(self, iface) -> None: - self.iface = iface - self.log: list[tuple[str, tuple]] = [] - for name in ( - "step_complete", - "component_done", - "error", - "rollback", - "recover_done", - "status_ready", - "busy_changed", - "provisioning_changed", - ): - setattr(iface, name, Signal(name, self.log)) - - def signals(self, name: str) -> list[tuple]: - """Args of every emit of one signal.""" - return [args for sig, args in self.log if sig == name] - - def busy_trace(self) -> list[bool]: - """Every busy_changed value, in order.""" - return [args[0] for args in self.signals("busy_changed")] - - def statuses(self) -> list[dict]: - """Every status_ready payload, decoded.""" - return [json.loads(args[0]) for args in self.signals("status_ready")] - - -@pytest.fixture -async def daemon(harness): - """Factory for live daemons; each is shut down at teardown.""" - import updater.dbus_service as dbus_mod - - started: list[Daemon] = [] - - def start() -> Daemon: - d = Daemon(dbus_mod.UpdaterDbusService()) - started.append(d) - return d - - yield start - for d in started: - await d.iface.shutdown() - - -async def wait_for(predicate, timeout: float = 15.0, what: str = "condition") -> None: - """Poll predicate until true; fail with `what` on timeout.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while not predicate(): - if loop.time() >= deadline: - pytest.fail(f"timed out waiting for {what}") - await asyncio.sleep(0.02) diff --git a/tests/integration/updater/test_updater_daemon_flow.py b/tests/integration/updater/test_updater_daemon_flow.py deleted file mode 100644 index 38d37203..00000000 --- a/tests/integration/updater/test_updater_daemon_flow.py +++ /dev/null @@ -1,197 +0,0 @@ -"""UpdaterDbusService end to end: busy/overlay contract, boot pass, deploy hold, cancel.""" - -import asyncio - -from conftest import wait_for - -UI_FILES = { - "updater/dbus_service.py": "# marker\n", - "scripts/install-updater.sh": "v1\n", -} - - -def _idle(d) -> bool: - """No update/recover task is still running on the daemon.""" - return not any( - t.get_name().startswith(("update_", "recover_")) and not t.done() - for t in d.iface._background_tasks - ) - - -async def _booted(daemon): - """Start a daemon and wait for its first status emit.""" - d = daemon() - await wait_for(lambda: d.statuses(), what="boot status") - return d - - -async def test_update_all_round_trip(harness, daemon): - origin = harness.remote("klipper") - harness.install(origin) - new = origin.commit({"klippy.py": "v2\n"}) - harness.component("klipper", service="klipper.service") - d = await _booted(daemon) - assert d.statuses()[0]["klipper"]["commits_behind"] == 1 - - assert await d.iface.update_all() is True - assert await d.iface.update_all() is False # busy rejects a second run - await wait_for(lambda: d.busy_trace() == [True, False], what="busy release") - - assert harness.head("klipper") == new - steps = [a for a in d.signals("step_complete") if a[0] == "klipper"] - assert steps == [("klipper", n, 4) for n in (1, 2, 3, 4)] - assert d.signals("component_done") == [("klipper", True)] - await wait_for(lambda: harness.apt_upgrade.await_count == 1, what="apt upgrade") - - seen = len(d.statuses()) - await d.iface.request_status() - await wait_for(lambda: len(d.statuses()) > seen, what="fresh status") - assert d.statuses()[-1]["klipper"]["commits_behind"] == 0 - - -async def test_nothing_dirty_still_releases_busy(harness, daemon): - harness.install(harness.remote("klipper")) - harness.component("klipper", service="klipper.service") - d = await _booted(daemon) - - assert await d.iface.update_all() is True - await wait_for(lambda: d.busy_trace() == [True, False], what="busy release") - - assert d.signals("step_complete") == [] - - -async def test_failed_update_releases_busy_and_reports(harness, daemon): - origin = harness.remote("klipper") - harness.install(origin) - old = harness.head("klipper") - origin.commit({"klippy.py": "v2\n"}) - harness.component("klipper", service="klipper.service") - harness.hook("klipper", "exit 1\n") - d = await _booted(daemon) - - assert await d.iface.update_all() is True - await wait_for(lambda: d.busy_trace() == [True, False], what="busy release") - - assert harness.head("klipper") == old - assert d.signals("error") == [("klipper", "hook")] - assert d.signals("rollback") == [("klipper", True)] - assert d.signals("component_done") == [("klipper", False)] - - -async def test_install_update_holds_overlay_until_deploy_finishes(harness, daemon): - origin = harness.remote("BlocksScreen", UI_FILES) - harness.install(origin) - origin.commit({"scripts/install-updater.sh": "v2\n"}) - harness.component("BlocksScreen", service="BlocksScreen.service") - harness.real_hook("BlocksScreen") - d = await _booted(daemon) - - assert await d.iface.update_all() is True - await wait_for(harness.deploy_flag.exists, what="deploy flag") - await wait_for(lambda: _idle(d), what="batch end") - await asyncio.sleep(0.3) # several hold polls - - # Batch done, install-updater still running: the overlay must not drop. - assert d.busy_trace() == [True] - assert await d.iface.get_busy() is True - assert await d.iface.update_all() is False - harness.apt_upgrade.assert_not_awaited() - - harness.deploy_flag.unlink() # install-updater's ExecStopPost - await wait_for(lambda: d.busy_trace() == [True, False], what="hold release") - - -async def test_deploy_hold_is_capped(harness, daemon): - harness.mp.setitem(harness.dbus_g, "_DEPLOY_HOLD_CAP_S", 0.3) - harness.deploy_flag.parent.mkdir(parents=True) - harness.deploy_flag.touch() # a hung install-updater never clears it - - d = daemon() - assert await d.iface.get_busy() is True - - await wait_for(lambda: d.busy_trace() == [False], timeout=5.0, what="cap") - assert harness.deploy_flag.exists() - - origin = harness.remote("klipper") - harness.install(origin) - origin.commit({"klippy.py": "v2\n"}) - harness.component("klipper", service="klipper.service") - assert await d.iface.update_all() is True - # The same stuck flag must not re-hold at the end of every update. - await wait_for(lambda: d.busy_trace() == [False, True, False], what="no re-hold") - assert d.iface._deploy_hold is None - - -async def test_stale_flag_under_dead_deploy_units_does_not_hold(harness, daemon): - harness.deploy_flag.parent.mkdir(parents=True) - harness.deploy_flag.touch() - for unit in ("BlocksScreen-deploy.path", "BlocksScreen-deploy.service"): - (harness.state / f"inactive-{unit}").touch() # path unit failed/stopped - - d = daemon() - await wait_for(lambda: d.busy_trace() == [False], what="stale flag release") - assert harness.deploy_flag.exists() - - -async def test_restarted_daemon_resumes_hold_silently(harness, daemon): - harness.deploy_flag.parent.mkdir(parents=True) - harness.deploy_flag.touch() # install-updater restarted us mid-run - - d = daemon() - # Not exported yet at __init__: clients read it via get_busy, no signal. - assert d.busy_trace() == [] - assert await d.iface.get_busy() is True - - harness.deploy_flag.unlink() - await wait_for(lambda: d.busy_trace() == [False], what="hold release") - - -async def test_boot_provisioning_raises_overlay(harness, daemon): - origin = harness.remote("klipper") - harness.component("klipper", service="klipper.service", install_if_missing=True) - - d = await _booted(daemon) - - assert [a[0] for a in d.signals("provisioning_changed")] == [True, False] - assert d.busy_trace() == [True, False] - assert await d.iface.get_provisioning() is False - assert harness.head("klipper") == harness.git("rev-parse", "HEAD", cwd=origin.bare) - assert d.statuses()[0]["klipper"]["commits_behind"] == 0 - - -async def test_boot_reconcile_reverts_power_cut(harness, daemon): - origin = harness.remote("klipper") - harness.install(origin) - old = harness.head("klipper") - origin.commit({"klippy.py": "v2\n"}) - harness.git("pull", "-q", cwd=harness.home / "klipper") - harness.inflight.parent.mkdir(parents=True, exist_ok=True) - harness.inflight.write_text(f'{{"klipper": "{old}"}}') - harness.component("klipper", service="klipper.service") - - await _booted(daemon) - - assert harness.head("klipper") == old - assert "boot_rollback" in harness.events("klipper") - assert not harness.inflight.exists() - - -async def test_cancel_mid_hook_reverts_and_releases(harness, daemon): - origin = harness.remote("klipper") - harness.install(origin) - old = harness.head("klipper") - origin.commit({"klippy.py": "v2\n"}) - harness.component("klipper", service="klipper.service") - harness.hook("klipper", "sleep 20\n") - d = await _booted(daemon) - - assert await d.iface.update_all() is True - await wait_for( - lambda: ("klipper", 3, 4) in d.signals("step_complete"), what="hook start" - ) - await d.iface.cancel() - - assert harness.head("klipper") == old - assert d.signals("rollback") == [("klipper", True)] - assert d.busy_trace() == [True, False] - assert not harness.inflight.exists() diff --git a/tests/integration/updater/test_updater_git_flow.py b/tests/integration/updater/test_updater_git_flow.py deleted file mode 100644 index 919e3bf7..00000000 --- a/tests/integration/updater/test_updater_git_flow.py +++ /dev/null @@ -1,333 +0,0 @@ -"""UpdateService end to end against real git repos: update, rollback, heal, install.""" - -UI_FILES = { - "updater/dbus_service.py": "# marker\n", - "scripts/install-updater.sh": "v1\n", -} - - -def _klipper(harness, **fields: object): - """Installed klipper one commit behind its origin; returns (origin, old, new).""" - origin = harness.remote("klipper") - harness.install(origin) - old = harness.head("klipper") - new = origin.commit({"klippy.py": "v2\n"}) - harness.component("klipper", service="klipper.service", **fields) - return origin, old, new - - -def _rollbacks(harness, name: str) -> list[dict]: - return [ - e - for e in harness.history() - if e["component"] == name and e["event"] == "rollback" - ] - - -async def test_update_moves_to_origin_tip_and_restarts(harness): - _, old, new = _klipper(harness) - harness.venv_pip("klipper") - svc, rec = harness.service() - - assert await svc.update_all({"klipper"}) is True - - assert harness.head("klipper") == new - assert harness.state_file()["klipper"]["prev_hash"] == old - assert harness.events("klipper") == ["update_start", "update_success"] - assert not harness.inflight.exists() - assert [s[1] for s in rec.of("on_step")] == [1, 2, 3, 4] - assert rec.of("on_component_done") == [("klipper", True)] - calls = harness.calls() - assert "systemctl restart klipper.service" in calls - assert "systemctl is-active klipper.service" in calls - # No requirements.txt: the venv is found but pip is never asked to install. - assert not [c for c in calls if c.startswith("venv-pip")] - - -async def test_deps_install_runs_component_venv_pip(harness): - origin, _, _ = _klipper(harness) - req = origin.commit({"requirements.txt": "pyserial\n"}) - harness.venv_pip("klipper") - svc, _ = harness.service() - - assert await svc.update_all({"klipper"}) is True - - assert harness.head("klipper") == req - req_path = harness.home / "klipper" / "requirements.txt" - assert f"venv-pip install -r {req_path} --quiet" in harness.calls() - - -async def test_hook_failure_reverts(harness): - _, old, _ = _klipper(harness) - harness.hook("klipper", "exit 3\n") - svc, rec = harness.service() - - assert await svc.update_all({"klipper"}) is False - - assert harness.head("klipper") == old - [rb] = _rollbacks(harness, "klipper") - assert (rb["reason"], rb["reverted_to"], rb["ok"]) == ("hook", old[:12], True) - assert rec.of("on_error") == [("klipper", "hook")] - assert rec.of("on_rollback") == [("klipper", True)] - assert "systemctl restart klipper.service" not in harness.calls() - assert not harness.inflight.exists() - - -async def test_hook_receives_hashes(harness): - _, old, new = _klipper(harness) - out = harness.root / "hook.env" - harness.hook( - "klipper", - f'echo "$COMPONENT_NAME $PREV_HASH $NEW_HASH $COMPONENT_PATH" >"{out}"\n', - ) - svc, _ = harness.service() - - assert await svc.update_all({"klipper"}) is True - - assert out.read_text().split() == [ - "klipper", - old, - new, - str(harness.home / "klipper"), - ] - - -async def test_restart_failure_reverts(harness): - _, old, _ = _klipper(harness) - harness.fail_restart("klipper.service") - svc, rec = harness.service() - - assert await svc.update_all({"klipper"}) is False - - assert harness.head("klipper") == old - assert [rb["reason"] for rb in _rollbacks(harness, "klipper")] == ["restart"] - assert rec.of("on_component_done") == [("klipper", False)] - - -async def test_deps_failure_reverts(harness): - origin, old, _ = _klipper(harness) - origin.commit({"requirements.txt": "broken-dep\n"}) - harness.venv_pip("klipper", 'case "$*" in *"-r "*) exit 1 ;; esac\nexit 0\n') - svc, _ = harness.service() - - assert await svc.update_all({"klipper"}) is False - - assert harness.head("klipper") == old - assert [rb["reason"] for rb in _rollbacks(harness, "klipper")] == ["deps"] - - -async def test_one_failure_does_not_block_the_rest(harness): - _, k_old, _ = _klipper(harness) - moon = harness.remote("moonraker") - harness.install(moon) - m_new = moon.commit({"moonraker.py": "v2\n"}) - harness.component("moonraker", service="moonraker.service") - harness.hook("klipper", "exit 1\n") - svc, rec = harness.service() - - assert await svc.update_all({"klipper", "moonraker"}) is False - - assert harness.head("klipper") == k_old - assert harness.head("moonraker") == m_new - assert sorted(rec.of("on_component_done")) == [ - ("klipper", False), - ("moonraker", True), - ] - assert harness.events("moonraker") == ["update_start", "update_success"] - - -async def test_dead_branch_is_skipped_untouched(harness): - origin = harness.remote("klipper") - harness.install(origin) - old = harness.head("klipper") - harness.component("klipper", service="klipper.service", branch="gone") - svc, rec = harness.service() - - assert await svc.update_all({"klipper"}) is False - - assert harness.head("klipper") == old - assert rec.of("on_error") == [ - ("klipper", "branch origin/gone not found - fix components.yaml") - ] - assert harness.history() == [] - - -async def test_branch_switch_follows_components_yaml(harness): - origin = harness.remote("klipper") - harness.install(origin) - feature = origin.commit({"feature.py": "x\n"}, branch="feature") - harness.component("klipper", service="klipper.service", branch="feature") - svc, _ = harness.service() - - assert await svc.update_all({"klipper"}) is True - - assert harness.branch("klipper") == "feature" - assert harness.head("klipper") == feature - - -async def test_hard_reset_discards_local_edits(harness): - _, _, new = _klipper(harness) - (harness.home / "klipper" / "README").write_text("edited on the printer\n") - svc, _ = harness.service() - - status = await svc.check_status(force=True) - assert status["klipper"].has_local_changes - assert status["klipper"].commits_behind == 1 - assert await svc.update_all({"klipper"}) is True - - assert harness.head("klipper") == new - assert (harness.home / "klipper" / "README").read_text() == "klipper\n" - - -async def test_non_https_remote_is_refused(harness): - origin, old, _ = _klipper(harness) - harness.git( - "remote", "set-url", "origin", str(origin.bare), cwd=harness.home / "klipper" - ) - svc, rec = harness.service() - - assert await svc.update_all({"klipper"}) is False - - assert harness.head("klipper") == old - assert rec.of("on_error") == [("klipper", "insecure remote")] - assert not harness.inflight.exists() - - -async def test_ui_tip_without_updater_is_refused(harness): - origin = harness.remote("BlocksScreen", UI_FILES) - harness.install(origin) - old = harness.head("BlocksScreen") - origin.commit({"updater/dbus_service.py": None}, msg="drop updater") - harness.component("BlocksScreen", service="BlocksScreen.service") - svc, rec = harness.service() - - assert await svc.update_all({"BlocksScreen"}) is False - - assert harness.head("BlocksScreen") == old - assert rec.of("on_error") == [("BlocksScreen", "refusing downgrade past updater")] - - -async def test_install_file_change_hands_off_to_deploy(harness): - origin = harness.remote("BlocksScreen", UI_FILES) - harness.install(origin) - new = origin.commit({"scripts/install-updater.sh": "v2\n"}) - harness.component("BlocksScreen", service="BlocksScreen.service") - harness.real_hook("BlocksScreen") - svc, _ = harness.service() - - assert await svc.update_all({"BlocksScreen"}) is True - - assert harness.head("BlocksScreen") == new - assert harness.deploy_flag.exists() - assert not harness.sentinel.exists() - assert svc.deploy_pending() - assert svc.daemon_restart_pending - calls = harness.calls() - assert "systemctl --no-block restart BlocksScreen.service" in calls - # The UI is fire-and-forget: never a blocking restart of our own screen. - assert "systemctl restart BlocksScreen.service" not in calls - - -async def test_plain_ui_change_sets_no_deploy_flag(harness): - origin = harness.remote("BlocksScreen", UI_FILES) - harness.install(origin) - origin.commit({"README": "docs\n"}) - harness.component("BlocksScreen", service="BlocksScreen.service") - harness.real_hook("BlocksScreen") - svc, _ = harness.service() - - assert await svc.update_all({"BlocksScreen"}) is True - - assert not harness.deploy_flag.exists() - assert not svc.daemon_restart_pending - assert "systemctl --no-block restart BlocksScreen.service" in harness.calls() - - -async def test_reconcile_reverts_power_cut_batch(harness): - _, old, new = _klipper(harness) - path = harness.home / "klipper" - # Power cut mid-batch: the tree moved, the marker still names the rollback point. - harness.git("pull", "-q", cwd=path) - assert harness.head("klipper") == new - harness.inflight.parent.mkdir(parents=True, exist_ok=True) - harness.inflight.write_text(f'{{"klipper": "{old}"}}') - svc, _ = harness.service() - - assert await svc.reconcile() is True - - assert harness.head("klipper") == old - assert "boot_rollback" in harness.events("klipper") - assert not harness.inflight.exists() - - -async def test_recover_returns_to_prev_hash(harness): - _, old, new = _klipper(harness) - svc, rec = harness.service() - assert await svc.update_all({"klipper"}) is True - assert harness.head("klipper") == new - restarts_before = harness.calls().count("systemctl restart klipper.service") - - assert await svc.recover("klipper", hard=True) is True - - assert harness.head("klipper") == old - assert rec.of("on_recover") == [("klipper", True)] - assert ( - harness.calls().count("systemctl restart klipper.service") - == restarts_before + 1 - ) - - -async def test_single_component_update_and_rollback(harness): - _, old, new = _klipper(harness) - svc, _ = harness.service() - assert await svc.update_component("klipper") is True - assert harness.head("klipper") == new - assert not harness.inflight.exists() - - harness.hook("klipper", "exit 1\n") - harness.git("reset", "-q", "--hard", old, cwd=harness.home / "klipper") - svc2, rec = harness.service() - - assert await svc2.update_component("klipper") is False - - assert harness.head("klipper") == old - assert rec.of("on_rollback") == [("klipper", True)] - - -async def test_provision_missing_clones_and_enables(harness): - origin = harness.remote("klipper") - harness.component("klipper", service="klipper.service", install_if_missing=True) - svc, rec = harness.service() - busy: list[bool] = [] - - assert await svc.provision_missing(busy.append) is False - - assert busy == [True, False] - assert harness.head("klipper") == harness.git("rev-parse", "HEAD", cwd=origin.bare) - assert harness.events("klipper") == ["install_start", "install_success"] - assert rec.of("on_component_done") == [("klipper", True)] - assert "systemctl enable klipper.service" in harness.calls() - - -async def test_provision_waits_while_printing(harness): - harness.remote("klipper") - harness.component("klipper", install_if_missing=True) - harness.printing.return_value = True - svc, _ = harness.service() - busy: list[bool] = [] - - assert await svc.provision_missing(busy.append) is True - - assert busy == [] - assert not (harness.home / "klipper").exists() - - -async def test_provision_offline_never_raises_busy(harness): - harness.component("klipper", install_if_missing=True) # no origin exists - svc, _ = harness.service() - busy: list[bool] = [] - - assert await svc.provision_missing(busy.append) is True - - assert busy == [] - assert not (harness.home / "klipper").exists()