apache: Add component to host an app on a site's root

Tests:

- Unit tests work.

- Functional tests on bepasty work.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-05-13 15:00:53 -07:00 committed by James Valleroy
parent 0fa1dcf902
commit d76a371f57
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
4 changed files with 321 additions and 17 deletions

View File

@ -6,5 +6,8 @@ Webserver
.. autoclass:: plinth.modules.apache.components.Webserver .. autoclass:: plinth.modules.apache.components.Webserver
:members: :members:
.. autoclass:: plinth.modules.apache.components.WebserverRoot
:members:
.. autoclass:: plinth.modules.apache.components.Uwsgi .. autoclass:: plinth.modules.apache.components.Uwsgi
:members: :members:

View File

@ -6,7 +6,7 @@ import subprocess
from django.utils.translation import gettext_noop from django.utils.translation import gettext_noop
from plinth import action_utils, app from plinth import action_utils, app, kvstore
from plinth.diagnostic_check import (DiagnosticCheck, from plinth.diagnostic_check import (DiagnosticCheck,
DiagnosticCheckParameters, Result) DiagnosticCheckParameters, Result)
from plinth.privileged import service as service_privileged from plinth.privileged import service as service_privileged
@ -17,8 +17,9 @@ from . import privileged
class Webserver(app.LeaderComponent): class Webserver(app.LeaderComponent):
"""Component to enable/disable Apache configuration.""" """Component to enable/disable Apache configuration."""
def __init__(self, component_id, web_name, kind='config', urls=None, def __init__(self, component_id: str, web_name: str, kind: str = 'config',
expect_redirects=False, last_updated_version=None): urls: list[str] | None = None, expect_redirects: bool = False,
last_updated_version: int | None = None):
"""Initialize the web server component. """Initialize the web server component.
component_id should be a unique ID across all components of an app and component_id should be a unique ID across all components of an app and
@ -35,6 +36,9 @@ class Webserver(app.LeaderComponent):
urls is a list of URLs over which a HTTP services will be available due urls is a list of URLs over which a HTTP services will be available due
to this component. This list is only used for running diagnostics. to this component. This list is only used for running diagnostics.
expect_redirects is a boolean that allows redirects when trying to
access the URLs during diagnosis of the component.
last_updated_version is the app version in which the web server last_updated_version is the app version in which the web server
configuration/site/module file was updated. Using this, web server will configuration/site/module file was updated. Using this, web server will
be automatically reloaded or restarted as necessary during app upgrade. be automatically reloaded or restarted as necessary during app upgrade.
@ -47,15 +51,15 @@ class Webserver(app.LeaderComponent):
self.expect_redirects = expect_redirects self.expect_redirects = expect_redirects
self.last_updated_version = last_updated_version or 0 self.last_updated_version = last_updated_version or 0
def is_enabled(self): def is_enabled(self) -> bool:
"""Return whether the Apache configuration is enabled.""" """Return whether the Apache configuration is enabled."""
return action_utils.webserver_is_enabled(self.web_name, kind=self.kind) return action_utils.webserver_is_enabled(self.web_name, kind=self.kind)
def enable(self): def enable(self) -> None:
"""Enable the Apache configuration.""" """Enable the Apache configuration."""
privileged.enable(self.web_name, self.kind) privileged.enable(self.web_name, self.kind)
def disable(self): def disable(self) -> None:
"""Disable the Apache configuration.""" """Disable the Apache configuration."""
privileged.disable(self.web_name, self.kind) privileged.disable(self.web_name, self.kind)
@ -63,7 +67,6 @@ class Webserver(app.LeaderComponent):
"""Check if the web path is accessible by clients. """Check if the web path is accessible by clients.
See :py:meth:`plinth.app.Component.diagnose`. See :py:meth:`plinth.app.Component.diagnose`.
""" """
results = [] results = []
for url in self.urls: for url in self.urls:
@ -79,7 +82,7 @@ class Webserver(app.LeaderComponent):
return results return results
def setup(self, old_version): def setup(self, old_version: int):
"""Restart/reload web server if configuration files changed.""" """Restart/reload web server if configuration files changed."""
if not old_version: if not old_version:
# App is being freshly setup. After setup, app will be enabled # App is being freshly setup. After setup, app will be enabled
@ -102,10 +105,115 @@ class Webserver(app.LeaderComponent):
service_privileged.reload('apache2') service_privileged.reload('apache2')
class WebserverRoot(app.FollowerComponent):
"""Component to enable/disable Apache configuration for domain root.
Each domain has a unique virtual host configuration in Apache. This file
includes an option configuration file that can dropped in by FreedomBox. If
an app wants to be hosted on a dedicated domain, it can provide a
configuration file that is meant to be in the <VirtualHost> section. Using
this component, the include file fragment for a selected domain can be
linked to app's configuration file. Then, for the selected domain, the
app's configuration becomes the domain's root configuration.
This components uses key/value store to remember the selected domain. When
the domain changes, the change must be notified using domain_set().
"""
def __init__(self, component_id: str, web_name: str,
expect_redirects: bool = False,
last_updated_version: int | None = None):
"""Initialize the web server component for domain root.
component_id should be a unique ID across all components of an app and
across all components.
web_name is the primary part of the configuration file path which must
be enabled/disabled by this component. The file's path should be
/etc/apache2/includes/<web_name>.conf.
expect_redirects is a boolean that allows redirects when trying to
access the domain URL during diagnosis of the component.
last_updated_version is the app version in which the web server
configuration/site/module file was updated. Using this, web server will
be automatically reloaded or restarted as necessary during app upgrade.
"""
super().__init__(component_id)
self.web_name = web_name
self.expect_redirects = expect_redirects
self.last_updated_version = last_updated_version or 0
def enable(self) -> None:
"""Link the Apache site root configuration to app configuration."""
domain = self.domain_get()
if domain:
privileged.link_root(domain, self.web_name)
def disable(self) -> None:
"""Unlink the Apache site root configuration from app configuration."""
domain = self.domain_get()
if domain:
privileged.unlink_root(domain)
def _key_get(self) -> str:
"""Return the key used to store the domain in kvstore."""
return f'{self.component_id}_domain'
def domain_get(self) -> str | None:
"""Return the currently configured domain name."""
return kvstore.get_default(self._key_get(), None)
def domain_set(self, domain: str | None):
"""Set the domain to use with the app."""
self.disable()
kvstore.set(self._key_get(), domain)
if self.app.is_enabled():
self.enable()
def diagnose(self) -> list[DiagnosticCheck]:
"""Check if the site root path is accessible by clients.
See :py:meth:`plinth.app.Component.diagnose`.
"""
results = []
domain = self.domain_get()
if domain:
results.append(
diagnose_url(f'https://{domain}', check_certificate=False,
component_id=self.component_id))
return results
def setup(self, old_version: int):
"""Restart/reload web server if configuration files changed."""
if not old_version:
# App is being freshly setup. After setup, app will be enabled
# which will result in reload/restart of web server.
return
if old_version >= self.last_updated_version:
# Already using the latest configuration. Web server reload/restart
# is not necessary.
return
if not self.app.is_enabled():
# App is currently disabled, web server will reloaded/restarted
# when the app is enabled.
return
service_privileged.reload('apache2')
def uninstall(self):
"""Remove the domain configured."""
kvstore.delete(self._key_get(), ignore_missing=True)
class Uwsgi(app.LeaderComponent): class Uwsgi(app.LeaderComponent):
"""Component to enable/disable uWSGI configuration.""" """Component to enable/disable uWSGI configuration."""
def __init__(self, component_id, uwsgi_name): def __init__(self, component_id: str, uwsgi_name: str):
"""Initialize the uWSGI component. """Initialize the uWSGI component.
component_id should be a unique ID across all components of an app and component_id should be a unique ID across all components of an app and
@ -119,20 +227,20 @@ class Uwsgi(app.LeaderComponent):
self.uwsgi_name = uwsgi_name self.uwsgi_name = uwsgi_name
def is_enabled(self): def is_enabled(self) -> bool:
"""Return whether the uWSGI configuration is enabled.""" """Return whether the uWSGI configuration is enabled."""
return action_utils.uwsgi_is_enabled(self.uwsgi_name) \ return action_utils.uwsgi_is_enabled(self.uwsgi_name) \
and action_utils.service_is_enabled('uwsgi') and action_utils.service_is_enabled('uwsgi')
def enable(self): def enable(self) -> None:
"""Enable the uWSGI configuration.""" """Enable the uWSGI configuration."""
privileged.uwsgi_enable(self.uwsgi_name) privileged.uwsgi_enable(self.uwsgi_name)
def disable(self): def disable(self) -> None:
"""Disable the uWSGI configuration.""" """Disable the uWSGI configuration."""
privileged.uwsgi_disable(self.uwsgi_name) privileged.uwsgi_disable(self.uwsgi_name)
def is_running(self): def is_running(self) -> bool:
"""Return whether the uWSGI daemon is running with configuration.""" """Return whether the uWSGI daemon is running with configuration."""
return action_utils.uwsgi_is_enabled(self.uwsgi_name) \ return action_utils.uwsgi_is_enabled(self.uwsgi_name) \
and action_utils.service_is_running('uwsgi') and action_utils.service_is_running('uwsgi')

View File

@ -3,6 +3,7 @@
import glob import glob
import os import os
import pathlib
import re import re
import subprocess import subprocess
@ -173,6 +174,36 @@ def _assert_kind(kind: str):
raise ValueError('Invalid value for parameter kind') raise ValueError('Invalid value for parameter kind')
@privileged
def link_root(domain: str, name: str):
"""Link the Apache site root configuration to app configuration."""
if '/' in domain or '/' in name:
raise ValueError('Invalid domain or name')
target_config = f'{name}.conf'
include_root = pathlib.Path('/etc/apache2/includes/')
config = include_root / f'{domain}-include-freedombox.conf'
config.unlink(missing_ok=True)
config.symlink_to(target_config)
action_utils.service_reload('apache2')
@privileged
def unlink_root(domain: str):
"""Unlink the Apache site root configuration from app configuration."""
if '/' in domain:
raise ValueError('Invalid domain')
include_root = pathlib.Path('/etc/apache2/includes/')
config = include_root / f'{domain}-include-freedombox.conf'
if not config.is_symlink():
return # Does not exist or not a symlink
config.unlink()
action_utils.service_reload('apache2')
@privileged @privileged
def uwsgi_enable(name: str): def uwsgi_enable(name: str):
"""Enable uWSGI configuration and reload.""" """Enable uWSGI configuration and reload."""

View File

@ -4,14 +4,14 @@ Test module for webserver components.
""" """
import subprocess import subprocess
from unittest.mock import call, patch from unittest.mock import Mock, PropertyMock, call, patch
import pytest import pytest
from plinth import app from plinth import app, kvstore
from plinth.diagnostic_check import DiagnosticCheck, Result from plinth.diagnostic_check import DiagnosticCheck, Result
from plinth.modules.apache.components import (Uwsgi, Webserver, check_url, from plinth.modules.apache.components import (Uwsgi, Webserver, WebserverRoot,
diagnose_url, check_url, diagnose_url,
diagnose_url_on_all) diagnose_url_on_all)
@ -164,6 +164,168 @@ def test_webserver_setup(service_reload, service_restart):
service_reload.assert_not_called() service_reload.assert_not_called()
def test_webserver_root_init():
"""Test that webserver root component can be initialized."""
with pytest.raises(ValueError):
WebserverRoot(None, None)
webserver = WebserverRoot('test-webserverroot', 'test-config',
expect_redirects=True, last_updated_version=10)
assert webserver.component_id == 'test-webserverroot'
assert webserver.web_name == 'test-config'
assert webserver.expect_redirects
assert webserver.last_updated_version == 10
webserver = WebserverRoot('test-webserverroot', None)
assert not webserver.expect_redirects
assert webserver.last_updated_version == 0
@patch('plinth.modules.apache.privileged.link_root')
def test_webserver_root_enable(link_root):
"""Test that enabling webserver root works."""
webserver = WebserverRoot('test-webserver', 'test-config')
with patch('plinth.modules.apache.components.WebserverRoot.domain_get'
) as get:
get.return_value = None
webserver.enable()
link_root.assert_not_called()
get.return_value = 'x-domain'
webserver.enable()
link_root.assert_has_calls([call('x-domain', 'test-config')])
@patch('plinth.modules.apache.privileged.unlink_root')
def test_webserver_root_disable(unlink_root):
"""Test that disabling webserver root works."""
webserver = WebserverRoot('test-webserver', 'test-config')
with patch('plinth.modules.apache.components.WebserverRoot.domain_get'
) as get:
get.return_value = None
webserver.disable()
unlink_root.assert_not_called()
get.return_value = 'x-domain'
webserver.disable()
unlink_root.assert_has_calls([call('x-domain')])
@pytest.mark.django_db
def test_webserver_root_domain_get():
"""Test retrieving webserver root's domain."""
webserver = WebserverRoot('test-webserver', 'test-config')
assert webserver.domain_get() is None
kvstore.set('test-webserver_domain', 'test-domain')
assert webserver.domain_get() == 'test-domain'
@pytest.mark.django_db
@patch('plinth.modules.apache.privileged.unlink_root')
@patch('plinth.modules.apache.privileged.link_root')
@patch('plinth.app.Component.app', new_callable=PropertyMock)
def test_webserver_root_domain_set(component_app, link_root, unlink_root):
"""Test setting webserver root's domain."""
webserver = WebserverRoot('test-webserver', 'test-config')
assert webserver.domain_get() is None
app = Mock()
component_app.return_value = app
app.is_enabled.return_value = True
webserver.domain_set('test-domain')
assert unlink_root.mock_calls == []
assert webserver.domain_get() == 'test-domain'
assert link_root.mock_calls == [call('test-domain', 'test-config')]
link_root.reset_mock()
app.is_enabled.return_value = False
assert not webserver.app.is_enabled()
webserver.domain_set('test-domain2')
assert unlink_root.mock_calls == [call('test-domain')]
assert webserver.domain_get() == 'test-domain2'
assert link_root.mock_calls == []
webserver.domain_set(None)
assert webserver.domain_get() is None
@pytest.mark.django_db
@patch('plinth.modules.apache.components.WebserverRoot.disable')
@patch('plinth.modules.apache.components.WebserverRoot.enable')
@patch('plinth.modules.apache.components.diagnose_url')
@patch('plinth.app.Component.app', new_callable=PropertyMock)
def test_webserver_root_diagnose(component_app, diagnose_url, enable, disable):
"""Test running diagnostics on webserver root component."""
webserver = WebserverRoot('test-webserver', 'test-config')
assert webserver.diagnose() == []
webserver.domain_set('test-domain')
result = DiagnosticCheck('test-all-id', 'test-result', 'success', {},
'message')
diagnose_url.return_value = result
assert webserver.diagnose() == [result]
@patch('plinth.privileged.service.reload')
def test_webserver_root_setup(service_reload):
"""Test that component reloads web server during app upgrades."""
class AppTest(app.App):
app_id = 'testapp'
enabled = False
def is_enabled(self):
return self.enabled
app1 = AppTest()
# Don't fail when last_updated_version is not provided.
webserver1 = WebserverRoot('test-webserverroot1', 'test-config')
assert webserver1.last_updated_version == 0
webserver1.setup(old_version=10)
service_reload.assert_not_called()
webserver1 = WebserverRoot('test-webserverroot1', 'test-config',
last_updated_version=5)
for version in (0, 5, 6):
webserver1.setup(old_version=version)
service_reload.assert_not_called()
app1.enabled = False
webserver2 = WebserverRoot('test-webserver2', 'test-config',
last_updated_version=5)
app1.add(webserver2)
webserver2.setup(old_version=3)
service_reload.assert_not_called()
app1.enabled = True
webserver3 = WebserverRoot('test-webserver3', 'test-config',
last_updated_version=5)
app1.add(webserver3)
webserver3.setup(old_version=3)
service_reload.assert_has_calls([call('apache2')])
service_reload.reset_mock()
@pytest.mark.django_db
@patch('plinth.modules.apache.components.WebserverRoot.disable')
@patch('plinth.modules.apache.components.WebserverRoot.enable')
@patch('plinth.app.Component.app', new_callable=PropertyMock)
def test_webserver_root_uninstall(component_app, enable, disable):
"""Test that component removes the DB key during uninstall."""
webserver = WebserverRoot('test-webserver', 'test-config')
webserver.uninstall()
assert kvstore.get_default('test-webserver_domain', 'x-value') == 'x-value'
webserver.domain_set('test-domain')
assert kvstore.get('test-webserver_domain') == 'test-domain'
webserver.uninstall()
assert kvstore.get_default('test-webserver_domain', 'x-value') == 'x-value'
def test_uwsgi_init(): def test_uwsgi_init():
"""Test that uWSGI component can be initialized.""" """Test that uWSGI component can be initialized."""
with pytest.raises(ValueError): with pytest.raises(ValueError):