setup: Add method to run app repair

- Repair is run within an operation.

- Diagnostics are run for the app first.

- Call app.repair, then re-run setup if needed.

- Add helper functions for apps or components to store error messages in thread
  local storage. These error messages are shown at the end.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
[sunil: Undo minor reformatting, due to automatic tool]
[sunil: Fix passing incorrect Exception argument to operation.on_update]
[sunil: Add full stop at the end of the success message to match install message]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
James Valleroy 2024-04-20 18:49:26 -04:00 committed by Sunil Mohan Adapa
parent f487565b2c
commit 35c2326261
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
2 changed files with 130 additions and 2 deletions

View File

@ -13,6 +13,7 @@ from django.utils.translation import gettext_noop
import plinth
from plinth import app as app_module
from plinth.diagnostic_check import Result
from plinth.package import Packages
from plinth.signals import post_setup
@ -26,6 +27,8 @@ _is_first_setup = False
is_first_setup_running = False
_is_shutting_down = False
thread_local_storage = threading.local()
def run_setup_on_app(app_id, allow_install=True, rerun=False):
"""Execute the setup process in a thread."""
@ -50,10 +53,10 @@ def run_setup_on_app(app_id, allow_install=True, rerun=False):
thread_data={'allow_install': allow_install})
def _run_setup_on_app(app, current_version):
def _run_setup_on_app(app, current_version, repair: bool = False):
"""Execute the setup process."""
logger.info('Setup run: %s', app.app_id)
exception_to_update = None
exception_to_update: Exception | None = None
message = None
try:
current_version = app.get_setup_version()
@ -74,12 +77,17 @@ def _run_setup_on_app(app, current_version):
if not current_version:
message = gettext_noop('Error installing app: {error}').format(
error=exception)
elif repair:
message = gettext_noop('Error repairing app: {error}').format(
error=exception)
else:
message = gettext_noop('Error updating app: {error}').format(
error=exception)
else:
if not current_version:
message = gettext_noop('App installed.')
elif repair:
return
else:
message = gettext_noop('App updated')
@ -89,6 +97,86 @@ def _run_setup_on_app(app, current_version):
operation.on_update(message, exception_to_update)
def run_repair_on_app(app_id):
"""Execute the repair process in a thread."""
app = app_module.App.get(app_id)
current_version = app.get_setup_version()
if not current_version:
logger.warning('App %s is not installed, cannot repair', app_id)
return
logger.debug('Creating operation to repair app: %s', app_id)
return operation_module.manager.new(f'{app_id}-repair', app_id,
gettext_noop('Repairing app'),
_run_repair_on_app, [app],
show_message=True,
show_notification=True)
def _run_repair_on_app(app: app_module.App):
"""Execute the repair process."""
logger.info('Repair run: %s', app.app_id)
message = None
operation = operation_module.Operation.get_operation()
# Always re-run diagnostics first for this app, to ensure results are
# current.
checks = []
try:
checks = app.diagnose()
except Exception as exception:
logger.error('Error running %s diagnostics - %s', app.app_id,
exception)
message = gettext_noop('Error running diagnostics: {error}').format(
error=exception)
operation.on_update(message, exception)
return
# Filter for checks that have failed.
failed_checks = []
for check in checks:
if check.result in [Result.FAILED, Result.WARNING]:
failed_checks.append(check)
if not failed_checks:
logger.warning('Skipping repair for %s: no failed checks', app.app_id)
message = gettext_noop('Skipping repair, no failed checks')
operation.on_update(message, None)
return
try:
should_rerun_setup = app.repair(failed_checks)
except Exception as exception:
logger.error('Repair error: %s: %s %s', app.app_id, message, exception)
message = gettext_noop('Error repairing app: {error}').format(
error=exception)
operation.on_update(message, exception)
return
if should_rerun_setup:
message = gettext_noop('Re-running setup to complete repairs')
operation.on_update(message, None)
current_version = app.get_setup_version()
_run_setup_on_app(app, current_version, True)
logger.info('Repair completed: %s', app.app_id)
# Check for errors in thread local storage
message = gettext_noop('App repaired.')
errors = retrieve_error_messages()
exceptions = None
if errors:
message = gettext_noop('App repair completed with errors:\n')
error_message = ''
for error in errors:
message += str(error) + '\n'
error_message += str(error) + '\n'
exceptions = Exception(error_message)
operation.on_update(message, exceptions)
def run_uninstall_on_app(app_id):
"""Execute the uninstall process in a thread."""
# App is already uninstalled
@ -565,3 +653,24 @@ def on_package_cache_updated():
"""Called by D-Bus service when apt package cache is updated."""
force_upgrader = ForceUpgrader.get_instance()
force_upgrader.on_package_cache_updated()
def store_error_message(error_message: str):
"""Add an error message to thread local storage."""
try:
thread_local_storage.errors.append(error_message)
except AttributeError:
thread_local_storage.errors = [error_message]
def retrieve_error_messages() -> list[str]:
"""Retrieve the error messages from thread local storage.
Errors are cleared after retrieval."""
try:
errors = thread_local_storage.errors
thread_local_storage.errors = []
except AttributeError:
errors = []
return errors

View File

@ -0,0 +1,19 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test module for setup module.
"""
from plinth.setup import store_error_message, retrieve_error_messages
def test_store_retrieve_error_message():
"""Test storing and retrieving error messages."""
store_error_message('error 1')
assert retrieve_error_messages() == ['error 1']
store_error_message('error 1')
store_error_message('error 2')
assert retrieve_error_messages() == ['error 1', 'error 2']
# errors are cleared after retrieving
assert retrieve_error_messages() == []