mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +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)
|
||||
|
||||
|
||||
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],
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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'
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user