2025-12-11 18:32:55 -07:00
|
|
|
"""
|
|
|
|
|
Unit tests for controller.lib.systemd module.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from controller.lib.systemd import (
|
|
|
|
|
get_managed_services,
|
|
|
|
|
get_service_config,
|
|
|
|
|
get_service_status,
|
2026-07-06 20:06:20 -06:00
|
|
|
discover_wrolpi_services,
|
2025-12-11 18:32:55 -07:00
|
|
|
get_all_services_status,
|
2026-03-05 18:11:25 -07:00
|
|
|
get_discovered_service_status,
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
get_service_kind,
|
|
|
|
|
classify_service_group,
|
2026-03-05 18:11:25 -07:00
|
|
|
_get_systemd_name,
|
2025-12-11 18:32:55 -07:00
|
|
|
start_service,
|
|
|
|
|
stop_service,
|
|
|
|
|
restart_service,
|
|
|
|
|
enable_service,
|
|
|
|
|
disable_service,
|
|
|
|
|
get_service_logs,
|
|
|
|
|
_run_systemctl,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetManagedServices:
|
|
|
|
|
"""Tests for get_managed_services function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_list(self):
|
|
|
|
|
"""Should return a list."""
|
|
|
|
|
result = get_managed_services()
|
|
|
|
|
assert isinstance(result, list)
|
|
|
|
|
|
|
|
|
|
def test_returns_services_from_config(self):
|
|
|
|
|
"""Should return services from config."""
|
|
|
|
|
result = get_managed_services()
|
|
|
|
|
# Default config has managed_services
|
|
|
|
|
assert len(result) > 0
|
|
|
|
|
|
|
|
|
|
def test_each_service_has_name(self):
|
|
|
|
|
"""Each service should have a name."""
|
|
|
|
|
result = get_managed_services()
|
|
|
|
|
for service in result:
|
|
|
|
|
assert "name" in service
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetServiceConfig:
|
|
|
|
|
"""Tests for get_service_config function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_config_for_known_service(self):
|
|
|
|
|
"""Should return config for a known service."""
|
|
|
|
|
# wrolpi-api is in default config
|
|
|
|
|
result = get_service_config("wrolpi-api")
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert result["name"] == "wrolpi-api"
|
|
|
|
|
|
|
|
|
|
def test_returns_none_for_unknown_service(self):
|
|
|
|
|
"""Should return None for unknown service."""
|
|
|
|
|
result = get_service_config("nonexistent-service")
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRunSystemctl:
|
|
|
|
|
"""Tests for _run_systemctl function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_dict(self):
|
|
|
|
|
"""Should return a dictionary."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = _run_systemctl("is-active", "test.service")
|
|
|
|
|
assert isinstance(result, dict)
|
|
|
|
|
|
|
|
|
|
def test_success_on_zero_returncode(self):
|
|
|
|
|
"""Should return success=True when returncode is 0."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="done", stderr="")
|
|
|
|
|
result = _run_systemctl("start", "test.service")
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
|
|
|
|
|
def test_failure_on_nonzero_returncode(self):
|
|
|
|
|
"""Should return success=False when returncode is non-zero."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="failed")
|
|
|
|
|
result = _run_systemctl("start", "test.service")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
assert result["error"] == "failed"
|
|
|
|
|
|
|
|
|
|
def test_handles_timeout(self):
|
|
|
|
|
"""Should handle subprocess timeout."""
|
|
|
|
|
import subprocess
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("systemctl", 30)):
|
|
|
|
|
result = _run_systemctl("start", "test.service")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
assert "timed out" in result["error"].lower()
|
|
|
|
|
|
|
|
|
|
def test_handles_file_not_found(self):
|
|
|
|
|
"""Should handle systemctl not found."""
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
|
|
|
|
result = _run_systemctl("start", "test.service")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
assert "not found" in result["error"].lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetServiceStatus:
|
|
|
|
|
"""Tests for get_service_status function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_error_for_unknown_service(self):
|
|
|
|
|
"""Should return error for unknown service."""
|
|
|
|
|
result = get_service_status("nonexistent-service")
|
|
|
|
|
assert "error" in result
|
|
|
|
|
|
|
|
|
|
def test_returns_status_dict(self):
|
|
|
|
|
"""Should return a status dictionary."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_service_status("wrolpi-api")
|
|
|
|
|
assert "name" in result
|
|
|
|
|
assert "status" in result
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("systemd_state,expected_status", [
|
|
|
|
|
("active", "running"),
|
|
|
|
|
("activating", "running"), # oneshot services during execution
|
|
|
|
|
("inactive", "stopped"),
|
|
|
|
|
("failed", "failed"),
|
|
|
|
|
])
|
|
|
|
|
def test_maps_systemd_state_to_status(self, systemd_state, expected_status):
|
|
|
|
|
"""Should map systemd states to simple status values."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout=systemd_state, stderr="")
|
|
|
|
|
result = get_service_status("wrolpi-api")
|
|
|
|
|
assert result["status"] == expected_status
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("service_name,expected_https", [
|
|
|
|
|
("wrolpi-help", True),
|
|
|
|
|
("wrolpi-kiwix", True),
|
|
|
|
|
("wrolpi-api", False),
|
|
|
|
|
])
|
|
|
|
|
def test_returns_use_https_field(self, service_name, expected_https):
|
|
|
|
|
"""Should return use_https field from service config."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_service_status(service_name)
|
|
|
|
|
assert result.get("use_https", False) == expected_https
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetAllServicesStatus:
|
|
|
|
|
"""Tests for get_all_services_status function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_list(self):
|
|
|
|
|
"""Should return a list."""
|
2026-07-06 20:06:20 -06:00
|
|
|
with mock.patch("controller.lib.systemd.discover_wrolpi_services", return_value=[]):
|
2026-03-05 18:11:25 -07:00
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
assert isinstance(result, list)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
def test_returns_status_for_each_visible_service(self):
|
|
|
|
|
"""Should return status for each visible managed service."""
|
2026-07-06 20:06:20 -06:00
|
|
|
with mock.patch("controller.lib.systemd.discover_wrolpi_services", return_value=[]):
|
2026-03-05 18:11:25 -07:00
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
managed = get_managed_services()
|
|
|
|
|
# All services should be returned when they're all "running" (active)
|
|
|
|
|
assert len(result) == len(managed)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-07-06 20:06:20 -06:00
|
|
|
def test_includes_stopped_but_installed_services(self):
|
|
|
|
|
"""show_only_when_running services stay visible while stopped as long
|
|
|
|
|
as the unit is installed: a finished oneshot (wrolpi-upgrade) must
|
|
|
|
|
keep its logs reachable from the UI."""
|
|
|
|
|
def side_effect(cmd, **kwargs):
|
|
|
|
|
out = "loaded" if cmd[:2] == ["systemctl", "show"] else "inactive"
|
|
|
|
|
return mock.Mock(returncode=0, stdout=out, stderr="")
|
|
|
|
|
|
|
|
|
|
with mock.patch("controller.lib.systemd.discover_wrolpi_services", return_value=[]):
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=side_effect):
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
assert len(result) == len(get_managed_services())
|
|
|
|
|
assert "wrolpi-upgrade" in [s["name"] for s in result]
|
|
|
|
|
|
|
|
|
|
def test_hides_optional_services_only_when_not_installed(self):
|
|
|
|
|
"""show_only_when_running services are hidden only when the unit does
|
|
|
|
|
not exist on this box (e.g. wrolpi-api-dev on production)."""
|
|
|
|
|
def side_effect(cmd, **kwargs):
|
|
|
|
|
out = "not-found" if cmd[:2] == ["systemctl", "show"] else "inactive"
|
|
|
|
|
return mock.Mock(returncode=0, stdout=out, stderr="")
|
|
|
|
|
|
|
|
|
|
with mock.patch("controller.lib.systemd.discover_wrolpi_services", return_value=[]):
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=side_effect):
|
2026-03-05 18:11:25 -07:00
|
|
|
result = get_all_services_status()
|
|
|
|
|
managed = get_managed_services()
|
|
|
|
|
always_visible = [s for s in managed if not s.get("show_only_when_running")]
|
|
|
|
|
assert len(result) == len(always_visible)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
def test_includes_show_only_when_running_services_when_running(self):
|
|
|
|
|
"""Should include show_only_when_running services when they're running."""
|
2026-07-06 20:06:20 -06:00
|
|
|
with mock.patch("controller.lib.systemd.discover_wrolpi_services", return_value=[]):
|
2026-03-05 18:11:25 -07:00
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
# Simulate all services as "active" (running)
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
managed = get_managed_services()
|
|
|
|
|
# All services should be visible when running
|
|
|
|
|
assert len(result) == len(managed)
|
|
|
|
|
# Verify wrolpi-upgrade is in the result
|
|
|
|
|
service_names = [s["name"] for s in result]
|
|
|
|
|
assert "wrolpi-upgrade" in service_names
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-07-06 20:06:20 -06:00
|
|
|
def test_includes_discovered_services_even_when_stopped(self):
|
|
|
|
|
"""Discovered wrolpi-* units (e.g. a finished script service) appear
|
|
|
|
|
in the list regardless of running state."""
|
|
|
|
|
def side_effect(cmd, **kwargs):
|
|
|
|
|
out = "loaded" if cmd[:2] == ["systemctl", "show"] else "inactive"
|
|
|
|
|
return mock.Mock(returncode=0, stdout=out, stderr="")
|
|
|
|
|
|
|
|
|
|
with mock.patch("controller.lib.systemd.discover_wrolpi_services",
|
|
|
|
|
return_value=["wrolpi-custom-task"]):
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=side_effect):
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
names = [s["name"] for s in result]
|
|
|
|
|
assert "wrolpi-custom-task" in names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDiscoverWrolpiServices:
|
|
|
|
|
"""Discovery unions loaded units (--all, catches transient units) with
|
|
|
|
|
installed unit files, so exited oneshots and dev services are visible."""
|
|
|
|
|
|
|
|
|
|
def _side_effect(self, list_units_out, list_unit_files_out):
|
|
|
|
|
def side_effect(cmd, **kwargs):
|
|
|
|
|
if cmd[:2] == ["systemctl", "list-units"]:
|
|
|
|
|
out = list_units_out
|
|
|
|
|
elif cmd[:2] == ["systemctl", "list-unit-files"]:
|
|
|
|
|
out = list_unit_files_out
|
|
|
|
|
else:
|
|
|
|
|
out = ""
|
|
|
|
|
return mock.Mock(returncode=0, stdout=out, stderr="")
|
|
|
|
|
return side_effect
|
|
|
|
|
|
|
|
|
|
def test_unions_loaded_and_installed_units(self):
|
|
|
|
|
list_units = "wrolpi-transient.service loaded active running Task\n"
|
|
|
|
|
list_unit_files = ("wrolpi-custom.service static -\n"
|
|
|
|
|
"wrolpi-api.service enabled enabled\n")
|
|
|
|
|
with mock.patch("subprocess.run",
|
|
|
|
|
side_effect=self._side_effect(list_units, list_unit_files)):
|
|
|
|
|
found = discover_wrolpi_services()
|
|
|
|
|
assert "wrolpi-transient" in found
|
|
|
|
|
assert "wrolpi-custom" in found
|
|
|
|
|
# Managed services are excluded from discovery.
|
|
|
|
|
assert "wrolpi-api" not in found
|
|
|
|
|
|
|
|
|
|
def test_deduplicates_units_present_in_both_listings(self):
|
|
|
|
|
list_units = "wrolpi-custom.service loaded inactive dead Task\n"
|
|
|
|
|
list_unit_files = "wrolpi-custom.service static -\n"
|
|
|
|
|
with mock.patch("subprocess.run",
|
|
|
|
|
side_effect=self._side_effect(list_units, list_unit_files)):
|
|
|
|
|
found = discover_wrolpi_services()
|
|
|
|
|
assert found.count("wrolpi-custom") == 1
|
|
|
|
|
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
class TestServiceActions:
|
|
|
|
|
"""Tests for service action functions (start, stop, restart, enable, disable)."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("func", [
|
|
|
|
|
start_service,
|
|
|
|
|
stop_service,
|
|
|
|
|
restart_service,
|
|
|
|
|
enable_service,
|
|
|
|
|
disable_service,
|
|
|
|
|
])
|
|
|
|
|
def test_returns_error_for_unknown_service(self, func):
|
|
|
|
|
"""Should return error for unknown service."""
|
|
|
|
|
result = func("nonexistent-service")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("func,expected_action", [
|
|
|
|
|
(start_service, "start"),
|
|
|
|
|
(stop_service, "stop"),
|
|
|
|
|
(restart_service, "restart"),
|
|
|
|
|
(enable_service, "enable"),
|
|
|
|
|
(disable_service, "disable"),
|
|
|
|
|
])
|
|
|
|
|
def test_calls_correct_systemctl_action(self, func, expected_action):
|
|
|
|
|
"""Should call systemctl with correct action."""
|
|
|
|
|
with mock.patch("controller.lib.systemd._run_systemctl") as mock_ctl:
|
|
|
|
|
mock_ctl.return_value = {"success": True, "output": "", "error": None}
|
|
|
|
|
result = func("wrolpi-api")
|
|
|
|
|
mock_ctl.assert_called_once()
|
|
|
|
|
assert mock_ctl.call_args[0][0] == expected_action
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result["action"] == expected_action
|
|
|
|
|
|
|
|
|
|
|
2025-12-25 09:29:01 -07:00
|
|
|
class TestControllerSelfRestart:
|
|
|
|
|
"""Tests for controller self-restart behavior."""
|
|
|
|
|
|
|
|
|
|
def test_controller_restart_uses_popen(self):
|
|
|
|
|
"""Controller restart should use Popen to avoid blocking on self-termination."""
|
|
|
|
|
with mock.patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
result = restart_service("wrolpi-controller")
|
|
|
|
|
mock_popen.assert_called_once_with(["systemctl", "restart", "wrolpi-controller"])
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result["pending"] is True
|
|
|
|
|
assert result["action"] == "restart"
|
|
|
|
|
|
|
|
|
|
def test_controller_restart_handles_exception(self):
|
|
|
|
|
"""Controller restart should handle exceptions gracefully."""
|
|
|
|
|
with mock.patch("subprocess.Popen", side_effect=Exception("popen failed")):
|
|
|
|
|
result = restart_service("wrolpi-controller")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
assert "popen failed" in result["error"]
|
|
|
|
|
|
|
|
|
|
|
2025-12-11 18:32:55 -07:00
|
|
|
class TestGetServiceLogs:
|
|
|
|
|
"""Tests for get_service_logs function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_error_for_unknown_service(self):
|
|
|
|
|
"""Should return error for unknown service."""
|
|
|
|
|
result = get_service_logs("nonexistent-service")
|
|
|
|
|
assert "error" in result
|
|
|
|
|
|
|
|
|
|
def test_returns_logs(self):
|
|
|
|
|
"""Should return logs."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="log line 1\nlog line 2", stderr="")
|
|
|
|
|
result = get_service_logs("wrolpi-api", lines=10)
|
|
|
|
|
assert "logs" in result
|
|
|
|
|
assert result["service"] == "wrolpi-api"
|
|
|
|
|
assert result["lines"] == 10
|
|
|
|
|
|
|
|
|
|
def test_handles_since_parameter(self):
|
|
|
|
|
"""Should include since parameter in journalctl command."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="logs", stderr="")
|
|
|
|
|
result = get_service_logs("wrolpi-api", lines=50, since="1h")
|
|
|
|
|
assert result["since"] == "1h"
|
|
|
|
|
# Verify journalctl was called with --since
|
|
|
|
|
call_args = mock_run.call_args[0][0]
|
|
|
|
|
assert "--since" in call_args
|
|
|
|
|
assert "1h" in call_args
|
2026-03-05 18:11:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDiscoverRunningWrolpiServices:
|
2026-07-06 20:06:20 -06:00
|
|
|
"""Tests for discover_wrolpi_services function."""
|
2026-03-05 18:11:25 -07:00
|
|
|
|
|
|
|
|
def test_discovers_unknown_services(self):
|
|
|
|
|
"""Should discover wrolpi-* services not in managed config."""
|
|
|
|
|
systemctl_output = (
|
|
|
|
|
"wrolpi-api.service loaded active running WROLPi API\n"
|
|
|
|
|
"wrolpi-fix-media-permissions.service loaded active running Fix permissions\n"
|
|
|
|
|
"wrolpi-repair.service loaded active running WROLPi Repair\n"
|
|
|
|
|
)
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout=systemctl_output, stderr="")
|
2026-07-06 20:06:20 -06:00
|
|
|
result = discover_wrolpi_services()
|
2026-03-05 18:11:25 -07:00
|
|
|
# wrolpi-api is managed, so only the other two should be discovered
|
|
|
|
|
assert "wrolpi-api" not in result
|
|
|
|
|
assert "wrolpi-fix-media-permissions" in result
|
|
|
|
|
assert "wrolpi-repair" in result
|
|
|
|
|
|
|
|
|
|
def test_returns_empty_on_failure(self):
|
|
|
|
|
"""Should return empty list when systemctl fails."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="error")
|
2026-07-06 20:06:20 -06:00
|
|
|
result = discover_wrolpi_services()
|
2026-03-05 18:11:25 -07:00
|
|
|
assert result == []
|
|
|
|
|
|
|
|
|
|
def test_returns_empty_on_no_systemctl(self):
|
|
|
|
|
"""Should return empty list when systemctl is not found."""
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
2026-07-06 20:06:20 -06:00
|
|
|
result = discover_wrolpi_services()
|
2026-03-05 18:11:25 -07:00
|
|
|
assert result == []
|
|
|
|
|
|
|
|
|
|
def test_returns_empty_when_no_extra_services(self):
|
|
|
|
|
"""Should return empty list when all wrolpi-* services are managed."""
|
|
|
|
|
systemctl_output = "wrolpi-api.service loaded active running WROLPi API\n"
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout=systemctl_output, stderr="")
|
2026-07-06 20:06:20 -06:00
|
|
|
result = discover_wrolpi_services()
|
2026-03-05 18:11:25 -07:00
|
|
|
assert result == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetDiscoveredServiceStatus:
|
|
|
|
|
"""Tests for get_discovered_service_status function."""
|
|
|
|
|
|
|
|
|
|
def test_returns_running_status(self):
|
|
|
|
|
"""Should return status for a discovered service."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_discovered_service_status("wrolpi-fix-media-permissions")
|
|
|
|
|
assert result["name"] == "wrolpi-fix-media-permissions"
|
|
|
|
|
assert result["status"] == "running"
|
|
|
|
|
assert result["port"] is None
|
|
|
|
|
assert result["viewable"] is False
|
|
|
|
|
|
|
|
|
|
def test_handles_no_systemctl(self):
|
|
|
|
|
"""Should handle missing systemctl."""
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
|
|
|
|
result = get_discovered_service_status("wrolpi-fix-media-permissions")
|
|
|
|
|
assert result["status"] == "unknown"
|
|
|
|
|
assert "error" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetSystemdName:
|
|
|
|
|
"""Tests for _get_systemd_name helper."""
|
|
|
|
|
|
|
|
|
|
def test_returns_systemd_name_for_managed_service(self):
|
|
|
|
|
"""Should return systemd_name from config for managed services."""
|
|
|
|
|
result = _get_systemd_name("wrolpi-api")
|
|
|
|
|
assert result == "wrolpi-api"
|
|
|
|
|
|
|
|
|
|
def test_returns_name_for_discovered_wrolpi_service(self):
|
|
|
|
|
"""Should return the name itself for discovered wrolpi-* services."""
|
|
|
|
|
result = _get_systemd_name("wrolpi-fix-media-permissions")
|
|
|
|
|
assert result == "wrolpi-fix-media-permissions"
|
|
|
|
|
|
|
|
|
|
def test_returns_none_for_unknown_non_wrolpi_service(self):
|
|
|
|
|
"""Should return None for unknown non-wrolpi services."""
|
|
|
|
|
result = _get_systemd_name("some-random-service")
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetAllServicesStatusWithDiscovery:
|
|
|
|
|
"""Tests for get_all_services_status including dynamic discovery."""
|
|
|
|
|
|
|
|
|
|
def test_includes_discovered_running_services(self):
|
|
|
|
|
"""Should include dynamically discovered running wrolpi-* services."""
|
|
|
|
|
systemctl_list_output = (
|
|
|
|
|
"wrolpi-fix-media-permissions.service loaded active running Fix permissions\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def mock_run_side_effect(cmd, **kwargs):
|
|
|
|
|
if cmd[0] == "systemctl" and cmd[1] == "list-units":
|
|
|
|
|
return mock.Mock(returncode=0, stdout=systemctl_list_output, stderr="")
|
|
|
|
|
# For is-active / is-enabled calls
|
|
|
|
|
return mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=mock_run_side_effect):
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
service_names = [s["name"] for s in result]
|
|
|
|
|
assert "wrolpi-fix-media-permissions" in service_names
|
|
|
|
|
|
|
|
|
|
def test_does_not_duplicate_managed_services(self):
|
|
|
|
|
"""Discovered services should not duplicate managed services."""
|
|
|
|
|
systemctl_list_output = (
|
|
|
|
|
"wrolpi-api.service loaded active running WROLPi API\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def mock_run_side_effect(cmd, **kwargs):
|
|
|
|
|
if cmd[0] == "systemctl" and cmd[1] == "list-units":
|
|
|
|
|
return mock.Mock(returncode=0, stdout=systemctl_list_output, stderr="")
|
|
|
|
|
return mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=mock_run_side_effect):
|
|
|
|
|
result = get_all_services_status()
|
|
|
|
|
api_count = sum(1 for s in result if s["name"] == "wrolpi-api")
|
|
|
|
|
assert api_count == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestActionsOnDiscoveredServices:
|
|
|
|
|
"""Tests that service actions work on discovered wrolpi-* services."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("func,expected_action", [
|
|
|
|
|
(start_service, "start"),
|
|
|
|
|
(stop_service, "stop"),
|
|
|
|
|
(restart_service, "restart"),
|
|
|
|
|
(enable_service, "enable"),
|
|
|
|
|
(disable_service, "disable"),
|
|
|
|
|
])
|
|
|
|
|
def test_actions_work_on_discovered_services(self, func, expected_action):
|
|
|
|
|
"""Should allow actions on discovered wrolpi-* services."""
|
|
|
|
|
with mock.patch("controller.lib.systemd._run_systemctl") as mock_ctl:
|
|
|
|
|
mock_ctl.return_value = {"success": True, "output": "", "error": None}
|
|
|
|
|
result = func("wrolpi-fix-media-permissions")
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result["action"] == expected_action
|
|
|
|
|
|
|
|
|
|
def test_logs_work_on_discovered_services(self):
|
|
|
|
|
"""Should return logs for discovered wrolpi-* services."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="log output", stderr="")
|
|
|
|
|
result = get_service_logs("wrolpi-fix-media-permissions", lines=10)
|
|
|
|
|
assert result["service"] == "wrolpi-fix-media-permissions"
|
|
|
|
|
assert "logs" in result
|
|
|
|
|
|
|
|
|
|
def test_get_service_status_for_discovered_service(self):
|
|
|
|
|
"""get_service_status should work for discovered wrolpi-* services."""
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_service_status("wrolpi-fix-media-permissions")
|
|
|
|
|
assert result["name"] == "wrolpi-fix-media-permissions"
|
|
|
|
|
assert result["status"] == "running"
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetServiceKind:
|
|
|
|
|
"""Tests for get_service_kind classification."""
|
|
|
|
|
|
|
|
|
|
def test_explicit_kind_wins(self):
|
|
|
|
|
assert get_service_kind({"kind": "task"}) == "task"
|
|
|
|
|
assert get_service_kind({"kind": "persistent"}) == "persistent"
|
|
|
|
|
|
|
|
|
|
def test_show_only_when_running_is_a_task(self):
|
|
|
|
|
"""Dev/upgrade units (show_only_when_running) default to 'task'."""
|
|
|
|
|
assert get_service_kind({"show_only_when_running": True}) == "task"
|
|
|
|
|
|
|
|
|
|
def test_default_is_persistent(self):
|
|
|
|
|
"""A plain managed service is a persistent, always-on service."""
|
|
|
|
|
assert get_service_kind({}) == "persistent"
|
|
|
|
|
assert get_service_kind({"name": "wrolpi-api"}) == "persistent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestClassifyServiceGroup:
|
|
|
|
|
"""Tests for classify_service_group."""
|
|
|
|
|
|
|
|
|
|
def test_persistent_and_enabled_is_core(self):
|
|
|
|
|
assert classify_service_group("persistent", True) == "core"
|
|
|
|
|
|
|
|
|
|
def test_persistent_but_disabled_is_optional(self):
|
|
|
|
|
"""A persistent service the user disabled at boot (e.g. Samba with no
|
|
|
|
|
shares) drops to the Optional & Maintenance group."""
|
|
|
|
|
assert classify_service_group("persistent", False) == "optional"
|
|
|
|
|
|
|
|
|
|
def test_task_is_always_optional(self):
|
|
|
|
|
"""One-off/dev/maintenance tasks are optional regardless of boot state."""
|
|
|
|
|
assert classify_service_group("task", True) == "optional"
|
|
|
|
|
assert classify_service_group("task", False) == "optional"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestServiceStatusGroup:
|
|
|
|
|
"""Group/kind are exposed on the status dicts the UI consumes."""
|
|
|
|
|
|
|
|
|
|
def test_enabled_persistent_service_is_core(self):
|
|
|
|
|
# is-active -> running, is-enabled -> enabled, LoadState -> loaded
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.side_effect = [
|
|
|
|
|
mock.Mock(returncode=0, stdout="active", stderr=""),
|
|
|
|
|
mock.Mock(returncode=0, stdout="enabled", stderr=""),
|
|
|
|
|
mock.Mock(returncode=0, stdout="loaded", stderr=""),
|
|
|
|
|
]
|
|
|
|
|
result = get_service_status("wrolpi-api")
|
|
|
|
|
assert result["kind"] == "persistent"
|
|
|
|
|
assert result["enabled"] is True
|
|
|
|
|
assert result["group"] == "core"
|
|
|
|
|
|
|
|
|
|
def test_disabled_persistent_service_is_optional(self):
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.side_effect = [
|
|
|
|
|
mock.Mock(returncode=0, stdout="inactive", stderr=""),
|
|
|
|
|
mock.Mock(returncode=0, stdout="disabled", stderr=""),
|
|
|
|
|
mock.Mock(returncode=0, stdout="loaded", stderr=""),
|
|
|
|
|
]
|
|
|
|
|
result = get_service_status("smbd")
|
|
|
|
|
assert result["kind"] == "persistent"
|
|
|
|
|
assert result["enabled"] is False
|
|
|
|
|
assert result["group"] == "optional"
|
|
|
|
|
|
|
|
|
|
def test_discovered_service_is_optional_task(self):
|
|
|
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
|
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
|
|
|
|
|
result = get_discovered_service_status("wrolpi-repair")
|
|
|
|
|
assert result["kind"] == "task"
|
|
|
|
|
assert result["group"] == "optional"
|