|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import os |
| 6 | +import re |
6 | 7 | import subprocess |
7 | 8 | from pathlib import Path |
8 | 9 |
|
@@ -70,15 +71,291 @@ def test_set_up_machine_rebuilds(self, tmp_path, repo): |
70 | 71 | assert not (tmp_path / "run" / "sentinel").exists() |
71 | 72 |
|
72 | 73 |
|
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 | +) |
74 | 79 |
|
75 | 80 |
|
76 | 81 | def _git(repo: Path, *args: str) -> str: |
77 | 82 | 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, |
79 | 98 | ).stdout.strip() |
80 | 99 |
|
81 | 100 |
|
| 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 | + |
82 | 359 | def _commit(repo: Path, files: dict[str, str]) -> str: |
83 | 360 | for rel, text in files.items(): |
84 | 361 | (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): |
152 | 429 | first = _commit(ui_repo, {"updater/components.yaml": "a: 1\n"}) |
153 | 430 | mid = _commit(ui_repo, {"updater/components.yaml": "a: 2\n"}) |
154 | 431 | 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] |
158 | 436 |
|
159 | 437 | def test_outside_updater_no_restart_sentinel(self, tmp_path, ui_repo): |
160 | 438 | # CLI/manual runs: nothing defers the restart, so none is requested. |
|
0 commit comments