diff --git a/src/decman/core/command.py b/src/decman/core/command.py index 5e36023..bbcc708 100644 --- a/src/decman/core/command.py +++ b/src/decman/core/command.py @@ -4,6 +4,7 @@ import os import pty import pwd import select +import shlex import shutil import struct import subprocess @@ -13,6 +14,7 @@ import tty import typing import decman.core.error as errors +import decman.core.output as output def get_user_info(user: str) -> tuple[int, int]: @@ -55,6 +57,8 @@ def pty_run( if not sys.stdin.isatty(): raise OSError(errno.ENOTTY, "Stdin is not a TTY.") + output.print_debug(f"Running command '{shlex.join(command)}'") + env = _build_env(user=user, env_overrides=env_overrides, mimic_login=mimic_login) pid, master_fd = pty.fork() @@ -90,6 +94,8 @@ def run( if not command: return 0, "" + output.print_debug(f"Running command '{shlex.join(command)}'") + env = _build_env(user=user, env_overrides=env_overrides, mimic_login=mimic_login) uid, gid = None, None @@ -104,9 +110,9 @@ def run( except OSError as error: # Mirror PTY behavior: ": \n" and errno-based exit code msg = error.strerror or str(error) - output = f"{command[0]}: {msg}\n" + text_output = f"{command[0]}: {msg}\n" code = error.errno if error.errno and error.errno < 128 else 127 - return code, output + return code, text_output return process.returncode, stdout.decode("utf-8", errors="replace") diff --git a/src/decman/core/output.py b/src/decman/core/output.py index 5b4ccfe..a36267e 100644 --- a/src/decman/core/output.py +++ b/src/decman/core/output.py @@ -133,6 +133,14 @@ def print_debug(msg: str): print(f"{_tag()} {_gray('DEBUG')}: {msg}") +def print_command_output(msg: str): + """ + Prints command output without a DECMAN tag if debug messages are enabled. + """ + if config.debug_output: + print(msg) + + # ───────────────────────────── # List printing # ───────────────────────────── diff --git a/src/decman/plugins/__init__.py b/src/decman/plugins/__init__.py index f46779c..e264087 100644 --- a/src/decman/plugins/__init__.py +++ b/src/decman/plugins/__init__.py @@ -1,4 +1,5 @@ import importlib.metadata as metadata +import typing import decman.core.module as module import decman.core.store as _store @@ -45,6 +46,24 @@ class Plugin: """ +def run_method_with_attribute(mod: module.Module, attribute: str) -> typing.Any: + """ + Runs the method with the given attribute in the module and returns its returned value. + Returns none if no such method is found. + + Only the first found method with the attribute is ran. + """ + for name in dir(mod): + attr = getattr(mod, name) + if not callable(attr): + continue + func = getattr(attr, "__func__", attr) + if getattr(func, attribute, False): + return attr() + + return None + + def available_plugins() -> dict[str, Plugin]: """ Returns all available plugins. diff --git a/src/decman/plugins/systemd.py b/src/decman/plugins/systemd.py index 27c84e2..70d1f40 100644 --- a/src/decman/plugins/systemd.py +++ b/src/decman/plugins/systemd.py @@ -1,5 +1,296 @@ +import shutil + +import decman.core.command as command +import decman.core.module as module +import decman.core.output as output +import decman.core.store as _store import decman.plugins as plugins +def units(fn): + """ + Annotate that this function returns a set of systemd unit names that should be enabled. + """ + fn.__systemd__units__ = True + return fn + + +def user_units(fn): + """ + Annotate that this function returns a dict of users and systemd user unit names that should be + enabled. + """ + fn.__systemd__user__units__ = True + return fn + + +class SystemdCommands: + """ + Default commands for the Systemd plugin. + """ + + def enable_units(self, units: set[str]) -> list[str]: + """ + Running this command enables the given systemd units. + """ + return ["systemctl", "enable"] + list(units) + + def disable_units(self, units: set[str]) -> list[str]: + """ + Running this command disables the given systemd units. + """ + return ["systemctl", "disable"] + list(units) + + def enable_user_units(self, units: set[str], user: str) -> list[str]: + """ + Running this command enables the given systemd units for the user. + """ + return ["systemctl", "--user", "-M", f"{user}@", "enable"] + list(units) + + def disable_user_units(self, units: set[str], user: str) -> list[str]: + """ + Running this command disables the given systemd units for the user. + """ + return ["systemctl", "--user", "-M", f"{user}@", "disable"] + list(units) + + def daemon_reload(self) -> list[str]: + """ + Running this command reloads the systemd daemon. + """ + return ["systemctl", "daemon-reload"] + + def user_daemon_reload(self, user: str) -> list[str]: + """ + Running this command reloads the systemd daemon for the given user. + """ + return ["systemctl", "--user", "-M", f"{user}@", "daemon-reload"] + + class Systemd(plugins.Plugin): NAME = "systemd" + + def __init__(self) -> None: + self.enabled_systemd_units: set[str] = set() + self.enabled_systemd_user_units: dict[str, set[str]] = {} + self.commands = SystemdCommands() + + def available(self) -> bool: + return shutil.which("systemctl") is not None + + def process_modules(self, store: _store.Store, modules: set[module.Module]): + # These store keys are used to track changes in modules. + # This way when these change, module can be marked as changed + store.ensure("systemd_units_for_module", {}) + store.ensure("systemd_user_units_for_module", {}) + + for mod in modules: + store["systemd_units_for_module"].setdefault(mod.name, set()) + store["systemd_user_units_for_module"].setdefault(mod.name, {}) + + units = plugins.run_method_with_attribute(mod, "__systemd__units__") or set() + user_units = plugins.run_method_with_attribute(mod, "__systemd__user__units__") or {} + + if store["systemd_units_for_module"][mod.name] != units: + mod._changed = True + + if store["systemd_user_units_for_module"][mod.name] != user_units: + mod._changed = True + + self.enabled_systemd_units |= units + for user, u_units in user_units.items(): + self.enabled_systemd_user_units.setdefault(user, set()).update(u_units) + + store["systemd_units_for_module"][mod.name] = units + store["systemd_user_units_for_module"][mod.name] = user_units + + def apply( + self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None + ) -> bool: + store.ensure("systemd_units", set()) + store.ensure("systemd_user_units", {}) + + units_to_enable = set() + units_to_disable = set() + user_units_to_enable: dict[str, set[str]] = {} + user_units_to_disable: dict[str, set[str]] = {} + + for unit in self.enabled_systemd_units: + if unit not in store["systemd_units"]: + units_to_enable.add(unit) + + for unit in store["systemd_units"]: + if unit not in self.enabled_systemd_units: + units_to_disable.add(unit) + + for user, units in self.enabled_systemd_user_units.items(): + store["systemd_user_units"].setdefault(user, set()) + user_units_to_enable.setdefault(user, set()) + + for unit in units: + if unit not in store["systemd_user_units"][user]: + user_units_to_enable[user].add(unit) + + for user, units in store["systemd_user_units"].items(): + self.enabled_systemd_user_units.setdefault(user, set()) + user_units_to_disable.setdefault(user, set()) + + for unit in units: + if unit not in self.enabled_systemd_user_units[user]: + user_units_to_disable[user].add(unit) + + output.print_info("Reloading systemd daemon.") + if not dry_run: + if not self.reload_daemon(): + return False + + output.print_info("Reloading systemd daemon for users.") + if not dry_run: + for user in user_units_to_enable.keys() | user_units_to_disable.keys(): + if not self.reload_user_daemon(user): + return False + + output.print_list("Enabling systemd units:", list(units_to_enable)) + if not dry_run: + if not self.enable_units(store, units_to_enable): + return False + + output.print_list("Disabling systemd units:", list(units_to_disable)) + if not dry_run: + if not self.disable_units(store, units_to_disable): + return False + + for user, units in user_units_to_enable.items(): + output.print_list(f"Enabling systemd units for {user}:", list(units)) + if not dry_run: + if not self.enable_user_units(store, units, user): + return False + + for user, units in user_units_to_disable.items(): + output.print_list(f"Disabling systemd units for {user}:", list(units)) + if not dry_run: + if not self.disable_user_units(store, units, user): + return False + + return True + + def enable_units(self, store: _store.Store, units: set[str]) -> bool: + """ + Enables the given units. + + Returns ``True`` if the operation was successful. + """ + if not units: + return True + + code, text = command.run(self.commands.enable_units(units)) + output.print_command_output(text) + if code != 0: + output.print_error(f"Failed to enable systemd units '{' '.join(units)}'.") + output.print_error(f"Command exited with code: {code}") + output.print_error(f"{text}") + return False + + store["systemd_units"] |= units + + return True + + def disable_units(self, store: _store.Store, units: set[str]) -> bool: + """ + Disables the given units. + + Returns ``True`` if the operation was successful. + """ + if not units: + return True + + code, text = command.run(self.commands.disable_units(units)) + output.print_command_output(text) + if code != 0: + output.print_error(f"Failed to disable systemd units '{' '.join(units)}'.") + output.print_error(f"Command exited with code: {code}") + output.print_error(f"{text}") + return False + + store["systemd_units"] -= units + + return True + + def enable_user_units(self, store: _store.Store, units: set[str], user: str) -> bool: + """ + Enables the given units for the given user. + + Returns ``True`` if the operation was successful. + """ + if not units: + return True + + code, text = command.run(self.commands.enable_user_units(units, user)) + output.print_command_output(text) + if code != 0: + output.print_error( + f"Failed to enable systemd units '{' '.join(units)}' for user {user}." + ) + output.print_error(f"Command exited with code: {code}") + output.print_error(f"{text}") + return False + + store["systemd_user_units"].setdefault(user, set()) + store["systemd_user_units"][user] |= units + + return True + + def disable_user_units(self, store: _store.Store, units: set[str], user: str) -> bool: + """ + Disables the given units for the given user. + + Returns ``True`` if the operation was successful. + """ + if not units: + return True + + code, text = command.run(self.commands.disable_user_units(units, user)) + output.print_command_output(text) + if code != 0: + output.print_error( + f"Failed to disable systemd units '{' '.join(units)}' for user {user}." + ) + output.print_error(f"Command exited with code: {code}") + output.print_error(f"{text}") + return False + + store["systemd_user_units"].setdefault(user, set()) + store["systemd_user_units"][user] -= units + + return True + + def reload_user_daemon(self, user: str) -> bool: + """ + Reloads the user's systemd daemon. + + Returns ``True`` if the operation was successful. + """ + + code, text = command.run(self.commands.user_daemon_reload(user)) + output.print_command_output(text) + if code != 0: + output.print_error(f"Failed to reload systemd daemon for {user}.") + output.print_error(f"Command exited with code: {code}") + output.print_error(f"{text}") + return False + return True + + def reload_daemon(self) -> bool: + """ + Reloads the systemd daemon. + + Returns ``True`` if the operation was successful. + """ + + code, text = command.run(self.commands.daemon_reload()) + output.print_command_output(text) + if code != 0: + output.print_error("Failed to reload systemd daemon.") + output.print_error(f"Command exited with code: {code}") + output.print_error(f"{text}") + return False + return True diff --git a/tests/test_decman_plugins.py b/tests/test_decman_plugins.py new file mode 100644 index 0000000..a024a02 --- /dev/null +++ b/tests/test_decman_plugins.py @@ -0,0 +1,26 @@ +from decman.core.module import Module +from decman.plugins import run_method_with_attribute + + +def mark(attr): + attr.__flag__ = True + return attr + + +def test_runs_marked_method_and_returns_value(): + class M(Module): + @mark + def foo(self): + return 123 + + m = M("m") + assert run_method_with_attribute(m, "__flag__") == 123 + + +def test_returns_none_if_no_method_has_attribute(): + class M(Module): + def foo(self): + return 1 + + m = M("m") + assert run_method_with_attribute(m, "__flag__") is None diff --git a/tests/test_decman_plugins_systemd.py b/tests/test_decman_plugins_systemd.py new file mode 100644 index 0000000..285f010 --- /dev/null +++ b/tests/test_decman_plugins_systemd.py @@ -0,0 +1,366 @@ +import pytest + +from decman.plugins import systemd as systemd_mod + + +class DummyStore(dict): + def ensure(self, key, default): + if key not in self: + self[key] = default + + +class DummyModule: + def __init__(self, name: str): + self.name = name + self._changed = False + + +@pytest.fixture +def store(): + return DummyStore() + + +@pytest.fixture +def systemd(): + return systemd_mod.Systemd() + + +def test_units_decorator_sets_attribute(): + @systemd_mod.units + def fn(): + pass + + assert getattr(fn, "__systemd__units__", False) is True + + +def test_user_units_decorator_sets_attribute(): + @systemd_mod.user_units + def fn(): + pass + + assert getattr(fn, "__systemd__user__units__", False) is True + + +def test_available_true_if_systemctl_found(monkeypatch, systemd): + called = {} + + def fake_which(name): + called["name"] = name + return "/bin/systemctl" + + monkeypatch.setattr(systemd_mod.shutil, "which", fake_which) + assert systemd.available() is True + assert called["name"] == "systemctl" + + +def test_available_false_if_systemctl_missing(monkeypatch, systemd): + monkeypatch.setattr(systemd_mod.shutil, "which", lambda name: None) + assert systemd.available() is False + + +def test_process_modules_marks_changed_and_updates_store(monkeypatch, store, systemd): + # initial store empty; ensure keys will be created + m1 = DummyModule("mod1") + m2 = DummyModule("mod2") + + def fake_run_method(mod, attr): + if mod is m1 and attr == "__systemd__units__": + return {"a.service"} + if mod is m1 and attr == "__systemd__user__units__": + return {"alice": {"u1.service"}} + # m2 has no units + return None + + monkeypatch.setattr(systemd_mod.plugins, "run_method_with_attribute", fake_run_method) + + systemd.process_modules(store, {m1, m2}) + + # m1 changed from default -> marked _changed + assert m1._changed is True + # m2 had no units + assert m2._changed is False + + # enabled units aggregated + assert systemd.enabled_systemd_units == {"a.service"} + assert systemd.enabled_systemd_user_units == {"alice": {"u1.service"}} + + # store updated per module + assert store["systemd_units_for_module"]["mod1"] == {"a.service"} + assert store["systemd_user_units_for_module"]["mod1"] == {"alice": {"u1.service"}} + assert store["systemd_units_for_module"]["mod2"] == set() + assert store["systemd_user_units_for_module"]["mod2"] == {} + + +def test_process_modules_no_change_second_run(monkeypatch, store, systemd): + m1 = DummyModule("mod1") + + def fake_run_method(mod, attr): + if attr == "__systemd__units__": + return {"a.service"} + if attr == "__systemd__user__units__": + return {"alice": {"u1.service"}} + return None + + monkeypatch.setattr(systemd_mod.plugins, "run_method_with_attribute", fake_run_method) + + # first run populates store + systemd.process_modules(store, {m1}) + m1._changed = False + + # new instance (fresh per-process in real usage) + systemd2 = systemd_mod.Systemd() + monkeypatch.setattr(systemd_mod.plugins, "run_method_with_attribute", fake_run_method) + + systemd2.process_modules(store, {m1}) + + # values in store are same -> _changed stays False + assert m1._changed is False + + +def test_apply_enables_and_disables_units_and_user_units(store): + s = systemd_mod.Systemd() + + # Current enabled according to modules + s.enabled_systemd_units = {"new.service"} + s.enabled_systemd_user_units = {"alice": {"newuser.service"}} + + # Store says we had an old unit enabled before + store["systemd_units"] = {"old.service"} + store["systemd_user_units"] = {"alice": {"olduser.service"}} + + calls = [] + + def fake_reload_daemon(): + calls.append(("reload_daemon",)) + return True + + def fake_reload_user_daemon(user): + calls.append(("reload_user_daemon", user)) + return True + + def fake_enable_units(store_arg, units_arg): + calls.append(("enable_units", frozenset(units_arg))) + store_arg["systemd_units"] |= units_arg + return True + + def fake_disable_units(store_arg, units_arg): + calls.append(("disable_units", frozenset(units_arg))) + store_arg["systemd_units"] -= units_arg + return True + + def fake_enable_user_units(store_arg, units_arg, user): + calls.append(("enable_user_units", user, frozenset(units_arg))) + store_arg["systemd_user_units"].setdefault(user, set()).update(units_arg) + return True + + def fake_disable_user_units(store_arg, units_arg, user): + calls.append(("disable_user_units", user, frozenset(units_arg))) + store_arg["systemd_user_units"].setdefault(user, set()).difference_update(units_arg) + return True + + # patch instance methods (no self parameter expected) + s.reload_daemon = fake_reload_daemon + s.reload_user_daemon = fake_reload_user_daemon + s.enable_units = fake_enable_units + s.disable_units = fake_disable_units + s.enable_user_units = fake_enable_user_units + s.disable_user_units = fake_disable_user_units + + result = s.apply(store, dry_run=False, params=None) + assert result is True + + # reloads called once + assert ("reload_daemon",) in calls + assert ("reload_user_daemon", "alice") in calls + + # enable/disable correct units + assert ("enable_units", frozenset({"new.service"})) in calls + assert ("disable_units", frozenset({"old.service"})) in calls + assert ("enable_user_units", "alice", frozenset({"newuser.service"})) in calls + assert ("disable_user_units", "alice", frozenset({"olduser.service"})) in calls + + # store reconciled + assert store["systemd_units"] == {"new.service"} + assert store["systemd_user_units"]["alice"] == {"newuser.service"} + + +def test_apply_dry_run_does_not_mutate_store_or_call_commands(store): + s = systemd_mod.Systemd() + s.enabled_systemd_units = {"new.service"} + s.enabled_systemd_user_units = {"alice": {"newuser.service"}} + + store["systemd_units"] = {"old.service"} + store["systemd_user_units"] = {"alice": {"olduser.service"}} + + called = {"reload": False, "enable": False, "disable": False} + + s.reload_daemon = lambda: called.__setitem__("reload", True) or True + s.reload_user_daemon = lambda user: called.__setitem__("reload", True) or True + s.enable_units = lambda st, u: called.__setitem__("enable", True) or True + s.disable_units = lambda st, u: called.__setitem__("disable", True) or True + s.enable_user_units = lambda st, u, user: called.__setitem__("enable", True) or True + s.disable_user_units = lambda st, u, user: called.__setitem__("disable", True) or True + + result = s.apply(store, dry_run=True, params=None) + assert result is True + + # no commands should be called + assert called == {"reload": False, "enable": False, "disable": False} + + # store unchanged + assert store["systemd_units"] == {"old.service"} + assert store["systemd_user_units"]["alice"] == {"olduser.service"} + + +def test_enable_units_success(monkeypatch, store, systemd): + store["systemd_units"] = {"old.service"} + + def fake_run(cmd): + assert cmd[0] == "systemctl" + assert cmd[1] == "enable" + assert "new.service" in cmd[2:] + return 0, "ok" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.enable_units(store, {"new.service"}) + assert result is True + assert store["systemd_units"] == {"old.service", "new.service"} + + +def test_enable_units_failure_does_not_update_store(monkeypatch, store, systemd): + store["systemd_units"] = {"old.service"} + + def fake_run(cmd): + return 1, "error" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.enable_units(store, {"new.service"}) + assert result is False + # unchanged + assert store["systemd_units"] == {"old.service"} + + +def test_disable_units_success(monkeypatch, store, systemd): + store["systemd_units"] = {"old.service", "new.service"} + + def fake_run(cmd): + assert cmd[0] == "systemctl" + assert cmd[1] == "disable" + assert "new.service" in cmd[2:] + return 0, "ok" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.disable_units(store, {"new.service"}) + assert result is True + assert store["systemd_units"] == {"old.service"} + + +def test_disable_units_failure_does_not_update_store(monkeypatch, store, systemd): + store["systemd_units"] = {"old.service", "new.service"} + + def fake_run(cmd): + return 1, "error" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.disable_units(store, {"new.service"}) + assert result is False + assert store["systemd_units"] == {"old.service", "new.service"} + + +def test_enable_user_units_success(monkeypatch, store, systemd): + store["systemd_user_units"] = {"alice": {"olduser.service"}} + + def fake_run(cmd): + assert cmd[0] == "systemctl" + assert "--user" in cmd + assert "enable" in cmd + assert "newuser.service" in cmd + return 0, "ok" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.enable_user_units(store, {"newuser.service"}, "alice") + assert result is True + assert store["systemd_user_units"]["alice"] == { + "olduser.service", + "newuser.service", + } + + +def test_enable_user_units_failure_does_not_update_store(monkeypatch, store, systemd): + store["systemd_user_units"] = {"alice": {"olduser.service"}} + + def fake_run(cmd): + return 1, "error" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.enable_user_units(store, {"newuser.service"}, "alice") + assert result is False + assert store["systemd_user_units"]["alice"] == {"olduser.service"} + + +def test_disable_user_units_success(monkeypatch, store, systemd): + store["systemd_user_units"] = {"alice": {"olduser.service", "newuser.service"}} + + def fake_run(cmd): + assert cmd[0] == "systemctl" + assert "--user" in cmd + assert "disable" in cmd + assert "newuser.service" in cmd + return 0, "ok" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.disable_user_units(store, {"newuser.service"}, "alice") + assert result is True + assert store["systemd_user_units"]["alice"] == {"olduser.service"} + + +def test_disable_user_units_failure_does_not_update_store(monkeypatch, store, systemd): + store["systemd_user_units"] = {"alice": {"olduser.service", "newuser.service"}} + + def fake_run(cmd): + return 1, "error" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + + result = systemd.disable_user_units(store, {"newuser.service"}, "alice") + assert result is False + assert store["systemd_user_units"]["alice"] == { + "olduser.service", + "newuser.service", + } + + +def test_reload_daemon_uses_command_run(monkeypatch, systemd): + called = {} + + def fake_run(cmd): + called["cmd"] = cmd + return 0, "ok" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + result = systemd.reload_daemon() + assert result is True + assert called["cmd"][:2] == ["systemctl", "daemon-reload"] + + +def test_reload_user_daemon_uses_command_run(monkeypatch, systemd): + called = {} + + def fake_run(cmd): + called["cmd"] = cmd + return 0, "ok" + + monkeypatch.setattr(systemd_mod.command, "run", fake_run) + result = systemd.reload_user_daemon("alice") + assert result is True + cmd = called["cmd"] + assert cmd[0] == "systemctl" + assert "--user" in cmd + assert "daemon-reload" in cmd