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)
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],

View File

@ -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.

View File

@ -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)

View File

@ -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.

View File

@ -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 = {

View File

@ -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'
]

View File

@ -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

View File

@ -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):