feat(moonraker): support headless install/remove/update

Add interactive parameter to MoonrakerSetupService.install/remove/update and

headless tests for the service.

Add unit tests for moonraker utility functions.
This commit is contained in:
dw-0
2026-07-11 17:49:45 +02:00
parent 58e3eb9f06
commit adf3e292fb
6 changed files with 1122 additions and 142 deletions
@@ -8,8 +8,9 @@
# ======================================================================= #
from __future__ import annotations
import traceback
from copy import copy
from subprocess import DEVNULL, PIPE, CalledProcessError, run
from subprocess import DEVNULL, PIPE, run
from typing import List
from components.klipper.klipper import Klipper
@@ -104,27 +105,42 @@ class MoonrakerSetupService:
self.msgsvc = MessageService()
def __refresh_state(self) -> None:
def _refresh_state(self) -> None:
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
def install(self) -> None:
self.__refresh_state()
def install(
self,
klipper_suffixes: List[str] | None = None,
create_example_cfg: bool | None = None,
interactive: bool = True,
) -> bool:
"""Install Moonraker.
if not self.__check_requirements(self.klipper_list):
return
When called from the TUI, the Klipper instance is selected interactively.
The CLI passes explicit suffixes and ``interactive=False``.
Returns ``True`` on success and ``False`` when installation cannot proceed.
"""
self._refresh_state()
if not self._check_requirements(self.klipper_list):
return False
new_instances: List[Moonraker] = []
if klipper_suffixes is not None:
for suffix in klipper_suffixes:
new_instances.append(self.misvc.create_new_instance(suffix))
elif interactive:
selected_option: str | Klipper
if len(self.klipper_list) == 1:
suffix: str = self.klipper_list[0].suffix
new_inst = self.misvc.create_new_instance(suffix)
new_instances.append(new_inst)
selected_suffix: str = self.klipper_list[0].suffix
new_instances.append(self.misvc.create_new_instance(selected_suffix))
else:
print_moonraker_overview(
self.klipper_list,
@@ -140,29 +156,48 @@ class MoonrakerSetupService:
if selected_option == "b":
Logger.print_status(EXIT_MOONRAKER_SETUP)
return
return True
if selected_option == "a":
new_inst_list: List[Moonraker] = [
self.misvc.create_new_instance(k.suffix) for k in self.klipper_list
self.misvc.create_new_instance(k.suffix)
for k in self.klipper_list
]
new_instances.extend(new_inst_list)
else:
klipper_instance: Klipper | None = options.get(selected_option)
if klipper_instance is None:
raise Exception("Error selecting instance!")
new_inst = self.misvc.create_new_instance(klipper_instance.suffix)
new_instances.append(new_inst)
new_instances.append(
self.misvc.create_new_instance(klipper_instance.suffix)
)
else:
for k in self.klipper_list:
new_instances.append(self.misvc.create_new_instance(k.suffix))
create_example_cfg = get_confirm("Create example moonraker.conf?")
if create_example_cfg is None:
create_example_cfg = (
get_confirm("Create example moonraker.conf?") if interactive else False
)
try:
self.__run_setup(new_instances, create_example_cfg)
except Exception as e:
Logger.print_error(f"Error while installing Moonraker: {e}")
return
self._run_setup(new_instances, create_example_cfg, interactive=interactive)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while installing Moonraker!")
return False
def update(self) -> None:
return True
def update(self, interactive: bool = True) -> bool:
"""Update Moonraker.
When called from the TUI, a warning and confirmation are shown. The CLI
passes ``interactive=False`` to run silently.
Returns ``True`` on success and ``False`` if the update could not be completed.
"""
if interactive:
Logger.print_dialog(
DialogType.WARNING,
[
@@ -173,10 +208,11 @@ class MoonrakerSetupService:
)
if not get_confirm("Update Moonraker now?"):
return
return False
self.__refresh_state()
self._refresh_state()
try:
if self.settings.kiauh.backup_before_update:
backup_moonraker_dir()
@@ -185,6 +221,12 @@ class MoonrakerSetupService:
install_moonraker_packages()
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
InstanceManager.start_all(self.moonraker_list)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while updating Moonraker!")
return False
return True
def remove(
self,
@@ -192,9 +234,26 @@ class MoonrakerSetupService:
remove_dir: bool,
remove_env: bool,
remove_polkit: bool,
) -> None:
self.__refresh_state()
*,
remove_all: bool = False,
instance_suffixes: List[str] | None = None,
interactive: bool = True,
) -> bool:
"""Remove Moonraker.
When called from the TUI, the user selects instances interactively. In
headless mode (``interactive=False``) the caller MUST express explicit
intent: pass ``remove_all=True`` to wipe every instance or
``instance_suffixes=[...]`` to remove a named subset. Without explicit
intent the service refuses and removes nothing, so a CLI user can never
accidentally destroy every Moonraker instance.
Returns ``True`` on success and ``False`` if removal could not be completed.
"""
self._refresh_state()
try:
if interactive:
completion_msg = Message(
title="Moonraker Removal Process completed",
color=Color.GREEN,
@@ -203,16 +262,18 @@ class MoonrakerSetupService:
if remove_service:
Logger.print_status("Removing Moonraker instances ...")
if self.moonraker_list:
instances_to_remove = self.__get_instances_to_remove()
self.__remove_instances(instances_to_remove)
if instances_to_remove:
selected = self._get_instances_to_remove()
self.__remove_instances(selected)
if selected:
instance_names = [
i.service_file_path.stem for i in instances_to_remove
i.service_file_path.stem for i in selected
]
txt = f"● Moonraker instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
Logger.print_info("No Moonraker Services installed! Skipped ...")
Logger.print_info(
"No Moonraker Services installed! Skipped ..."
)
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
"moonraker", suffix="service"
@@ -225,32 +286,80 @@ class MoonrakerSetupService:
]
else:
if remove_polkit:
Logger.print_status("Removing all Moonraker policykit rules ...")
Logger.print_status(
"Removing all Moonraker policykit rules ..."
)
if remove_polkit_rules():
completion_msg.text.append("● Moonraker policykit rules removed")
completion_msg.text.append(
"● Moonraker policykit rules removed"
)
if remove_dir:
Logger.print_status("Removing Moonraker local repository ...")
if run_remove_routines(MOONRAKER_DIR):
completion_msg.text.append("● Moonraker local repository removed")
completion_msg.text.append(
"● Moonraker local repository removed"
)
if remove_env:
Logger.print_status("Removing Moonraker Python environment ...")
if run_remove_routines(MOONRAKER_ENV_DIR):
completion_msg.text.append("● Moonraker Python environment removed")
completion_msg.text.append(
"● Moonraker Python environment removed"
)
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
completion_msg.text.insert(
0, "The following actions were performed:"
)
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
self.msgsvc.set_message(completion_msg)
else:
if remove_service and self.moonraker_list:
selected = self._select_instances_for_headless_removal(
remove_all, instance_suffixes
)
if selected is None:
Logger.print_error(
"Refusing to remove Moonraker instances: no explicit "
"intent. Pass remove_all=True or instance_suffixes."
)
return False
self.__remove_instances(selected)
def __run_setup(
self, new_instances: List[Moonraker], create_example_cfg: bool
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
"moonraker", suffix="service"
):
Logger.print_info(
"Moonraker services still installed; skipping repository/env removal."
)
return True
if remove_polkit:
remove_polkit_rules()
if remove_dir:
run_remove_routines(MOONRAKER_DIR)
if remove_env:
run_remove_routines(MOONRAKER_ENV_DIR)
except Exception:
Logger.print_error(traceback.format_exc())
Logger.print_error("Error while removing Moonraker!")
return False
return True
def _run_setup(
self,
new_instances: List[Moonraker],
create_example_cfg: bool,
interactive: bool = True,
) -> None:
check_install_dependencies()
self.__install_deps()
# Only create a fresh venv when none exists; existing venvs are
# preserved in both TUI and CLI modes.
self._install_deps(interactive=interactive)
ports_map = self.misvc.get_instance_port_map()
for i in new_instances:
@@ -289,14 +398,21 @@ class MoonrakerSetupService:
dialog_content.append("You can access Moonraker via the following URL:")
dialog_content.extend(url_list)
if interactive:
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Moonraker successfully installed!",
custom_color=Color.GREEN,
content=dialog_content,
)
else:
if url_list:
for url in url_list:
Logger.print_info(url)
else:
Logger.print_info("Moonraker successfully installed!")
def __check_requirements(self, klipper_list: List[Klipper]) -> bool:
def _check_requirements(self, klipper_list: List[Klipper]) -> bool:
is_klipper_installed = len(klipper_list) >= 1
if not is_klipper_installed:
Logger.print_warn("Klipper not installed!")
@@ -306,7 +422,7 @@ class MoonrakerSetupService:
return is_klipper_installed and is_python_ok
def __install_deps(self) -> None:
def _install_deps(self, interactive: bool = True) -> None:
default_repo = (MOONRAKER_REPO_URL, "master")
repo = self.settings.moonraker.repositories
# pull the first repo defined in kiauh.cfg or fallback to the official Moonraker repo
@@ -315,18 +431,24 @@ class MoonrakerSetupService:
try:
install_moonraker_packages()
if create_python_venv(MOONRAKER_ENV_DIR, False, False, self.settings.moonraker.use_python_binary):
if create_python_venv(
MOONRAKER_ENV_DIR,
force=False,
allow_access_to_system_site_packages=False,
use_python_binary=self.settings.moonraker.use_python_binary,
interactive=interactive,
):
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
if self.settings.moonraker.optional_speedups:
install_python_requirements(
MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE
)
self.__install_polkit()
self._install_polkit()
except Exception:
Logger.print_error("Error during installation of Moonraker requirements!")
raise
def __install_polkit(self) -> None:
def _install_polkit(self) -> None:
Logger.print_status("Installing Moonraker policykit rules ...")
legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True)
@@ -337,7 +459,6 @@ class MoonrakerSetupService:
Logger.print_info("Moonraker policykit rules are already installed.")
return
try:
command = [POLKIT_SCRIPT, "--disable-systemctl"]
result = run(
command,
@@ -348,16 +469,13 @@ class MoonrakerSetupService:
if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False)
Logger.print_error("Installing Moonraker policykit rules failed!")
# Intentional fail-soft: polkit rules are optional on many systems
# and a failure here must not abort the whole Moonraker installation.
return
Logger.print_ok("Moonraker policykit rules successfully installed!")
except CalledProcessError as e:
log = (
f"Error while installing Moonraker policykit rules: {e.stderr.decode()}"
)
Logger.print_error(log)
def __get_instances_to_remove(self) -> List[Moonraker] | None:
def _get_instances_to_remove(self) -> List[Moonraker] | None:
start_index = 1
curr_instances: List[Moonraker] = self.moonraker_list
instance_count = len(curr_instances)
@@ -383,6 +501,26 @@ class MoonrakerSetupService:
return [instance_map[selection]]
def _select_instances_for_headless_removal(
self,
remove_all: bool,
instance_suffixes: List[str] | None,
) -> List[Moonraker] | None:
"""Resolve which instances to remove in headless mode.
Returns the list of instances to remove, or ``None`` when the caller did
not express explicit intent (no ``remove_all`` and no ``instance_suffixes``).
A ``None`` return is the "refuse to wipe everything" signal the CLI path
relies on. Kept as a single-public-seam helper (no name mangling) so
tests can patch it without brittle ``_Class__method`` access.
"""
if remove_all:
return list(self.moonraker_list)
if instance_suffixes:
wanted = set(instance_suffixes)
return [i for i in self.moonraker_list if i.suffix in wanted]
return None
def __remove_instances(
self,
instance_list: List[Moonraker] | None,
@@ -397,7 +535,7 @@ class MoonrakerSetupService:
InstanceManager.remove(instance)
self.__delete_env_file(instance)
self.__refresh_state()
self._refresh_state()
def __delete_env_file(self, instance: Moonraker):
Logger.print_status(f"Remove '{instance.env_file}'")
@@ -0,0 +1,4 @@
from __future__ import annotations
@@ -0,0 +1,560 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import pytest
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
class FakeKlipper:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
name = f"klipper-{suffix}" if suffix else "klipper"
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
class FakeMoonraker:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
name = f"moonraker-{suffix}" if suffix else "moonraker"
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
self.env_file = Path(f"/tmp/{name}.env")
self.port = 7125
self.base = type("Base", (), {"sysd_dir": Path("/tmp")})()
def create(self) -> None:
pass
class FakeKlipperInstanceService:
def __init__(self, instances: List[FakeKlipper]) -> None:
self._instances = instances
def load_instances(self) -> None:
pass
def get_all_instances(self) -> List[FakeKlipper]:
return self._instances
class FakeMoonrakerInstanceService:
def __init__(self, instances: List[FakeMoonraker]) -> None:
self._instances = instances
self.created: List[str] = []
def load_instances(self) -> None:
pass
def get_all_instances(self) -> List[FakeMoonraker]:
return self._instances
def create_new_instance(self, suffix: str) -> FakeMoonraker:
self.created.append(suffix)
return FakeMoonraker(suffix)
def get_instance_by_suffix(self, suffix: str) -> FakeMoonraker:
return FakeMoonraker(suffix)
def get_instance_port_map(self) -> Dict[str, int]:
return {}
@pytest.fixture
def reset_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
MoonrakerSetupService, "_MoonrakerSetupService__cls_instance", None
)
@pytest.fixture
def patch_instance_services(
monkeypatch: pytest.MonkeyPatch, reset_service
) -> Dict[str, Any]:
state = {"klipper": [], "moonraker": []}
def make_kis(*args, **kwargs):
return FakeKlipperInstanceService(state["klipper"])
def make_mis(*args, **kwargs):
return FakeMoonrakerInstanceService(state["moonraker"])
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(f"{module}.KlipperInstanceService", make_kis)
monkeypatch.setattr(f"{module}.MoonrakerInstanceService", make_mis)
return state
class TestMoonrakerInstall:
def test_installs_for_single_klipper_instance(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
service = MoonrakerSetupService()
service.install()
assert len(setup_calls) == 1
instances, cfg, _interactive = setup_calls[0]
assert len(instances) == 1
assert instances[0].suffix == ""
assert cfg is True
def test_installs_for_selected_klipper_instance(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
monkeypatch.setattr(f"{module}.get_selection_input", lambda *a, **k: "1")
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
service = MoonrakerSetupService()
service.install()
assert len(setup_calls) == 1
assert setup_calls[0][0][0].suffix == "a"
class TestMoonrakerUpdate:
def test_update_runs_expected_steps(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
module = "components.moonraker.services.moonraker_setup_service"
calls: List[str] = []
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
monkeypatch.setattr(
f"{module}.backup_moonraker_dir", lambda: calls.append("backup")
)
monkeypatch.setattr(
f"{module}.InstanceManager.stop_all",
staticmethod(lambda instances: calls.append("stop")),
)
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
)
monkeypatch.setattr(
f"{module}.install_moonraker_packages", lambda: calls.append("packages")
)
monkeypatch.setattr(
f"{module}.install_python_requirements",
lambda *a, **k: calls.append("requirements"),
)
monkeypatch.setattr(
f"{module}.InstanceManager.start_all",
staticmethod(lambda instances: calls.append("start")),
)
service = MoonrakerSetupService()
service.settings.kiauh.backup_before_update = True
service.update()
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
class TestMoonrakerRemove:
def test_removes_selected_instance(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
module = "components.moonraker.services.moonraker_setup_service"
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
fake_instance = FakeMoonraker("")
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._get_instances_to_remove",
lambda self: [fake_instance],
)
monkeypatch.setattr(
f"{module}.InstanceManager.remove",
staticmethod(lambda instance: removed["instances"].append(instance)),
)
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
monkeypatch.setattr(f"{module}.remove_polkit_rules", lambda: True)
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda path: removed["paths"].append(str(path)) or True,
)
service = MoonrakerSetupService()
service.remove(
remove_service=True, remove_dir=True, remove_env=True, remove_polkit=True
)
assert removed["instances"] == [fake_instance]
assert any("moonraker" in p for p in removed["paths"])
class TestMoonrakerInstallHeadless:
def test_headless_install_does_not_show_success_dialog(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._install_deps",
lambda self, interactive: None,
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
monkeypatch.setattr(f"{module}.cmd_sysctl_service", lambda *a, **k: None)
monkeypatch.setattr(f"{module}.cmd_sysctl_manage", lambda *a, **k: None)
monkeypatch.setattr(
f"{module}.check_install_dependencies", lambda *a, **k: None
)
monkeypatch.setattr(f"{module}.get_ipv4_addr", lambda: "127.0.0.1")
monkeypatch.setattr(
f"{module}.Logger.print_dialog",
lambda *a, **k: pytest.fail("should not show dialog in headless install"),
)
errors: List[str] = []
monkeypatch.setattr(
f"{module}.Logger.print_error",
lambda msg, *a, **k: errors.append(str(msg)),
)
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert errors == [], f"unexpected errors: {errors}"
assert result is True
def test_installs_with_explicit_klipper_suffixes(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
service = MoonrakerSetupService()
result = service.install(klipper_suffixes=["a", "b"], interactive=False)
assert result is True
assert len(setup_calls) == 1
instances, cfg, interactive = setup_calls[0]
assert [i.suffix for i in instances] == ["a", "b"]
assert cfg is False
assert interactive is False
def test_installs_for_all_klipper_instances_when_non_interactive(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
setup_calls: List[Any] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert result is True
assert [i.suffix for i in setup_calls[0][0]] == ["a", "b"]
assert setup_calls[0][2] is False
def test_returns_false_when_klipper_is_missing(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = []
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert result is False
def test_returns_false_when_setup_raises(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
)
service = MoonrakerSetupService()
result = service.install(interactive=False)
assert result is False
class TestMoonrakerUpdateHeadless:
def test_update_runs_without_confirmation(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
module = "components.moonraker.services.moonraker_setup_service"
calls: List[str] = []
monkeypatch.setattr(
f"{module}.backup_moonraker_dir", lambda: calls.append("backup")
)
monkeypatch.setattr(
f"{module}.InstanceManager.stop_all",
staticmethod(lambda instances: calls.append("stop")),
)
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
)
monkeypatch.setattr(
f"{module}.install_moonraker_packages", lambda: calls.append("packages")
)
monkeypatch.setattr(
f"{module}.install_python_requirements",
lambda *a, **k: calls.append("requirements"),
)
monkeypatch.setattr(
f"{module}.InstanceManager.start_all",
staticmethod(lambda instances: calls.append("start")),
)
service = MoonrakerSetupService()
service.settings.kiauh.backup_before_update = True
service.update(interactive=False)
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
def test_update_cancelled_by_user_returns_false(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("")]
pulled: List[str] = []
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
service = MoonrakerSetupService()
result = service.update(interactive=True)
assert result is False
assert pulled == []
class TestMoonrakerPolkitBehavior:
def test_install_polkit_failure_logs_error_and_continues(
self, patch_instance_services, monkeypatch
) -> None:
module = "components.moonraker.services.moonraker_setup_service"
class FakeResult:
returncode = 1
stderr = "polkit install failed"
monkeypatch.setattr(f"{module}.run", lambda *a, **k: FakeResult())
monkeypatch.setattr(
f"{module}.check_file_exist", lambda p, follow_symlinks=False: False
)
error_messages: List[str] = []
monkeypatch.setattr(
f"{module}.Logger.print_error",
lambda msg, *a, **k: error_messages.append(str(msg)),
)
service = MoonrakerSetupService()
service._install_polkit()
assert any("polkit" in m.lower() for m in error_messages)
def test_install_succeeds_when_polkit_rules_fail(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["klipper"] = [FakeKlipper("")]
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._check_requirements",
lambda self, kl: True,
)
setup_calls: List[Any] = []
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._run_setup",
lambda self, instances, cfg, interactive=True: setup_calls.append((
instances,
cfg,
interactive,
)),
)
class FakeResult:
returncode = 1
stderr = "polkit install failed"
monkeypatch.setattr(f"{module}.run", lambda *a, **k: FakeResult())
monkeypatch.setattr(
f"{module}.check_file_exist", lambda p, follow_symlinks=False: False
)
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
def fake_install_deps(self, interactive: bool = True) -> None:
self._install_polkit()
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._install_deps",
fake_install_deps,
)
service = MoonrakerSetupService()
result = service.install()
assert result is True
assert len(setup_calls) == 1
class TestMoonrakerRemoveHeadless:
def _patch_remove_internals(self, monkeypatch, removed):
module = "components.moonraker.services.moonraker_setup_service"
monkeypatch.setattr(
f"{module}.InstanceManager.remove",
staticmethod(lambda instance: removed["instances"].append(instance.suffix)),
)
monkeypatch.setattr(
f"{module}.MoonrakerSetupService._refresh_state",
lambda self: None,
)
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
monkeypatch.setattr(
f"{module}.remove_polkit_rules",
lambda: removed["paths"].append("polkit") or True,
)
monkeypatch.setattr(
f"{module}.run_remove_routines",
lambda path: removed["paths"].append(str(path)) or True,
)
def test_removes_all_instances_when_explicit_all(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [FakeMoonraker("a"), FakeMoonraker("b")]
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = MoonrakerSetupService()
service.remove(
remove_service=True,
remove_dir=True,
remove_env=True,
remove_polkit=True,
remove_all=True,
interactive=False,
)
assert set(removed["instances"]) == {"a", "b"}
assert "polkit" in removed["paths"]
def test_without_explicit_intent_removes_nothing(
self, patch_instance_services, monkeypatch
) -> None:
# non-interactive remove with no --all / --instance must not destroy any instance and must refuse.
patch_instance_services["moonraker"] = [FakeMoonraker("a"), FakeMoonraker("b")]
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = MoonrakerSetupService()
result = service.remove(
remove_service=True,
remove_dir=False,
remove_env=False,
remove_polkit=False,
interactive=False,
)
assert result is False
assert removed["instances"] == []
assert removed["paths"] == []
def test_with_instance_suffix_removes_only_matching(
self, patch_instance_services, monkeypatch
) -> None:
patch_instance_services["moonraker"] = [
FakeMoonraker("a"),
FakeMoonraker("b"),
]
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
self._patch_remove_internals(monkeypatch, removed)
service = MoonrakerSetupService()
service.remove(
remove_service=True,
remove_dir=False,
remove_env=False,
remove_polkit=False,
instance_suffixes=["a"],
interactive=False,
)
assert removed["instances"] == ["a"]
@@ -0,0 +1,278 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List
import pytest
from components.moonraker.utils import utils as moonraker_utils
from components.moonraker.utils.utils import (
backup_moonraker_db_dir,
backup_moonraker_dir,
create_example_moonraker_conf,
get_moonraker_status,
install_moonraker_packages,
load_sysdeps_json,
remove_polkit_rules,
)
class FakeMoonraker:
def __init__(self, suffix: str = "") -> None:
self.suffix = suffix
self.data_dir = Path(f"/tmp/moonraker{suffix}_data")
self.db_dir = self.data_dir.joinpath("database")
self.cfg_file = self.data_dir.joinpath("moonraker.conf")
self.base = type(
"Base",
(),
{
"cfg_dir": self.data_dir,
"comms_dir": self.data_dir.joinpath("comms"),
},
)()
@pytest.fixture
def fake_instance(tmp_path: Path) -> FakeMoonraker:
instance = FakeMoonraker("")
instance.data_dir = tmp_path / "moonraker_data"
instance.cfg_file = instance.data_dir / "moonraker.conf"
instance.db_dir = instance.data_dir / "database"
instance.base = type(
"Base",
(),
{
"cfg_dir": instance.data_dir,
"comms_dir": instance.data_dir / "comms",
},
)()
return instance
class TestGetMoonrakerStatus:
def test_delegates_to_get_install_status(self, monkeypatch: pytest.MonkeyPatch) -> None:
called: List[Any] = []
monkeypatch.setattr(
moonraker_utils,
"get_install_status",
lambda *args: called.append(args) or type("S", (), {"status": 0})(),
)
status = get_moonraker_status()
assert called
assert status.status == 0
class TestInstallMoonrakerPackages:
def test_parses_deps_json_when_present(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
deps_file = tmp_path / "moonraker_deps.json"
deps_file.write_text(json.dumps({"debian": ["pkg1", "pkg2"]}))
install_script = tmp_path / "install_moonraker.sh"
install_script.write_text("# dummy")
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
deps: List[str] = []
monkeypatch.setattr(
moonraker_utils, "check_install_dependencies", lambda p: deps.extend(p)
)
class FakeParser:
def parse_dependencies(self, data):
return ["pkg1", "pkg2"]
monkeypatch.setattr(moonraker_utils, "SysDepsParser", FakeParser)
install_moonraker_packages()
assert "pkg1" in deps
assert "pkg2" in deps
def test_falls_back_to_install_script(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
deps_file = tmp_path / "missing.json"
install_script = tmp_path / "install_moonraker.sh"
install_script.write_text("apt-get install pkg3 pkg4\n")
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
deps: List[str] = []
monkeypatch.setattr(
moonraker_utils, "check_install_dependencies", lambda p: deps.extend(p)
)
monkeypatch.setattr(
moonraker_utils,
"parse_packages_from_file",
lambda p: ["pkg3", "pkg4"],
)
install_moonraker_packages()
assert "pkg3" in deps
def test_raises_when_no_deps_found(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
deps_file = tmp_path / "missing.json"
install_script = tmp_path / "missing.sh"
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
with pytest.raises(ValueError):
install_moonraker_packages()
class TestRemovePolkitRules:
def test_returns_false_when_moonraker_dir_missing(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path / "missing")
assert remove_polkit_rules() is False
def test_returns_true_on_success(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path)
monkeypatch.setattr(
moonraker_utils,
"run",
lambda *a, **k: type("R", (), {"returncode": 0})(),
)
assert remove_polkit_rules() is True
def test_returns_false_on_command_failure(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path)
def fake_run(*a, **k):
raise moonraker_utils.CalledProcessError(1, cmd="clear")
monkeypatch.setattr(moonraker_utils, "run", fake_run)
assert remove_polkit_rules() is False
class TestCreateExampleMoonrakerConf:
def test_skips_when_config_already_exists(
self, monkeypatch: pytest.MonkeyPatch, fake_instance: FakeMoonraker
) -> None:
fake_instance.cfg_file.parent.mkdir(parents=True, exist_ok=True)
fake_instance.cfg_file.write_text("existing")
create_example_moonraker_conf(fake_instance, {})
# no changes expected
assert fake_instance.cfg_file.read_text() == "existing"
def test_creates_config_with_default_port(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
fake_instance: FakeMoonraker,
) -> None:
fake_instance.cfg_file.parent.mkdir(parents=True, exist_ok=True)
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
template = assets_dir / "moonraker.conf"
template.write_text(
"[server]\nport: %{PORT}%\nklippy_uds_address: %{UDS}%\n"
"[authorization]\ntrusted_clients:\n %{CLIENTS}%\n"
)
monkeypatch.setattr(moonraker_utils, "MODULE_PATH", tmp_path)
monkeypatch.setattr(
moonraker_utils, "get_ipv4_addr", lambda: "192.168.1.10"
)
create_example_moonraker_conf(fake_instance, {})
content = fake_instance.cfg_file.read_text()
assert "192.168.0.0/16" in content
class TestBackupMoonrakerDir:
def test_backs_up_repository_and_environment(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
calls: List[Dict[str, Any]] = []
class FakeBackup:
def backup_directory(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path / "moonraker")
monkeypatch.setattr(moonraker_utils, "MOONRAKER_ENV_DIR", tmp_path / "env")
backup_moonraker_dir()
assert len(calls) == 2
class TestBackupMoonrakerDbDir:
def test_backs_up_db_for_each_instance(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
calls: List[Dict[str, Any]] = []
class FakeBackup:
def backup_directory(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
monkeypatch.setattr(
moonraker_utils, "get_instances", lambda model: [FakeMoonraker("")]
)
backup_moonraker_db_dir()
assert len(calls) == 1
def test_falls_back_to_home_dirs(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
printer_data = tmp_path / "printer_data"
printer_data.mkdir()
printer_data.joinpath("database").mkdir()
calls: List[Dict[str, Any]] = []
class FakeBackup:
def backup_directory(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
monkeypatch.setattr(moonraker_utils, "get_instances", lambda model: [])
backup_moonraker_db_dir()
assert len(calls) == 1
class TestLoadSysdepsJson:
def test_loads_valid_json(self, tmp_path: Path) -> None:
file = tmp_path / "deps.json"
file.write_text('{"debian": ["curl"]}')
result = load_sysdeps_json(file)
assert result == {"debian": ["curl"]}
def test_returns_empty_on_invalid_json(self, tmp_path: Path) -> None:
file = tmp_path / "deps.json"
file.write_text("not json")
result = load_sysdeps_json(file)
assert result == {}