diff --git a/doc/dev/reference/components/daemon.rst b/doc/dev/reference/components/daemon.rst index ff6dd4664..69ef72786 100644 --- a/doc/dev/reference/components/daemon.rst +++ b/doc/dev/reference/components/daemon.rst @@ -8,3 +8,6 @@ Daemon .. autoclass:: plinth.daemon.RelatedDaemon :members: + +.. autoclass:: plinth.daemon.SharedDaemon + :members: diff --git a/plinth/daemon.py b/plinth/daemon.py index 88750ece0..24844f1be 100644 --- a/plinth/daemon.py +++ b/plinth/daemon.py @@ -135,6 +135,41 @@ class RelatedDaemon(app.FollowerComponent): self.unit = unit +class SharedDaemon(Daemon): + """Component to manage a daemon that is used by multiple apps. + + Daemons such as a database server are a hard requirement for an app. + However, there may be multiple apps using that server. This component + ensures that server is enabled and running when app is enabled. It runs + diagnostics on the daemon when app is diagnosed. The primary difference + from the Daemon component is that when the app is disabled the daemon must + only be disabled if there is no other app using this daemon. + """ + + # A shared daemon may be running even when an app is disabled because + # another app might be using the daemon. Hence, the enabled/disabled state + # of this component can't be used to determine the enabled/disabled state + # of the app. + is_leader = False + + def set_enabled(self, enabled): + """Do nothing. Enabled state is still determined by unit status.""" + + def disable(self): + """Disable the daemon iff this is the last app using the daemon.""" + other_apps_enabled = False + for other_app in app.App.list(): + if other_app.app_id == self.app_id: + continue + + for component in other_app.get_components_of_type(SharedDaemon): + if component.unit == self.unit and other_app.is_enabled(): + other_apps_enabled = True + + if not other_apps_enabled: + super().disable() + + def app_is_running(app_): """Return whether all the daemons in the app are running.""" for component in app_.components.values(): diff --git a/plinth/tests/test_daemon.py b/plinth/tests/test_daemon.py index 7051d6fe3..9dabdf092 100644 --- a/plinth/tests/test_daemon.py +++ b/plinth/tests/test_daemon.py @@ -10,7 +10,7 @@ from unittest.mock import Mock, call, patch import pytest from plinth.app import App, FollowerComponent, Info -from plinth.daemon import (Daemon, RelatedDaemon, app_is_running, +from plinth.daemon import (Daemon, RelatedDaemon, SharedDaemon, app_is_running, diagnose_netcat, diagnose_port_listening) from plinth.modules.diagnostics.check import DiagnosticCheck, Result @@ -332,3 +332,56 @@ def test_related_daemon_initialization(): with pytest.raises(ValueError): RelatedDaemon(None, 'test-daemon') + + +def test_shared_daemon_leader(): + """Test that shared daemon is not a leader component.""" + component1 = SharedDaemon('test-component1', 'test-daemon') + assert not component1.is_leader + + +@patch('plinth.action_utils.service_is_enabled') +def test_shared_daemon_set_enabled(service_is_enabled): + """Test that enabled status is determined by unit status.""" + component = SharedDaemon('test-component', 'test-daemon') + + service_is_enabled.return_value = False + component.set_enabled(False) + assert not component.is_enabled() + component.set_enabled(True) + assert not component.is_enabled() + + service_is_enabled.return_value = True + component.set_enabled(False) + assert component.is_enabled() + component.set_enabled(True) + assert component.is_enabled() + + +@patch('plinth.privileged.service.disable') +def test_shared_daemon_disable(disable_method): + """Test that shared daemon disables service correctly.""" + + class AppTest2(App): + """Test application class.""" + app_id = 'test-app-2' + + component1 = SharedDaemon('test-component1', 'test-daemon') + app1 = AppTest() + app1.add(component1) + app1.is_enabled = Mock() + + component2 = SharedDaemon('test-component2', 'test-daemon') + app2 = AppTest2() + app2.add(component2) + + # When another app is enabled, service should not be disabled + app1.is_enabled.return_value = True + app2.disable() + assert disable_method.mock_calls == [] + + # When all other apps are disabled, service should be disabled + disable_method.reset_mock() + app1.is_enabled.return_value = False + app2.disable() + assert disable_method.mock_calls == [call('test-daemon')]