Implement systemd plugin

This commit is contained in:
Kivi Kaitaniemi
2025-12-13 20:51:21 +02:00
parent 9471bbf6ac
commit fd96cbec57
6 changed files with 718 additions and 2 deletions
+8 -2
View File
@@ -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: "<cmd>: <error>\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")
+8
View File
@@ -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
# ─────────────────────────────
+19
View File
@@ -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.
+291
View File
@@ -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
+26
View File
@@ -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
+366
View File
@@ -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