mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-04-29 10:10:19 +00:00
container: Add component for managing containers using podman
Tests: - Unit tests work. - Enabling/disabling an app works. - Developer documentation shows the Container in reference section. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
16f90d863b
commit
5b0d980035
7
doc/dev/reference/components/container.rst
Normal file
7
doc/dev/reference/components/container.rst
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
|
Container
|
||||||
|
^^^^^^^^^
|
||||||
|
|
||||||
|
.. autoclass:: plinth.container.Container
|
||||||
|
:members:
|
||||||
@ -22,6 +22,7 @@ Components
|
|||||||
staticfiles
|
staticfiles
|
||||||
backups
|
backups
|
||||||
coturn
|
coturn
|
||||||
|
container
|
||||||
|
|
||||||
Base Classes
|
Base Classes
|
||||||
^^^^^^^^^^^^
|
^^^^^^^^^^^^
|
||||||
|
|||||||
143
plinth/container.py
Normal file
143
plinth/container.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""Component to manage a container using podman."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_noop
|
||||||
|
|
||||||
|
from plinth import app, privileged
|
||||||
|
from plinth.daemon import diagnose_port_listening
|
||||||
|
from plinth.diagnostic_check import (DiagnosticCheck,
|
||||||
|
DiagnosticCheckParameters, Result)
|
||||||
|
|
||||||
|
|
||||||
|
class Container(app.LeaderComponent):
|
||||||
|
"""Component to manage a podman container."""
|
||||||
|
|
||||||
|
def __init__(self, component_id: str, name: str, image_name: str,
|
||||||
|
volume_name: str, volume_path: str,
|
||||||
|
volumes: dict[str, str] | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
binds_to: list[str] | None = None,
|
||||||
|
devices: dict[str, str] | None = None,
|
||||||
|
listen_ports: list[tuple[int, str]] | None = None):
|
||||||
|
"""Initialize a container component.
|
||||||
|
|
||||||
|
`name` is a string which is the name of the container to create and
|
||||||
|
manage. A systemd service unit with the same name is also created.
|
||||||
|
|
||||||
|
`image_name` is a string that represents the repository location from
|
||||||
|
which the container images must be pull from.
|
||||||
|
|
||||||
|
`volume_name` is a string with name of the storage volume to create for
|
||||||
|
the container to use.
|
||||||
|
|
||||||
|
`volume_path` is a string path on the host machine where the volume
|
||||||
|
files for the container is stored.
|
||||||
|
|
||||||
|
`volumes` is a dictionary mapping each string path on the host to a
|
||||||
|
string path inside the container. These are bind mounts made available
|
||||||
|
inside the container.
|
||||||
|
|
||||||
|
`env` is a dictionary of string key to string values that set the
|
||||||
|
environment variables for the processes inside the container to run in.
|
||||||
|
|
||||||
|
`binds_to` is a list of systemd service units that the container's own
|
||||||
|
systemd service unit will add BindsTo= and After= dependencies on.
|
||||||
|
|
||||||
|
`devices` is a list of strings with device paths that will be made
|
||||||
|
available inside the container. If any of the devices don't exist on
|
||||||
|
the host, they will not be added.
|
||||||
|
|
||||||
|
`listen_ports` is a list of tuples containing port number and 'tcp4' or
|
||||||
|
'tcp6' network types on which this container is expected to listen on
|
||||||
|
after starting the container. This information is used to run
|
||||||
|
diagnostic checks on the container.
|
||||||
|
"""
|
||||||
|
super().__init__(component_id)
|
||||||
|
self.name = name
|
||||||
|
self.image_name = image_name
|
||||||
|
self.volume_name = volume_name
|
||||||
|
self.volume_path = volume_path
|
||||||
|
self.volumes = volumes
|
||||||
|
self.env = env
|
||||||
|
self.binds_to = binds_to
|
||||||
|
self.devices = devices
|
||||||
|
self.listen_ports = listen_ports or []
|
||||||
|
|
||||||
|
def is_enabled(self):
|
||||||
|
"""Return if the container is enabled."""
|
||||||
|
return privileged.container_is_enabled(self.name)
|
||||||
|
|
||||||
|
def enable(self):
|
||||||
|
"""Run operations to enable and run the container."""
|
||||||
|
super().enable()
|
||||||
|
privileged.container_enable(self.name)
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
"""Run operations to disable and stop the container."""
|
||||||
|
super().disable()
|
||||||
|
privileged.container_disable(self.name)
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
"""Return whether the container service is running."""
|
||||||
|
return privileged.is_running(self.name)
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def ensure_running(self):
|
||||||
|
"""Ensure a service is running and return to previous state."""
|
||||||
|
from plinth.privileged import service as service_privileged
|
||||||
|
starting_state = self.is_running()
|
||||||
|
if not starting_state:
|
||||||
|
service_privileged.enable(self.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield starting_state
|
||||||
|
finally:
|
||||||
|
if not starting_state:
|
||||||
|
service_privileged.disable(self.name)
|
||||||
|
|
||||||
|
def setup(self, old_version: int):
|
||||||
|
"""Bring up and run the container."""
|
||||||
|
# Determine whether app should be disabled after setup
|
||||||
|
should_disable = old_version and not self.is_enabled()
|
||||||
|
|
||||||
|
privileged.container_setup(self.name, self.image_name,
|
||||||
|
self.volume_name, self.volume_path,
|
||||||
|
self.volumes, self.env, self.binds_to,
|
||||||
|
self.devices)
|
||||||
|
|
||||||
|
if should_disable:
|
||||||
|
self.disable()
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
"""Remove the container."""
|
||||||
|
privileged.container_uninstall(self.name, self.image_name,
|
||||||
|
self.volume_name, self.volume_path)
|
||||||
|
|
||||||
|
def diagnose(self) -> list[DiagnosticCheck]:
|
||||||
|
"""Check if the container is running..
|
||||||
|
|
||||||
|
See :py:meth:`plinth.app.Component.diagnose`.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
results.append(self._diagnose_unit_is_running())
|
||||||
|
for port in self.listen_ports:
|
||||||
|
results.append(
|
||||||
|
diagnose_port_listening(port[0], port[1], None,
|
||||||
|
self.component_id))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _diagnose_unit_is_running(self) -> DiagnosticCheck:
|
||||||
|
"""Check if a daemon is running."""
|
||||||
|
check_id = f'container-running-{self.name}'
|
||||||
|
result = Result.PASSED if self.is_running() else Result.FAILED
|
||||||
|
|
||||||
|
description = gettext_noop('Container {container_name} is running')
|
||||||
|
parameters: DiagnosticCheckParameters = {
|
||||||
|
'container_name': str(self.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiagnosticCheck(check_id, description, result, parameters,
|
||||||
|
self.component_id)
|
||||||
@ -2,6 +2,9 @@
|
|||||||
"""Package holding all the privileged actions outside of apps."""
|
"""Package holding all the privileged actions outside of apps."""
|
||||||
|
|
||||||
from .config import dropin_is_valid, dropin_link, dropin_unlink
|
from .config import dropin_is_valid, dropin_link, dropin_unlink
|
||||||
|
from .container import (container_disable, container_enable,
|
||||||
|
container_is_enabled, container_setup,
|
||||||
|
container_uninstall)
|
||||||
from .packages import (filter_conffile_packages, install,
|
from .packages import (filter_conffile_packages, install,
|
||||||
is_package_manager_busy, remove, update)
|
is_package_manager_busy, remove, update)
|
||||||
from .service import (disable, enable, is_enabled, is_running, mask, reload,
|
from .service import (disable, enable, is_enabled, is_running, mask, reload,
|
||||||
@ -13,5 +16,6 @@ __all__ = [
|
|||||||
'update', 'systemd_set_default', 'disable', 'enable', 'is_enabled',
|
'update', 'systemd_set_default', 'disable', 'enable', 'is_enabled',
|
||||||
'is_running', 'mask', 'reload', 'restart', 'start', 'stop',
|
'is_running', 'mask', 'reload', 'restart', 'start', 'stop',
|
||||||
'try_reload_or_restart', 'try_restart', 'unmask', 'dropin_is_valid',
|
'try_reload_or_restart', 'try_restart', 'unmask', 'dropin_is_valid',
|
||||||
'dropin_link', 'dropin_unlink'
|
'dropin_link', 'dropin_unlink', 'container_disable', 'container_enable',
|
||||||
|
'container_is_enabled', 'container_setup', 'container_uninstall'
|
||||||
]
|
]
|
||||||
|
|||||||
78
plinth/privileged/container.py
Normal file
78
plinth/privileged/container.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""Handle container run using podman."""
|
||||||
|
|
||||||
|
from plinth import action_utils
|
||||||
|
from plinth import app as app_module
|
||||||
|
from plinth import module_loader
|
||||||
|
from plinth.actions import privileged
|
||||||
|
|
||||||
|
|
||||||
|
@privileged
|
||||||
|
def container_is_enabled(container: str) -> bool:
|
||||||
|
"""Return whether a container is enabled."""
|
||||||
|
_assert_container_is_managed(container)
|
||||||
|
return action_utils.podman_is_enabled(container)
|
||||||
|
|
||||||
|
|
||||||
|
@privileged
|
||||||
|
def container_enable(container: str):
|
||||||
|
"""Enable a container so that it start on system boot."""
|
||||||
|
_assert_container_is_managed(container)
|
||||||
|
action_utils.podman_enable(container)
|
||||||
|
action_utils.service_enable(container)
|
||||||
|
|
||||||
|
|
||||||
|
@privileged
|
||||||
|
def container_disable(container: str):
|
||||||
|
"""Disable a container so that it does not start on system boot."""
|
||||||
|
_assert_container_is_managed(container)
|
||||||
|
action_utils.service_disable(container)
|
||||||
|
action_utils.podman_disable(container)
|
||||||
|
|
||||||
|
|
||||||
|
@privileged
|
||||||
|
def container_setup(container: str, image_name: str, volume_name: str,
|
||||||
|
volume_path: str, volumes: dict[str, str] | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
binds_to: list[str] | None = None,
|
||||||
|
devices: dict[str, str] | None = None):
|
||||||
|
"""Remove and recreate the podman container."""
|
||||||
|
_assert_container_is_managed(container)
|
||||||
|
action_utils.podman_create(container, image_name, volume_name, volume_path,
|
||||||
|
volumes, env, binds_to, devices)
|
||||||
|
action_utils.service_start(container, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@privileged
|
||||||
|
def container_uninstall(container: str, image_name: str, volume_name: str,
|
||||||
|
volume_path: str):
|
||||||
|
"""Remove podman container."""
|
||||||
|
action_utils.podman_uninstall(container_name=container,
|
||||||
|
image_name=image_name,
|
||||||
|
volume_name=volume_name,
|
||||||
|
volume_path=volume_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_managed_containers() -> set[str]:
|
||||||
|
"""Get a set of all containers managed by FreedomBox."""
|
||||||
|
from plinth.container import Container
|
||||||
|
|
||||||
|
containers = set()
|
||||||
|
module_loader.load_modules()
|
||||||
|
app_module.apps_init()
|
||||||
|
for app in app_module.App.list():
|
||||||
|
components = app.get_components_of_type(Container)
|
||||||
|
for component in components:
|
||||||
|
containers.add(component.name)
|
||||||
|
|
||||||
|
return containers
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_container_is_managed(container_name):
|
||||||
|
"""Check that container is managed by one of the FreedomBox apps."""
|
||||||
|
managed_containers = _get_managed_containers()
|
||||||
|
if container_name not in managed_containers:
|
||||||
|
msg = ("The container '%s' is not managed by FreedomBox. Access is "
|
||||||
|
"only permitted for containers listed in the Container "
|
||||||
|
"components of any FreedomBox app.") % container_name
|
||||||
|
raise ValueError(msg)
|
||||||
@ -106,6 +106,8 @@ def is_running(service: str) -> bool:
|
|||||||
|
|
||||||
def _get_managed_services():
|
def _get_managed_services():
|
||||||
"""Get a set of all services managed by FreedomBox."""
|
"""Get a set of all services managed by FreedomBox."""
|
||||||
|
from plinth.container import Container
|
||||||
|
|
||||||
services = set()
|
services = set()
|
||||||
module_loader.load_modules()
|
module_loader.load_modules()
|
||||||
app_module.apps_init()
|
app_module.apps_init()
|
||||||
@ -120,6 +122,10 @@ def _get_managed_services():
|
|||||||
for component in components:
|
for component in components:
|
||||||
services.add(component.unit)
|
services.add(component.unit)
|
||||||
|
|
||||||
|
components = app.get_components_of_type(Container)
|
||||||
|
for component in components:
|
||||||
|
services.add(component.name)
|
||||||
|
|
||||||
return services
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
188
plinth/tests/test_container.py
Normal file
188
plinth/tests/test_container.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""Test component to manage a container using podman."""
|
||||||
|
|
||||||
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plinth.app import App, Info
|
||||||
|
from plinth.container import Container
|
||||||
|
from plinth.diagnostic_check import DiagnosticCheck, Result
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures('mock_privileged')
|
||||||
|
privileged_modules_to_mock = [
|
||||||
|
'plinth.privileged', 'plinth.privileged.container',
|
||||||
|
'plinth.privileged.service'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AppTest(App):
|
||||||
|
"""Test application that contains a daemon."""
|
||||||
|
|
||||||
|
app_id = 'test-app'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name='container')
|
||||||
|
def fixture_container():
|
||||||
|
app1 = AppTest()
|
||||||
|
app1.add(Info('test-app', 1))
|
||||||
|
container = Container('test-container', 'name1', 'image:stable', 'volume1',
|
||||||
|
'/volume', {'/host1': '/cont1'}, {'KEY1': 'VAL1'},
|
||||||
|
['service1.service'], {'/dev/host1': '/dev/cont1'},
|
||||||
|
[(1234, 'tcp4')])
|
||||||
|
app1.add(container)
|
||||||
|
with patch('plinth.app.App.list') as app_list:
|
||||||
|
app_list.return_value = [app1]
|
||||||
|
yield container
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_init(container):
|
||||||
|
"""Test initializing the container component."""
|
||||||
|
component = Container('test-container', 'name1', 'image:stable', 'volume1',
|
||||||
|
'/volume')
|
||||||
|
assert component.component_id == 'test-container'
|
||||||
|
assert component.name == 'name1'
|
||||||
|
assert component.image_name == 'image:stable'
|
||||||
|
assert component.volume_name == 'volume1'
|
||||||
|
assert component.volume_path == '/volume'
|
||||||
|
assert component.volumes is None
|
||||||
|
assert component.env is None
|
||||||
|
assert component.binds_to is None
|
||||||
|
assert component.devices is None
|
||||||
|
assert component.listen_ports == []
|
||||||
|
|
||||||
|
assert container.component_id == 'test-container'
|
||||||
|
assert container.name == 'name1'
|
||||||
|
assert container.image_name == 'image:stable'
|
||||||
|
assert container.volume_name == 'volume1'
|
||||||
|
assert container.volume_path == '/volume'
|
||||||
|
assert container.volumes == {'/host1': '/cont1'}
|
||||||
|
assert container.env == {'KEY1': 'VAL1'}
|
||||||
|
assert container.binds_to == ['service1.service']
|
||||||
|
assert container.devices == {'/dev/host1': '/dev/cont1'}
|
||||||
|
assert container.listen_ports == [(1234, 'tcp4')]
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.podman_is_enabled')
|
||||||
|
def test_container_is_enabled(podman_is_enabled, container):
|
||||||
|
"""Test checking if container is enabled."""
|
||||||
|
podman_is_enabled.return_value = False
|
||||||
|
assert not container.is_enabled()
|
||||||
|
|
||||||
|
podman_is_enabled.return_value = True
|
||||||
|
assert container.is_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.service_enable')
|
||||||
|
@patch('plinth.action_utils.podman_enable')
|
||||||
|
def test_container_enable(podman_enable, enable, container):
|
||||||
|
"""Test enabling a container component."""
|
||||||
|
container.enable()
|
||||||
|
assert podman_enable.mock_calls == [call('name1')]
|
||||||
|
assert enable.mock_calls == [call('name1')]
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.service_disable')
|
||||||
|
@patch('plinth.action_utils.podman_disable')
|
||||||
|
def test_container_disable(podman_disable, disable, container):
|
||||||
|
"""Test disabling a container component."""
|
||||||
|
container.disable()
|
||||||
|
assert podman_disable.mock_calls == [call('name1')]
|
||||||
|
assert disable.mock_calls == [call('name1')]
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.service_is_running')
|
||||||
|
def test_container_is_running(service_is_running, container):
|
||||||
|
"""Test checking of container component is running."""
|
||||||
|
service_is_running.return_value = False
|
||||||
|
assert not container.is_running()
|
||||||
|
assert service_is_running.mock_calls == [call('name1')]
|
||||||
|
|
||||||
|
service_is_running.reset_mock()
|
||||||
|
service_is_running.return_value = True
|
||||||
|
assert container.is_running()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.service_disable')
|
||||||
|
@patch('plinth.action_utils.service_enable')
|
||||||
|
@patch('plinth.action_utils.service_is_running')
|
||||||
|
def test_container_ensure_running(service_is_running, enable, disable,
|
||||||
|
container):
|
||||||
|
"""Test checking of container component can be ensured to be running."""
|
||||||
|
service_is_running.return_value = True
|
||||||
|
with container.ensure_running() as state:
|
||||||
|
assert state
|
||||||
|
assert enable.mock_calls == []
|
||||||
|
|
||||||
|
assert disable.mock_calls == []
|
||||||
|
|
||||||
|
service_is_running.return_value = False
|
||||||
|
with container.ensure_running() as state:
|
||||||
|
assert not state
|
||||||
|
assert enable.mock_calls == [call('name1')]
|
||||||
|
|
||||||
|
assert disable.mock_calls == [call('name1')]
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.service_disable')
|
||||||
|
@patch('plinth.action_utils.service_start')
|
||||||
|
@patch('plinth.action_utils.podman_disable')
|
||||||
|
@patch('plinth.action_utils.podman_is_enabled')
|
||||||
|
@patch('plinth.action_utils.podman_create')
|
||||||
|
def test_container_setup(podman_create, is_enabled, disable, service_start,
|
||||||
|
service_disable, container):
|
||||||
|
"""Test setting up the container."""
|
||||||
|
is_enabled.return_value = True
|
||||||
|
container.setup(0)
|
||||||
|
assert podman_create.mock_calls == [
|
||||||
|
call('name1', 'image:stable', 'volume1', '/volume',
|
||||||
|
{'/host1': '/cont1'}, {'KEY1': 'VAL1'}, ['service1.service'],
|
||||||
|
{'/dev/host1': '/dev/cont1'})
|
||||||
|
]
|
||||||
|
assert service_start.mock_calls == [call('name1', check=True)]
|
||||||
|
assert disable.mock_calls == []
|
||||||
|
|
||||||
|
is_enabled.return_value = False
|
||||||
|
container.setup(0)
|
||||||
|
assert disable.mock_calls == []
|
||||||
|
|
||||||
|
is_enabled.return_value = False
|
||||||
|
container.setup(1)
|
||||||
|
assert disable.mock_calls == [call('name1')]
|
||||||
|
assert service_disable.mock_calls == [call('name1')]
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.podman_uninstall')
|
||||||
|
def test_container_uninstall(podman_uninstall, container):
|
||||||
|
"""Test uninstalling the container."""
|
||||||
|
container.uninstall()
|
||||||
|
assert podman_uninstall.mock_calls == [
|
||||||
|
call(container_name='name1', image_name='image:stable',
|
||||||
|
volume_name='volume1', volume_path='/volume')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@patch('plinth.action_utils.service_is_running')
|
||||||
|
@patch('plinth.container.diagnose_port_listening')
|
||||||
|
def test_container_diagnose(diagnose_port_listening, service_is_running,
|
||||||
|
container):
|
||||||
|
"""Test diagnosing the container."""
|
||||||
|
expected_results = [
|
||||||
|
DiagnosticCheck('container-running-name1',
|
||||||
|
'Container {container_name} is running', Result.PASSED,
|
||||||
|
{'container_name': 'name1'}, 'test-container'),
|
||||||
|
DiagnosticCheck('daemon-listening-tcp4-1234',
|
||||||
|
'Listening on tcp4 port 1234', Result.PASSED, {
|
||||||
|
'kind': 'tcp4',
|
||||||
|
'port': 1234
|
||||||
|
}, 'test-container'),
|
||||||
|
]
|
||||||
|
diagnose_port_listening.return_value = expected_results[1]
|
||||||
|
service_is_running.return_value = True
|
||||||
|
results = container.diagnose()
|
||||||
|
assert results == expected_results
|
||||||
|
|
||||||
|
service_is_running.return_value = False
|
||||||
|
expected_results[0].result = Result.FAILED
|
||||||
|
results = container.diagnose()
|
||||||
|
assert results == expected_results
|
||||||
Loading…
x
Reference in New Issue
Block a user