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
12 changes: 12 additions & 0 deletions tests/updater/test_components_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,15 @@ def test_origin_prefixed_branch_is_stripped(caplog):
bs = next(c for c in configs if c.name == "BlocksScreen")
assert bs.branch == "Release-2.0" # stray origin/ prefix stripped
assert any("origin/" in r.message for r in caplog.records)


def test_shipped_happy_hare_is_pinned_without_service():
with patch("updater.components.OVERRIDE_PATH", Path("/nonexistent/override.yaml")):
configs, _ = load_components()
hh = next(c for c in configs if c.name == "Happy-Hare")
assert hh.install_if_missing and hh.url
assert hh.version == "d5cce9f96991b270ff570b7497bbf6b4463a82b9"
assert (hh.branch, hh.reset_mode) == ("v3", "hard")
assert hh.service is None
names = [c.name for c in configs]
assert names.index("moonraker") < names.index("Happy-Hare")
10 changes: 10 additions & 0 deletions tests/updater/test_dbus_service_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,16 @@ async def test_followup_that_ran_sweeps_apt(self, svc):
svc._svc.background_apt_upgrade.assert_awaited_once()
assert svc._followed_up is True

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

@pytest.mark.asyncio
async def test_followup_skips_apt_when_restart_pending(self, svc):
svc._provisioned = True
Expand Down
19 changes: 19 additions & 0 deletions tests/updater/test_executor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,25 @@ async def test_run_hook_default_timeout(self, tmp_path, monkeypatch):
await run_hook("comp", tmp_path, "n", "p")
assert run.await_args.kwargs["timeout"] == 60.0

@pytest.mark.asyncio
async def test_extra_env_never_overrides_component_vars(
self, tmp_path, monkeypatch
):
import updater.executor as ex

monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path)
monkeypatch.setenv("PATH", "/usr/bin")
(tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n")
(tmp_path / "comp.sh").chmod(0o755)
extra = {"X": "1", "NEW_HASH": "bad", "PATH": "/tmp/evil", "LC_ALL": "xx"}
with patch.object(ex, "_run", new=AsyncMock(return_value=(True, ""))) as run:
await run_hook("comp", tmp_path, "n", "p", extra_env=extra)
env = run.await_args.kwargs["env"]
assert env["X"] == "1"
assert env["NEW_HASH"] == "n"
assert env["PATH"] == "/usr/bin"
assert env["LC_ALL"] == "C"

@pytest.mark.asyncio
async def test_hook_path_traversal_rejected(self, tmp_path, monkeypatch):
import updater.executor as ex
Expand Down
288 changes: 283 additions & 5 deletions tests/updater/test_hooks_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -70,15 +71,291 @@ def test_set_up_machine_rebuilds(self, tmp_path, repo):
assert not (tmp_path / "run" / "sentinel").exists()


UI_HOOK = HOOK.with_name("BlocksScreen.sh")
HH_HOOK = HOOK.parent / "Happy-Hare.sh"
_NOT_ROOT = pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file modes")
_MOONRAKER_CONF = (
"[server]\nhost: 0.0.0.0\n\n[mmu_server]\nenable_file_preprocessor: True\n"
)


def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args], capture_output=True, text=True, check=True
[
"git",
"-C",
str(repo),
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
*args,
],
capture_output=True,
text=True,
check=True,
).stdout.strip()


@pytest.fixture
def hh_home(tmp_path: Path) -> Path:
home = tmp_path / "home"
hh = home / "Happy-Hare"
(hh / "components").mkdir(parents=True)
(hh / "components" / "mmu_server.py").write_text("v1\n")
_git(hh, "init", "-q")
_git(hh, "add", ".")
_git(hh, "commit", "-qm", "v1")
components = home / "moonraker" / "moonraker" / "components"
components.mkdir(parents=True)
_git(home / "moonraker", "init", "-q")
conf = home / "printer_data" / "config" / "moonraker.conf"
conf.parent.mkdir(parents=True)
conf.write_text(_MOONRAKER_CONF)
return home


def _run_hh_hook(
tmp_path: Path,
home: Path,
prev: str = "",
new: str = "",
hook: Path = HH_HOOK,
**extra: str,
) -> subprocess.CompletedProcess:
"""Run a Happy-Hare/Moonraker hook with a stub sudo that records its argv."""
stub_bin = tmp_path / "stub-bin"
stub_bin.mkdir(exist_ok=True)
stubs = {
"sudo": f'echo "$*" >>{tmp_path}/sudo.log; exit {extra.pop("SUDO_RC", "0")}',
}
for name, body in stubs.items():
(stub_bin / name).write_text(f"#!/bin/bash\n{body}\n")
(stub_bin / name).chmod(0o755)
env = {
"PATH": f"{stub_bin}:{os.environ['PATH']}",
"HOME": str(home),
"COMPONENT_PATH": str(home / "Happy-Hare"),
"PREV_HASH": prev,
"NEW_HASH": new,
**extra,
}
return subprocess.run(
["bash", str(hook)], env=env, capture_output=True, text=True, check=False
)


def _sudo_calls(tmp_path: Path) -> list[str]:
log = tmp_path / "sudo.log"
return log.read_text().splitlines() if log.exists() else []


def _exclude(home: Path) -> list[str]:
return (home / "moonraker" / ".git" / "info" / "exclude").read_text().splitlines()


class TestHappyHareHook:
def test_provision_links_excludes_and_restarts(self, tmp_path, hh_home):
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
assert dst.resolve() == hh_home / "Happy-Hare" / "components" / "mmu_server.py"
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
assert _git(hh_home / "moonraker", "status", "--porcelain") == ""
assert _sudo_calls(tmp_path) == ["-n systemctl restart moonraker.service"]

def test_restart_argv_matches_sudoers_rule(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
root = HOOK.parents[2]
rule = re.search(
r"NOPASSWD: /usr/bin/(systemctl restart) %s",
(root / "scripts" / "install-updater.sh").read_text(),
)
assert rule is not None
manifest = (root / "updater" / "components.yaml").read_text()
assert re.search(r"^\s*service: moonraker\.service$", manifest, re.M)
assert _sudo_calls(tmp_path) == [f"-n {rule[1]} moonraker.service"]

def test_pending_moonraker_restart_skips_restart(self, tmp_path, hh_home):
result = _run_hh_hook(
tmp_path,
hh_home,
BS_UPDATER_PENDING_RESTARTS="klipper.service moonraker.service",
)
assert result.returncode == 0, result.stderr
assert _sudo_calls(tmp_path) == []

def test_other_pending_restarts_still_restart(self, tmp_path, hh_home):
result = _run_hh_hook(
tmp_path, hh_home, BS_UPDATER_PENDING_RESTARTS="klipper.service"
)
assert result.returncode == 0, result.stderr
assert len(_sudo_calls(tmp_path)) == 1

def test_failed_restart_warns_and_links(self, tmp_path, hh_home):
result = _run_hh_hook(tmp_path, hh_home, SUDO_RC="1")
assert result.returncode == 0, result.stderr
assert "WARN: could not restart" in result.stdout
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()

def test_printing_links_without_restart(self, tmp_path, hh_home):
result = _run_hh_hook(tmp_path, hh_home, BS_UPDATER_PRINTING="1")
assert result.returncode == 0, result.stderr
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()
assert _sudo_calls(tmp_path) == []

@_NOT_ROOT
def test_unwritable_exclude_warns_and_links(self, tmp_path, hh_home):
info = hh_home / "moonraker" / ".git" / "info"
info.mkdir(exist_ok=True)
(info / "exclude").write_text("*.pyc\n")
(info / "exclude").chmod(0o444)
try:
result = _run_hh_hook(tmp_path, hh_home)
finally:
(info / "exclude").chmod(0o644)
assert result.returncode == 0, result.stderr
assert "WARN" in result.stdout
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()

def test_unchanged_update_is_idempotent(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
head = _git(hh_home / "Happy-Hare", "rev-parse", "HEAD")
result = _run_hh_hook(tmp_path, hh_home, head, head)
assert result.returncode == 0, result.stderr
assert _exclude(hh_home).count("/moonraker/components/mmu_server.py") == 1
assert len(_sudo_calls(tmp_path)) == 1

def test_changed_mmu_server_restarts(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
hh = hh_home / "Happy-Hare"
prev = _git(hh, "rev-parse", "HEAD")
(hh / "components" / "mmu_server.py").write_text("v2\n")
_git(hh, "commit", "-qam", "v2")
result = _run_hh_hook(tmp_path, hh_home, prev, _git(hh, "rev-parse", "HEAD"))
assert result.returncode == 0, result.stderr
assert len(_sudo_calls(tmp_path)) == 2

def test_no_mmu_server_section_skips_restart(self, tmp_path, hh_home):
(hh_home / "printer_data" / "config" / "moonraker.conf").write_text(
"[server]\n"
)
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert (
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
).is_symlink()
assert _sudo_calls(tmp_path) == []

def test_replaces_copied_file(self, tmp_path, hh_home):
dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
dst.write_text("old copy\n")
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert dst.is_symlink()

def test_exclude_without_trailing_newline(self, tmp_path, hh_home):
exclude = hh_home / "moonraker" / ".git" / "info" / "exclude"
exclude.write_text("*.pyc")
_run_hh_hook(tmp_path, hh_home)
assert _exclude(hh_home) == ["*.pyc", "/moonraker/components/mmu_server.py"]

def test_missing_moonraker_skips(self, tmp_path, hh_home):
subprocess.run(["rm", "-rf", str(hh_home / "moonraker")], check=True)
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert _sudo_calls(tmp_path) == []

def test_missing_source_fails(self, tmp_path, hh_home):
(hh_home / "Happy-Hare" / "components" / "mmu_server.py").unlink()
result = _run_hh_hook(tmp_path, hh_home)
assert result.returncode == 1


MR_HOOK = HOOK.with_name("moonraker.sh")
_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"


class TestMoonrakerHook:
"""A Moonraker reclone must get the mmu_server link back."""

def _run(self, tmp_path: Path, home: Path, prev: str = "abc"):
return _run_hh_hook(
tmp_path,
home,
prev,
hook=MR_HOOK,
COMPONENT_PATH=str(home / "moonraker"),
)

def _dst(self, home: Path) -> Path:
return home / "moonraker" / "moonraker" / "components" / "mmu_server.py"

def test_update_relinks_without_restart(self, tmp_path, hh_home):
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
assert _sudo_calls(tmp_path) == []

def test_boot_reclone_relinks_and_restarts(self, tmp_path, hh_home):
result = self._run(tmp_path, hh_home, _EMPTY_TREE)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()
assert _sudo_calls(tmp_path) == ["-n systemctl restart moonraker.service"]

def test_intact_link_restores_exclude_without_restart(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
exclude = hh_home / "moonraker" / ".git" / "info" / "exclude"
exclude.write_text("")
result = self._run(tmp_path, hh_home, _EMPTY_TREE)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
assert len(_sudo_calls(tmp_path)) == 1

def test_no_happy_hare_skips(self, tmp_path, hh_home):
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert not self._dst(hh_home).exists()

def test_removes_link_left_by_failed_provision(self, tmp_path, hh_home):
_run_hh_hook(tmp_path, hh_home)
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert not self._dst(hh_home).is_symlink()

def test_keeps_foreign_link_without_happy_hare(self, tmp_path, hh_home):
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
self._dst(hh_home).symlink_to(tmp_path / "elsewhere.py")
result = self._run(tmp_path, hh_home)
assert result.returncode == 0, result.stderr
assert self._dst(hh_home).is_symlink()

@_NOT_ROOT
def test_relink_failure_never_fails_hook(self, tmp_path, hh_home):
components = self._dst(hh_home).parent
components.chmod(0o555)
try:
result = self._run(tmp_path, hh_home)
finally:
components.chmod(0o755)
assert result.returncode == 0, result.stderr
assert "WARN" in result.stdout


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


def _commit(repo: Path, files: dict[str, str]) -> str:
for rel, text in files.items():
(repo / rel).parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -152,9 +429,10 @@ 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
_, followup = self._run(tmp_path, ui_repo, first, mid)
followup.write_text(f"{first}\n2\n")
self._run(tmp_path, ui_repo, mid, last)
assert followup.read_text().split() == [first]

def test_outside_updater_no_restart_sentinel(self, tmp_path, ui_repo):
# CLI/manual runs: nothing defers the restart, so none is requested.
Expand Down
Loading