app: Add ability to retrieve logs from all systemd units of an app

Tests:

- Upto 200 lines are shown in the logs. The description and unit name of the app
is correct.

- Apps without systemd units don't have 'View Logs' menu item.

- Nextcloud container logs are shown.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
This commit is contained in:
Sunil Mohan Adapa 2025-09-11 11:34:39 -07:00 committed by Veiko Aasa
parent b3d5ee30ed
commit 0661d7da7c
No known key found for this signature in database
GPG Key ID: 478539CAE680674E
8 changed files with 139 additions and 13 deletions

View File

@ -176,6 +176,27 @@ def service_reset_failed(service_name: str, check: bool = False):
service_action(service_name, 'reset-failed', check=check) service_action(service_name, 'reset-failed', check=check)
def service_get_logs(service_name: str) -> str:
"""Return the last lines of journal entries for a unit."""
command = [
'journalctl', '--no-pager', '--lines=200', '--unit', service_name
]
process = subprocess.run(command, check=False, stdout=subprocess.PIPE)
return process.stdout.decode()
def service_show(service_name: str) -> dict[str, str]:
"""Return the status of the service in dictionary format."""
command = ['systemctl', 'show', service_name]
process = subprocess.run(command, check=False, stdout=subprocess.PIPE)
status = {}
for line in process.stdout.decode().splitlines():
parts = line.partition('=')
status[parts[0]] = parts[2]
return status
def service_action(service_name: str, action: str, check: bool = False): def service_action(service_name: str, action: str, check: bool = False):
"""Perform the given action on the service_name.""" """Perform the given action on the service_name."""
subprocess.run(['systemctl', action, service_name], subprocess.run(['systemctl', action, service_name],

View File

@ -309,6 +309,28 @@ class App:
return should_rerun_setup return should_rerun_setup
def has_logs(self) -> bool:
"""Return if any of the components emit logs.
This is typically true if the apps has daemons or containers.
"""
from plinth import log
for component in self.components.values():
if isinstance(component, log.LogEmitter):
return True
return False
def get_logs(self) -> _list_type[dict[str, str]]:
"""Return the logs in a dictionary format."""
from plinth import log
logs: list[dict[str, str]] = []
for component in self.components.values():
if isinstance(component, log.LogEmitter):
logs.append(component.get_logs())
return logs
class Component: class Component:
"""Interface for an app component. """Interface for an app component.

View File

@ -5,13 +5,13 @@ import contextlib
from django.utils.translation import gettext_noop from django.utils.translation import gettext_noop
from plinth import app, privileged from plinth import app, log, privileged
from plinth.daemon import diagnose_port_listening from plinth.daemon import diagnose_port_listening
from plinth.diagnostic_check import (DiagnosticCheck, from plinth.diagnostic_check import (DiagnosticCheck,
DiagnosticCheckParameters, Result) DiagnosticCheckParameters, Result)
class Container(app.LeaderComponent): class Container(app.LeaderComponent, log.LogEmitter):
"""Component to manage a podman container.""" """Component to manage a podman container."""
def __init__(self, component_id: str, name: str, image_name: str, def __init__(self, component_id: str, name: str, image_name: str,
@ -65,6 +65,9 @@ class Container(app.LeaderComponent):
self.devices = devices self.devices = devices
self.listen_ports = listen_ports or [] self.listen_ports = listen_ports or []
# For logs
self.unit = self.name
def is_enabled(self): def is_enabled(self):
"""Return if the container is enabled.""" """Return if the container is enabled."""
return privileged.container_is_enabled(self.name) return privileged.container_is_enabled(self.name)

View File

@ -8,12 +8,12 @@ import subprocess
import psutil import psutil
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, log
from plinth.diagnostic_check import (DiagnosticCheck, from plinth.diagnostic_check import (DiagnosticCheck,
DiagnosticCheckParameters, Result) DiagnosticCheckParameters, Result)
class Daemon(app.LeaderComponent): class Daemon(app.LeaderComponent, log.LogEmitter):
"""Component to manage a background daemon or any systemd unit.""" """Component to manage a background daemon or any systemd unit."""
def __init__(self, component_id: str, unit: str, def __init__(self, component_id: str, unit: str,
@ -130,7 +130,7 @@ class Daemon(app.LeaderComponent):
self.component_id) self.component_id)
class RelatedDaemon(app.FollowerComponent): class RelatedDaemon(app.FollowerComponent, log.LogEmitter):
"""Component to hold information about additional systemd units handled. """Component to hold information about additional systemd units handled.
Unlike a daemon described by the Daemon component which is enabled/disabled Unlike a daemon described by the Daemon component which is enabled/disabled
@ -202,8 +202,7 @@ def app_is_running(app_):
def diagnose_port_listening( def diagnose_port_listening(
port: int, kind: str = 'tcp', port: int, kind: str = 'tcp', listen_address: str | None = None,
listen_address: str | None = None,
component_id: str | None = None) -> DiagnosticCheck: component_id: str | None = None) -> DiagnosticCheck:
"""Run a diagnostic on whether a port is being listened on. """Run a diagnostic on whether a port is being listened on.

View File

@ -5,6 +5,7 @@ Setup logging for the application.
import logging import logging
import logging.config import logging.config
import typing
import warnings import warnings
from . import cfg from . import cfg
@ -12,6 +13,24 @@ from . import cfg
default_level = None default_level = None
class LogEmitterProtocol(typing.Protocol):
unit: str
class LogEmitter:
"""A mixin for App components that emit logs.
Used as a simple base class for identifying components that have logs. Use
the self.unit property to fetch systemd journal logs of the unit.
"""
unit: str
def get_logs(self: LogEmitterProtocol) -> dict[str, str]:
from plinth.privileged import service as service_privileged
return service_privileged.get_logs(self.unit)
class ColoredFormatter(logging.Formatter): class ColoredFormatter(logging.Formatter):
"""Print parts of log message in color.""" """Print parts of log message in color."""
codes = { codes = {

View File

@ -7,15 +7,16 @@ from .container import (container_disable, container_enable,
container_uninstall) 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, get_logs, is_enabled, is_running, mask,
restart, start, stop, systemd_set_default, reload, restart, start, stop, systemd_set_default,
try_reload_or_restart, try_restart, unmask) try_reload_or_restart, try_restart, unmask)
__all__ = [ __all__ = [
'filter_conffile_packages', 'install', 'is_package_manager_busy', 'remove', 'filter_conffile_packages', 'install', 'is_package_manager_busy', 'remove',
'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', 'get_logs',
'dropin_link', 'dropin_unlink', 'container_disable', 'container_enable', 'dropin_is_valid', 'dropin_link', 'dropin_unlink', 'container_disable',
'container_is_enabled', 'container_setup', 'container_uninstall' 'container_enable', 'container_is_enabled', 'container_setup',
'container_uninstall'
] ]

View File

@ -104,6 +104,16 @@ def is_running(service: str) -> bool:
return action_utils.service_is_running(service) return action_utils.service_is_running(service)
@privileged
def get_logs(service: str) -> dict[str, str]:
_assert_service_is_managed_by_plinth(service)
return {
'unit': service,
'description': action_utils.service_show(service)['Description'],
'logs': action_utils.service_get_logs(service)
}
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 from plinth.container import Container

View File

@ -8,12 +8,15 @@ from unittest.mock import Mock, call, patch
import pytest import pytest
from plinth import log
from plinth.app import (App, Component, EnableState, FollowerComponent, Info, from plinth.app import (App, Component, EnableState, FollowerComponent, Info,
LeaderComponent, apps_init) LeaderComponent, apps_init)
from plinth.diagnostic_check import DiagnosticCheck, Result from plinth.diagnostic_check import DiagnosticCheck, Result
# pylint: disable=protected-access # pylint: disable=protected-access
privileged_modules_to_mock = ['plinth.privileged']
class AppTest(App): class AppTest(App):
"""Sample App for testing.""" """Sample App for testing."""
@ -30,10 +33,14 @@ class AppSetupTest(App):
self.add(info) self.add(info)
class LeaderTest(FollowerComponent): class LeaderTest(FollowerComponent, log.LogEmitter):
"""Test class for using LeaderComponent in tests.""" """Test class for using LeaderComponent in tests."""
is_leader = True is_leader = True
@property
def unit(self): # For LogEmitter
return self.component_id
def diagnose(self) -> list[DiagnosticCheck]: def diagnose(self) -> list[DiagnosticCheck]:
"""Return diagnostic results.""" """Return diagnostic results."""
return [ return [
@ -325,6 +332,50 @@ def test_app_repair(_run_setup_on_app, app_with_components):
assert not should_rerun_setup assert not should_rerun_setup
def test_app_has_logs(app_with_components):
"""Test checking if an app has logs."""
app = app_with_components
# App with components that emit logs
assert app.has_logs()
# App with components that don't have diagnostics
app.remove('test-leader-1')
app.remove('test-leader-2')
assert not app.has_diagnostics()
@patch('plinth.privileged.service._assert_service_is_managed_by_plinth')
@patch('plinth.action_utils.service_get_logs')
@patch('plinth.action_utils.service_show')
def test_app_get_logs(service_show, service_get_logs, _, app_with_components,
mock_privileged):
"""Test retrieving logs from an app."""
service_show.side_effect = [{
'Description': 'Test Desc 1'
}, {
'Description': 'Test Desc 2'
}]
service_get_logs.side_effect = ['test-logs-1', 'test-logs-2']
logs = app_with_components.get_logs()
assert logs == [{
'unit': 'test-leader-1',
'description': 'Test Desc 1',
'logs': 'test-logs-1'
}, {
'unit': 'test-leader-2',
'description': 'Test Desc 2',
'logs': 'test-logs-2'
}]
assert service_show.mock_calls == [
call('test-leader-1'), call('test-leader-2')
]
assert service_get_logs.mock_calls == [
call('test-leader-1'), call('test-leader-2')
]
def test_component_initialization(): def test_component_initialization():
"""Test that component is initialized properly.""" """Test that component is initialized properly."""
with pytest.raises(ValueError): with pytest.raises(ValueError):