Skip to content

Commit 83deefe

Browse files
authored
fix(updater): install Happy-Hare mmu_server Moonraker component (#366)
1 parent a192767 commit 83deefe

12 files changed

Lines changed: 860 additions & 33 deletions

‎tests/updater/test_components_unit.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,3 +545,15 @@ def test_origin_prefixed_branch_is_stripped(caplog):
545545
bs = next(c for c in configs if c.name == "BlocksScreen")
546546
assert bs.branch == "Release-2.0" # stray origin/ prefix stripped
547547
assert any("origin/" in r.message for r in caplog.records)
548+
549+
550+
def test_shipped_happy_hare_is_pinned_without_service():
551+
with patch("updater.components.OVERRIDE_PATH", Path("/nonexistent/override.yaml")):
552+
configs, _ = load_components()
553+
hh = next(c for c in configs if c.name == "Happy-Hare")
554+
assert hh.install_if_missing and hh.url
555+
assert hh.version == "d5cce9f96991b270ff570b7497bbf6b4463a82b9"
556+
assert (hh.branch, hh.reset_mode) == ("v3", "hard")
557+
assert hh.service is None
558+
names = [c.name for c in configs]
559+
assert names.index("moonraker") < names.index("Happy-Hare")

‎tests/updater/test_dbus_service_unit.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,16 @@ async def test_followup_that_ran_sweeps_apt(self, svc):
647647
svc._svc.background_apt_upgrade.assert_awaited_once()
648648
assert svc._followed_up is True
649649

650+
@pytest.mark.asyncio
651+
async def test_retrying_followup_emits_status_without_apt(self, svc):
652+
svc._provisioned = True
653+
svc._emit_status = AsyncMock()
654+
svc._svc.run_followup = AsyncMock(return_value=(True, True))
655+
await svc._followup_pass()
656+
svc._emit_status.assert_awaited_once()
657+
svc._svc.background_apt_upgrade.assert_not_called()
658+
assert svc._followed_up is False
659+
650660
@pytest.mark.asyncio
651661
async def test_followup_skips_apt_when_restart_pending(self, svc):
652662
svc._provisioned = True

‎tests/updater/test_executor_unit.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,25 @@ async def test_run_hook_default_timeout(self, tmp_path, monkeypatch):
12521252
await run_hook("comp", tmp_path, "n", "p")
12531253
assert run.await_args.kwargs["timeout"] == 60.0
12541254

1255+
@pytest.mark.asyncio
1256+
async def test_extra_env_never_overrides_component_vars(
1257+
self, tmp_path, monkeypatch
1258+
):
1259+
import updater.executor as ex
1260+
1261+
monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path)
1262+
monkeypatch.setenv("PATH", "/usr/bin")
1263+
(tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n")
1264+
(tmp_path / "comp.sh").chmod(0o755)
1265+
extra = {"X": "1", "NEW_HASH": "bad", "PATH": "/tmp/evil", "LC_ALL": "xx"}
1266+
with patch.object(ex, "_run", new=AsyncMock(return_value=(True, ""))) as run:
1267+
await run_hook("comp", tmp_path, "n", "p", extra_env=extra)
1268+
env = run.await_args.kwargs["env"]
1269+
assert env["X"] == "1"
1270+
assert env["NEW_HASH"] == "n"
1271+
assert env["PATH"] == "/usr/bin"
1272+
assert env["LC_ALL"] == "C"
1273+
12551274
@pytest.mark.asyncio
12561275
async def test_hook_path_traversal_rejected(self, tmp_path, monkeypatch):
12571276
import updater.executor as ex

‎tests/updater/test_hooks_unit.py‎

Lines changed: 283 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import os
6+
import re
67
import subprocess
78
from pathlib import Path
89

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

7273

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

7580

7681
def _git(repo: Path, *args: str) -> str:
7782
return subprocess.run(
78-
["git", "-C", str(repo), *args], capture_output=True, text=True, check=True
83+
[
84+
"git",
85+
"-C",
86+
str(repo),
87+
"-c",
88+
"user.name=t",
89+
"-c",
90+
"user.email=t@t",
91+
"-c",
92+
"commit.gpgsign=false",
93+
*args,
94+
],
95+
capture_output=True,
96+
text=True,
97+
check=True,
7998
).stdout.strip()
8099

81100

101+
@pytest.fixture
102+
def hh_home(tmp_path: Path) -> Path:
103+
home = tmp_path / "home"
104+
hh = home / "Happy-Hare"
105+
(hh / "components").mkdir(parents=True)
106+
(hh / "components" / "mmu_server.py").write_text("v1\n")
107+
_git(hh, "init", "-q")
108+
_git(hh, "add", ".")
109+
_git(hh, "commit", "-qm", "v1")
110+
components = home / "moonraker" / "moonraker" / "components"
111+
components.mkdir(parents=True)
112+
_git(home / "moonraker", "init", "-q")
113+
conf = home / "printer_data" / "config" / "moonraker.conf"
114+
conf.parent.mkdir(parents=True)
115+
conf.write_text(_MOONRAKER_CONF)
116+
return home
117+
118+
119+
def _run_hh_hook(
120+
tmp_path: Path,
121+
home: Path,
122+
prev: str = "",
123+
new: str = "",
124+
hook: Path = HH_HOOK,
125+
**extra: str,
126+
) -> subprocess.CompletedProcess:
127+
"""Run a Happy-Hare/Moonraker hook with a stub sudo that records its argv."""
128+
stub_bin = tmp_path / "stub-bin"
129+
stub_bin.mkdir(exist_ok=True)
130+
stubs = {
131+
"sudo": f'echo "$*" >>{tmp_path}/sudo.log; exit {extra.pop("SUDO_RC", "0")}',
132+
}
133+
for name, body in stubs.items():
134+
(stub_bin / name).write_text(f"#!/bin/bash\n{body}\n")
135+
(stub_bin / name).chmod(0o755)
136+
env = {
137+
"PATH": f"{stub_bin}:{os.environ['PATH']}",
138+
"HOME": str(home),
139+
"COMPONENT_PATH": str(home / "Happy-Hare"),
140+
"PREV_HASH": prev,
141+
"NEW_HASH": new,
142+
**extra,
143+
}
144+
return subprocess.run(
145+
["bash", str(hook)], env=env, capture_output=True, text=True, check=False
146+
)
147+
148+
149+
def _sudo_calls(tmp_path: Path) -> list[str]:
150+
log = tmp_path / "sudo.log"
151+
return log.read_text().splitlines() if log.exists() else []
152+
153+
154+
def _exclude(home: Path) -> list[str]:
155+
return (home / "moonraker" / ".git" / "info" / "exclude").read_text().splitlines()
156+
157+
158+
class TestHappyHareHook:
159+
def test_provision_links_excludes_and_restarts(self, tmp_path, hh_home):
160+
result = _run_hh_hook(tmp_path, hh_home)
161+
assert result.returncode == 0, result.stderr
162+
dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
163+
assert dst.resolve() == hh_home / "Happy-Hare" / "components" / "mmu_server.py"
164+
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
165+
assert _git(hh_home / "moonraker", "status", "--porcelain") == ""
166+
assert _sudo_calls(tmp_path) == ["-n systemctl restart moonraker.service"]
167+
168+
def test_restart_argv_matches_sudoers_rule(self, tmp_path, hh_home):
169+
_run_hh_hook(tmp_path, hh_home)
170+
root = HOOK.parents[2]
171+
rule = re.search(
172+
r"NOPASSWD: /usr/bin/(systemctl restart) %s",
173+
(root / "scripts" / "install-updater.sh").read_text(),
174+
)
175+
assert rule is not None
176+
manifest = (root / "updater" / "components.yaml").read_text()
177+
assert re.search(r"^\s*service: moonraker\.service$", manifest, re.M)
178+
assert _sudo_calls(tmp_path) == [f"-n {rule[1]} moonraker.service"]
179+
180+
def test_pending_moonraker_restart_skips_restart(self, tmp_path, hh_home):
181+
result = _run_hh_hook(
182+
tmp_path,
183+
hh_home,
184+
BS_UPDATER_PENDING_RESTARTS="klipper.service moonraker.service",
185+
)
186+
assert result.returncode == 0, result.stderr
187+
assert _sudo_calls(tmp_path) == []
188+
189+
def test_other_pending_restarts_still_restart(self, tmp_path, hh_home):
190+
result = _run_hh_hook(
191+
tmp_path, hh_home, BS_UPDATER_PENDING_RESTARTS="klipper.service"
192+
)
193+
assert result.returncode == 0, result.stderr
194+
assert len(_sudo_calls(tmp_path)) == 1
195+
196+
def test_failed_restart_warns_and_links(self, tmp_path, hh_home):
197+
result = _run_hh_hook(tmp_path, hh_home, SUDO_RC="1")
198+
assert result.returncode == 0, result.stderr
199+
assert "WARN: could not restart" in result.stdout
200+
assert (
201+
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
202+
).is_symlink()
203+
204+
def test_printing_links_without_restart(self, tmp_path, hh_home):
205+
result = _run_hh_hook(tmp_path, hh_home, BS_UPDATER_PRINTING="1")
206+
assert result.returncode == 0, result.stderr
207+
assert (
208+
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
209+
).is_symlink()
210+
assert _sudo_calls(tmp_path) == []
211+
212+
@_NOT_ROOT
213+
def test_unwritable_exclude_warns_and_links(self, tmp_path, hh_home):
214+
info = hh_home / "moonraker" / ".git" / "info"
215+
info.mkdir(exist_ok=True)
216+
(info / "exclude").write_text("*.pyc\n")
217+
(info / "exclude").chmod(0o444)
218+
try:
219+
result = _run_hh_hook(tmp_path, hh_home)
220+
finally:
221+
(info / "exclude").chmod(0o644)
222+
assert result.returncode == 0, result.stderr
223+
assert "WARN" in result.stdout
224+
assert (
225+
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
226+
).is_symlink()
227+
228+
def test_unchanged_update_is_idempotent(self, tmp_path, hh_home):
229+
_run_hh_hook(tmp_path, hh_home)
230+
head = _git(hh_home / "Happy-Hare", "rev-parse", "HEAD")
231+
result = _run_hh_hook(tmp_path, hh_home, head, head)
232+
assert result.returncode == 0, result.stderr
233+
assert _exclude(hh_home).count("/moonraker/components/mmu_server.py") == 1
234+
assert len(_sudo_calls(tmp_path)) == 1
235+
236+
def test_changed_mmu_server_restarts(self, tmp_path, hh_home):
237+
_run_hh_hook(tmp_path, hh_home)
238+
hh = hh_home / "Happy-Hare"
239+
prev = _git(hh, "rev-parse", "HEAD")
240+
(hh / "components" / "mmu_server.py").write_text("v2\n")
241+
_git(hh, "commit", "-qam", "v2")
242+
result = _run_hh_hook(tmp_path, hh_home, prev, _git(hh, "rev-parse", "HEAD"))
243+
assert result.returncode == 0, result.stderr
244+
assert len(_sudo_calls(tmp_path)) == 2
245+
246+
def test_no_mmu_server_section_skips_restart(self, tmp_path, hh_home):
247+
(hh_home / "printer_data" / "config" / "moonraker.conf").write_text(
248+
"[server]\n"
249+
)
250+
result = _run_hh_hook(tmp_path, hh_home)
251+
assert result.returncode == 0, result.stderr
252+
assert (
253+
hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
254+
).is_symlink()
255+
assert _sudo_calls(tmp_path) == []
256+
257+
def test_replaces_copied_file(self, tmp_path, hh_home):
258+
dst = hh_home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
259+
dst.write_text("old copy\n")
260+
result = _run_hh_hook(tmp_path, hh_home)
261+
assert result.returncode == 0, result.stderr
262+
assert dst.is_symlink()
263+
264+
def test_exclude_without_trailing_newline(self, tmp_path, hh_home):
265+
exclude = hh_home / "moonraker" / ".git" / "info" / "exclude"
266+
exclude.write_text("*.pyc")
267+
_run_hh_hook(tmp_path, hh_home)
268+
assert _exclude(hh_home) == ["*.pyc", "/moonraker/components/mmu_server.py"]
269+
270+
def test_missing_moonraker_skips(self, tmp_path, hh_home):
271+
subprocess.run(["rm", "-rf", str(hh_home / "moonraker")], check=True)
272+
result = _run_hh_hook(tmp_path, hh_home)
273+
assert result.returncode == 0, result.stderr
274+
assert _sudo_calls(tmp_path) == []
275+
276+
def test_missing_source_fails(self, tmp_path, hh_home):
277+
(hh_home / "Happy-Hare" / "components" / "mmu_server.py").unlink()
278+
result = _run_hh_hook(tmp_path, hh_home)
279+
assert result.returncode == 1
280+
281+
282+
MR_HOOK = HOOK.with_name("moonraker.sh")
283+
_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
284+
285+
286+
class TestMoonrakerHook:
287+
"""A Moonraker reclone must get the mmu_server link back."""
288+
289+
def _run(self, tmp_path: Path, home: Path, prev: str = "abc"):
290+
return _run_hh_hook(
291+
tmp_path,
292+
home,
293+
prev,
294+
hook=MR_HOOK,
295+
COMPONENT_PATH=str(home / "moonraker"),
296+
)
297+
298+
def _dst(self, home: Path) -> Path:
299+
return home / "moonraker" / "moonraker" / "components" / "mmu_server.py"
300+
301+
def test_update_relinks_without_restart(self, tmp_path, hh_home):
302+
result = self._run(tmp_path, hh_home)
303+
assert result.returncode == 0, result.stderr
304+
assert self._dst(hh_home).is_symlink()
305+
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
306+
assert _sudo_calls(tmp_path) == []
307+
308+
def test_boot_reclone_relinks_and_restarts(self, tmp_path, hh_home):
309+
result = self._run(tmp_path, hh_home, _EMPTY_TREE)
310+
assert result.returncode == 0, result.stderr
311+
assert self._dst(hh_home).is_symlink()
312+
assert _sudo_calls(tmp_path) == ["-n systemctl restart moonraker.service"]
313+
314+
def test_intact_link_restores_exclude_without_restart(self, tmp_path, hh_home):
315+
_run_hh_hook(tmp_path, hh_home)
316+
exclude = hh_home / "moonraker" / ".git" / "info" / "exclude"
317+
exclude.write_text("")
318+
result = self._run(tmp_path, hh_home, _EMPTY_TREE)
319+
assert result.returncode == 0, result.stderr
320+
assert self._dst(hh_home).is_symlink()
321+
assert "/moonraker/components/mmu_server.py" in _exclude(hh_home)
322+
assert len(_sudo_calls(tmp_path)) == 1
323+
324+
def test_no_happy_hare_skips(self, tmp_path, hh_home):
325+
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
326+
result = self._run(tmp_path, hh_home)
327+
assert result.returncode == 0, result.stderr
328+
assert not self._dst(hh_home).exists()
329+
330+
def test_removes_link_left_by_failed_provision(self, tmp_path, hh_home):
331+
_run_hh_hook(tmp_path, hh_home)
332+
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
333+
result = self._run(tmp_path, hh_home)
334+
assert result.returncode == 0, result.stderr
335+
assert not self._dst(hh_home).is_symlink()
336+
337+
def test_keeps_foreign_link_without_happy_hare(self, tmp_path, hh_home):
338+
subprocess.run(["rm", "-rf", str(hh_home / "Happy-Hare")], check=True)
339+
self._dst(hh_home).symlink_to(tmp_path / "elsewhere.py")
340+
result = self._run(tmp_path, hh_home)
341+
assert result.returncode == 0, result.stderr
342+
assert self._dst(hh_home).is_symlink()
343+
344+
@_NOT_ROOT
345+
def test_relink_failure_never_fails_hook(self, tmp_path, hh_home):
346+
components = self._dst(hh_home).parent
347+
components.chmod(0o555)
348+
try:
349+
result = self._run(tmp_path, hh_home)
350+
finally:
351+
components.chmod(0o755)
352+
assert result.returncode == 0, result.stderr
353+
assert "WARN" in result.stdout
354+
355+
356+
UI_HOOK = HOOK.with_name("BlocksScreen.sh")
357+
358+
82359
def _commit(repo: Path, files: dict[str, str]) -> str:
83360
for rel, text in files.items():
84361
(repo / rel).parent.mkdir(parents=True, exist_ok=True)
@@ -152,9 +429,10 @@ def test_chained_updates_keep_oldest_base(self, tmp_path, ui_repo):
152429
first = _commit(ui_repo, {"updater/components.yaml": "a: 1\n"})
153430
mid = _commit(ui_repo, {"updater/components.yaml": "a: 2\n"})
154431
last = _commit(ui_repo, {"updater/components.yaml": "a: 3\n"})
155-
self._run(tmp_path, ui_repo, first, mid)
156-
_, followup = self._run(tmp_path, ui_repo, mid, last)
157-
assert followup.read_text().strip() == first
432+
_, followup = self._run(tmp_path, ui_repo, first, mid)
433+
followup.write_text(f"{first}\n2\n")
434+
self._run(tmp_path, ui_repo, mid, last)
435+
assert followup.read_text().split() == [first]
158436

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

0 commit comments

Comments
 (0)