From 0661d7da7cb149ba0b82741ced1263ee7e3eee0e Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 11 Sep 2025 11:34:39 -0700 Subject: [PATCH] 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 Reviewed-by: Veiko Aasa --- plinth/action_utils.py | 21 ++++++++++++++ plinth/app.py | 22 +++++++++++++++ plinth/container.py | 7 +++-- plinth/daemon.py | 9 +++--- plinth/log.py | 19 +++++++++++++ plinth/privileged/__init__.py | 11 ++++---- plinth/privileged/service.py | 10 +++++++ plinth/tests/test_app.py | 53 ++++++++++++++++++++++++++++++++++- 8 files changed, 139 insertions(+), 13 deletions(-) diff --git a/plinth/action_utils.py b/plinth/action_utils.py index 4781ca750..e7db636a1 100644 --- a/plinth/action_utils.py +++ b/plinth/action_utils.py @@ -176,6 +176,27 @@ def service_reset_failed(service_name: str, check: bool = False): 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): """Perform the given action on the service_name.""" subprocess.run(['systemctl', action, service_name], diff --git a/plinth/app.py b/plinth/app.py index a7882ba2a..5d94554ae 100644 --- a/plinth/app.py +++ b/plinth/app.py @@ -309,6 +309,28 @@ class App: 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: """Interface for an app component. diff --git a/plinth/container.py b/plinth/container.py index 1f16985b7..574db4d6a 100644 --- a/plinth/container.py +++ b/plinth/container.py @@ -5,13 +5,13 @@ import contextlib 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.diagnostic_check import (DiagnosticCheck, DiagnosticCheckParameters, Result) -class Container(app.LeaderComponent): +class Container(app.LeaderComponent, log.LogEmitter): """Component to manage a podman container.""" def __init__(self, component_id: str, name: str, image_name: str, @@ -65,6 +65,9 @@ class Container(app.LeaderComponent): self.devices = devices self.listen_ports = listen_ports or [] + # For logs + self.unit = self.name + def is_enabled(self): """Return if the container is enabled.""" return privileged.container_is_enabled(self.name) diff --git a/plinth/daemon.py b/plinth/daemon.py index b70d2e099..9c8fba8d9 100644 --- a/plinth/daemon.py +++ b/plinth/daemon.py @@ -8,12 +8,12 @@ import subprocess import psutil 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, DiagnosticCheckParameters, Result) -class Daemon(app.LeaderComponent): +class Daemon(app.LeaderComponent, log.LogEmitter): """Component to manage a background daemon or any systemd unit.""" def __init__(self, component_id: str, unit: str, @@ -130,7 +130,7 @@ class Daemon(app.LeaderComponent): self.component_id) -class RelatedDaemon(app.FollowerComponent): +class RelatedDaemon(app.FollowerComponent, log.LogEmitter): """Component to hold information about additional systemd units handled. 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( - port: int, kind: str = 'tcp', - listen_address: str | None = None, + port: int, kind: str = 'tcp', listen_address: str | None = None, component_id: str | None = None) -> DiagnosticCheck: """Run a diagnostic on whether a port is being listened on. diff --git a/plinth/log.py b/plinth/log.py index 8f4fe4682..e34a02f47 100644 --- a/plinth/log.py +++ b/plinth/log.py @@ -5,6 +5,7 @@ Setup logging for the application. import logging import logging.config +import typing import warnings from . import cfg @@ -12,6 +13,24 @@ from . import cfg 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): """Print parts of log message in color.""" codes = { diff --git a/plinth/privileged/__init__.py b/plinth/privileged/__init__.py index f71d4b940..3707aae3a 100644 --- a/plinth/privileged/__init__.py +++ b/plinth/privileged/__init__.py @@ -7,15 +7,16 @@ from .container import (container_disable, container_enable, container_uninstall) from .packages import (filter_conffile_packages, install, is_package_manager_busy, remove, update) -from .service import (disable, enable, is_enabled, is_running, mask, reload, - restart, start, stop, systemd_set_default, +from .service import (disable, enable, get_logs, is_enabled, is_running, mask, + reload, restart, start, stop, systemd_set_default, try_reload_or_restart, try_restart, unmask) __all__ = [ 'filter_conffile_packages', 'install', 'is_package_manager_busy', 'remove', 'update', 'systemd_set_default', 'disable', 'enable', 'is_enabled', 'is_running', 'mask', 'reload', 'restart', 'start', 'stop', - 'try_reload_or_restart', 'try_restart', 'unmask', 'dropin_is_valid', - 'dropin_link', 'dropin_unlink', 'container_disable', 'container_enable', - 'container_is_enabled', 'container_setup', 'container_uninstall' + 'try_reload_or_restart', 'try_restart', 'unmask', 'get_logs', + 'dropin_is_valid', 'dropin_link', 'dropin_unlink', 'container_disable', + 'container_enable', 'container_is_enabled', 'container_setup', + 'container_uninstall' ] diff --git a/plinth/privileged/service.py b/plinth/privileged/service.py index 28bfa2f6a..7386dc54e 100644 --- a/plinth/privileged/service.py +++ b/plinth/privileged/service.py @@ -104,6 +104,16 @@ def is_running(service: str) -> bool: 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(): """Get a set of all services managed by FreedomBox.""" from plinth.container import Container diff --git a/plinth/tests/test_app.py b/plinth/tests/test_app.py index 7512aeffd..a9a1649be 100644 --- a/plinth/tests/test_app.py +++ b/plinth/tests/test_app.py @@ -8,12 +8,15 @@ from unittest.mock import Mock, call, patch import pytest +from plinth import log from plinth.app import (App, Component, EnableState, FollowerComponent, Info, LeaderComponent, apps_init) from plinth.diagnostic_check import DiagnosticCheck, Result # pylint: disable=protected-access +privileged_modules_to_mock = ['plinth.privileged'] + class AppTest(App): """Sample App for testing.""" @@ -30,10 +33,14 @@ class AppSetupTest(App): self.add(info) -class LeaderTest(FollowerComponent): +class LeaderTest(FollowerComponent, log.LogEmitter): """Test class for using LeaderComponent in tests.""" is_leader = True + @property + def unit(self): # For LogEmitter + return self.component_id + def diagnose(self) -> list[DiagnosticCheck]: """Return diagnostic results.""" return [ @@ -325,6 +332,50 @@ def test_app_repair(_run_setup_on_app, app_with_components): 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(): """Test that component is initialized properly.""" with pytest.raises(ValueError):