mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +00:00
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:
parent
b3d5ee30ed
commit
0661d7da7c
@ -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],
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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'
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user