Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions tests/updater/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def svc():
mock_svc.recover = AsyncMock()
mock_svc.has_fetch_failures = MagicMock(return_value=False)
mock_svc.provision_missing = AsyncMock(return_value=False)
mock_svc.run_followup = AsyncMock(return_value=(False, False))
mock_svc.background_apt_upgrade = AsyncMock()
mock_svc.daemon_restart_pending = False
mock_svc.reconcile = AsyncMock(return_value=True)
mock_svc.reconcile_if_pending = AsyncMock()
mock_svc.deploy_pending = MagicMock(return_value=False)
Expand All @@ -80,6 +83,7 @@ def svc():
s._busy = False
s._provisioning = False
s._provisioned = False
s._followed_up = False
s._boot_pass_done = True
s._deploy_hold = None
s._capped_flag = None
Expand Down
67 changes: 67 additions & 0 deletions tests/updater/test_dbus_service_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,24 @@ async def test_hold_end_leaves_running_provision_busy(self, svc, monkeypatch):
svc._provision_busy(False)
assert svc._busy is False

@pytest.mark.asyncio
@pytest.mark.parametrize("followed_up", [False, True])
async def test_hold_end_runs_pending_followup(self, svc, monkeypatch, followed_up):
"""The boot hold blocks the follow-up; it runs on release, not a poll later."""
from updater import dbus_service

monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01)
svc._provisioned = True
svc._followed_up = followed_up
svc._svc.run_followup = AsyncMock(return_value=(False, False))
svc._svc.deploy_pending.return_value = True
svc._set_busy(False)
await asyncio.sleep(0.03)
svc._svc.run_followup.assert_not_awaited()
svc._svc.deploy_pending.return_value = False
await asyncio.sleep(0.05)
assert svc._svc.run_followup.await_count == (0 if followed_up else 1)

@pytest.mark.asyncio
async def test_update_rejected_during_hold(self, svc):
"""install-updater restarts moonraker: no new batch may start under it."""
Expand Down Expand Up @@ -603,6 +621,55 @@ async def test_deferred_provision_repolls_soon_then_stops(self, svc):
assert svc._svc.provision_missing.await_count == 2
assert sleeps == [dbus_service._FETCH_RETRY_INTERVAL_S, 86_400.0, 86_400.0]

@pytest.mark.asyncio
async def test_followup_runs_once_after_provisioning(self, svc):
svc._svc.poll_interval = 86_400.0
sleeps = await self._run_polls(svc, polls=3)
svc._svc.run_followup.assert_awaited_once_with(svc._set_busy)
assert sleeps == [86_400.0] * 3

@pytest.mark.asyncio
async def test_deferred_followup_repolls_soon(self, svc):
from updater import dbus_service

svc._svc.poll_interval = 86_400.0
svc._svc.run_followup = AsyncMock(side_effect=[(True, False), (False, False)])
sleeps = await self._run_polls(svc, polls=3)
assert svc._svc.run_followup.await_count == 2
assert sleeps == [dbus_service._FETCH_RETRY_INTERVAL_S, 86_400.0, 86_400.0]

@pytest.mark.asyncio
async def test_followup_that_ran_sweeps_apt(self, svc):
svc._provisioned = True
svc._svc.run_followup = AsyncMock(return_value=(False, True))
await svc._followup_pass()
await asyncio.gather(*svc._background_tasks)
svc._svc.background_apt_upgrade.assert_awaited_once()
assert svc._followed_up is True

@pytest.mark.asyncio
async def test_followup_skips_apt_when_restart_pending(self, svc):
svc._provisioned = True
svc._svc.daemon_restart_pending = True
svc._svc.run_followup = AsyncMock(return_value=(False, True))
await svc._followup_pass()
svc._svc.background_apt_upgrade.assert_not_called()

@pytest.mark.asyncio
async def test_followup_waits_while_busy(self, svc):
svc._provisioned = True
svc._busy = True
await svc._followup_pass()
svc._svc.run_followup.assert_not_awaited()
assert svc._followed_up is False

@pytest.mark.asyncio
async def test_failed_followup_is_not_retried(self, svc):
svc._provisioned = True
svc._svc.run_followup = AsyncMock(side_effect=RuntimeError("boom"))
await svc._followup_pass()
assert svc._followed_up is True


class TestProvisioningFlag:
def test_provision_busy_emits_provisioning_then_busy(self, svc):
Expand Down
49 changes: 49 additions & 0 deletions tests/updater/test_executor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
git_remote_url,
git_repair,
git_reset_to_hash,
git_show_file,
git_prune_extra_remotes,
git_untracked_paths,
enable_service,
Expand Down Expand Up @@ -937,6 +938,53 @@ async def test_dead_configured_branch_reports_actionable_error(self, tmp_path):
)
assert result.error == "branch origin/wip/dead not found - fix components.yaml"

@pytest.mark.asyncio
@pytest.mark.parametrize(("pin", "behind"), [(b"abc1234\n", 0), (b"def5678\n", 1)])
async def test_pinned_version_dirty_only_off_the_pin(self, tmp_path, pin, behind):
# A moved pin must count as pending, or Update never applies it.
procs = [
_make_proc(0, b"abc1234\n", b""), # git_get_hash
_make_proc(0, b"main\n", b""), # git_get_current_branch
_make_proc(0, pin, b""), # git_ref_hash version^{commit}
_make_proc(0, b"https://github.com/x/y\n", b""), # git_remote_url
_make_proc(0, b"", b""), # git_is_dirty (clean)
_make_proc(0, b"v1.0\n", b""), # git_describe current
_make_proc(0, b"v1.1\n", b""), # git_describe version
]
exec_mock = AsyncMock(side_effect=procs)
with patch("asyncio.create_subprocess_exec", exec_mock):
result = await check_git_status(
"Spoolman", tmp_path, version="def5678", skip_fetch=True
)
assert result.commits_behind == behind
assert result.remote_version == "v1.1"
describe_args = exec_mock.await_args_list[-1].args
assert describe_args[-1] == "def5678"


class TestGitShowFile:
@pytest.mark.asyncio
async def test_returns_content_at_ref(self, tmp_path):
exec_mock = AsyncMock(return_value=_make_proc(0, b"components: []\n", b""))
with patch("asyncio.create_subprocess_exec", exec_mock):
out = await git_show_file(tmp_path, "a" * 40, "updater/components.yaml")
assert out == "components: []\n"
assert exec_mock.await_args.args[-1] == f"{'a' * 40}:updater/components.yaml"

@pytest.mark.asyncio
@pytest.mark.parametrize("ref", ["main", "--output=/tmp/x", ""])
async def test_rejects_non_sha_ref_without_running_git(self, tmp_path, ref):
exec_mock = AsyncMock()
with patch("asyncio.create_subprocess_exec", exec_mock):
assert await git_show_file(tmp_path, ref, "updater/components.yaml") == ""
exec_mock.assert_not_called()

@pytest.mark.asyncio
async def test_git_failure_returns_empty(self, tmp_path):
exec_mock = AsyncMock(return_value=_make_proc(128, b"", b"fatal: bad object\n"))
with patch("asyncio.create_subprocess_exec", exec_mock):
assert await git_show_file(tmp_path, "a" * 40, "x") == ""


class TestCheckAptStatus:
@pytest.mark.asyncio
Expand Down Expand Up @@ -1177,6 +1225,7 @@ async def test_invalid_name_never_queries(self):
assert await service_unit_missing("bad;name.service") is False
mock_exec.assert_not_called()


class TestRunHook:
"""run_hook: resolve under hooks dir, pass timeout, reject traversal."""

Expand Down
101 changes: 100 additions & 1 deletion tests/updater/test_hooks_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
HOOK = Path(__file__).resolve().parents[2] / "updater" / "hooks" / "DeviceDiscovery.sh"


def _run_hook(tmp_path: Path, repo: Path, load_state: str) -> subprocess.CompletedProcess:
def _run_hook(
tmp_path: Path, repo: Path, load_state: str
) -> subprocess.CompletedProcess:
"""Run the hook with a stub systemctl that reports load_state."""
stub_bin = tmp_path / "stub-bin"
stub_bin.mkdir()
Expand Down Expand Up @@ -66,3 +68,100 @@ def test_set_up_machine_rebuilds(self, tmp_path, repo):
assert result.returncode == 0, result.stderr
assert (repo / "built").exists()
assert not (tmp_path / "run" / "sentinel").exists()


UI_HOOK = HOOK.with_name("BlocksScreen.sh")


def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args], capture_output=True, text=True, check=True
).stdout.strip()


def _commit(repo: Path, files: dict[str, str]) -> str:
for rel, text in files.items():
(repo / rel).parent.mkdir(parents=True, exist_ok=True)
(repo / rel).write_text(text)
_git(repo, "add", "-A")
_git(
repo,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"c",
)
return _git(repo, "rev-parse", "HEAD")


class TestBlocksScreenHook:
"""Updater code or manifest changes must reach the daemon within the same Update."""

@pytest.fixture
def ui_repo(self, tmp_path: Path) -> Path:
repo = tmp_path / "BlocksScreen"
repo.mkdir()
_git(repo, "init", "-q")
return repo

def _run(self, tmp_path: Path, repo: Path, prev: str, new: str, **extra: str):
env = {
"PATH": os.environ["PATH"],
"HOME": str(tmp_path),
"COMPONENT_PATH": str(repo),
"PREV_HASH": prev,
"NEW_HASH": new,
"BS_UPDATER_SELF_UPDATE": "1",
"BS_UPDATER_RESTART_SENTINEL": str(tmp_path / "run" / "sentinel"),
**extra,
}
result = subprocess.run(
["bash", str(UI_HOOK)], env=env, capture_output=True, text=True, check=False
)
assert result.returncode == 0, result.stderr
followup = tmp_path / ".cache" / "blockscreen" / "updater_followup"
return tmp_path / "run" / "sentinel", followup

def test_manifest_change_queues_followup_and_restart(self, tmp_path, ui_repo):
prev = _commit(ui_repo, {"updater/components.yaml": "a: 1\n"})
new = _commit(ui_repo, {"updater/components.yaml": "a: 2\n"})
sentinel, followup = self._run(tmp_path, ui_repo, prev, new)
assert sentinel.read_text().split() == ["code"]
assert followup.read_text().strip() == prev

def test_code_only_change_restarts_without_followup(self, tmp_path, ui_repo):
prev = _commit(ui_repo, {"updater/service.py": "x = 1\n"})
new = _commit(ui_repo, {"updater/service.py": "x = 2\n"})
sentinel, followup = self._run(tmp_path, ui_repo, prev, new)
assert sentinel.read_text().split() == ["code"]
assert not followup.exists()

def test_non_updater_change_does_nothing(self, tmp_path, ui_repo):
prev = _commit(ui_repo, {"BlocksScreen/a.py": "1\n"})
new = _commit(ui_repo, {"BlocksScreen/a.py": "2\n"})
sentinel, followup = self._run(tmp_path, ui_repo, prev, new)
assert not sentinel.exists()
assert not followup.exists()

def test_chained_updates_keep_oldest_base(self, tmp_path, ui_repo):
first = _commit(ui_repo, {"updater/components.yaml": "a: 1\n"})
mid = _commit(ui_repo, {"updater/components.yaml": "a: 2\n"})
last = _commit(ui_repo, {"updater/components.yaml": "a: 3\n"})
self._run(tmp_path, ui_repo, first, mid)
_, followup = self._run(tmp_path, ui_repo, mid, last)
assert followup.read_text().strip() == first

def test_outside_updater_no_restart_sentinel(self, tmp_path, ui_repo):
# CLI/manual runs: nothing defers the restart, so none is requested.
prev = _commit(ui_repo, {"updater/components.yaml": "a: 1\n"})
new = _commit(ui_repo, {"updater/components.yaml": "a: 2\n"})
sentinel, followup = self._run(
tmp_path, ui_repo, prev, new, BS_UPDATER_SELF_UPDATE=""
)
assert not sentinel.exists()
assert followup.exists()
Loading