diff --git a/plinth/__main__.py b/plinth/__main__.py index 15bd0a10d..36a998b79 100644 --- a/plinth/__main__.py +++ b/plinth/__main__.py @@ -155,6 +155,11 @@ def setup_server(): cherrypy.engine.signal_handler.subscribe() +def on_server_stop(): + """Stop all other threads since web server is trying to exit""" + setup.stop() + + def configure_django(): """Setup Django configuration in the absense of .settings file""" logging_configuration = { @@ -218,7 +223,7 @@ def configure_django(): django.conf.settings.configure( ALLOWED_HOSTS=['*'], - AUTH_PASSWORD_VALIDATORS = [ + AUTH_PASSWORD_VALIDATORS=[ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, @@ -259,6 +264,7 @@ def configure_django(): 'plinth.middleware.AdminRequiredMiddleware', 'plinth.modules.first_boot.middleware.FirstBootMiddleware', 'plinth.middleware.SetupMiddleware', + 'plinth.middleware.FirstSetupMiddleware', ), ROOT_URLCONF='plinth.urls', SECURE_PROXY_SSL_HEADER=secure_proxy_ssl_header, @@ -279,21 +285,14 @@ def configure_django(): os.chmod(cfg.store_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) -def run_setup(module_list, allow_install=True): - try: - if not module_list: - setup.setup_modules(essential=True, allow_install=allow_install) - else: - setup.setup_modules(module_list, allow_install=allow_install) - except Exception as exception: - logger.error('Error running setup - %s', exception) - return 1 - return 0 - - def run_setup_and_exit(module_list, allow_install=True): """Run setup on all essential modules and exit.""" - error_code = run_setup(module_list, allow_install) + error_code = 0 + try: + setup.run_setup_on_modules(module_list, allow_install) + except Exception as exception: + error_code = 1 + sys.exit(error_code) @@ -380,13 +379,11 @@ def main(): if arguments.diagnose: run_diagnostics_and_exit() - # Run setup steps for essential modules - # Installation is not necessary as they are dependencies of Plinth - run_setup(None, allow_install=False) - + setup.run_setup_in_background() setup_server() cherrypy.engine.start() + cherrypy.engine.subscribe('stop', on_server_stop) cherrypy.engine.block() diff --git a/plinth/middleware.py b/plinth/middleware.py index 14ff78a3b..4d4575da8 100644 --- a/plinth/middleware.py +++ b/plinth/middleware.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ Common Django middleware. """ @@ -24,17 +23,18 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied +from django.shortcuts import render from django.utils.translation import ugettext_lazy as _ import logging from stronghold.utils import is_view_func_public import plinth +from plinth import setup from plinth.package import PackageException from plinth.utils import is_user_admin from . import views - logger = logging.getLogger(__name__) @@ -76,7 +76,7 @@ class SetupMiddleware(object): error_details = getattr(exception, 'error_details', '') message = _('Error installing application: {string} ' '{details}').format( - string=error_string, details=error_details) + string=error_string, details=error_details) else: message = _('Error installing application: {error}') \ .format(error=exception) @@ -104,3 +104,15 @@ class AdminRequiredMiddleware(object): if not is_user_admin(request): raise PermissionDenied + + +class FirstSetupMiddleware(object): + + @staticmethod + def process_view(request, view_func, view_args, view_kwargs): + """Block all user interactions when first setup is pending.""" + if not setup.is_first_setup_running: + return + + context = {'is_first_setup_running': setup.is_first_setup_running} + return render(request, 'first_setup.html', context) diff --git a/plinth/package.py b/plinth/package.py index 8ae3c8499..7419106ef 100644 --- a/plinth/package.py +++ b/plinth/package.py @@ -131,3 +131,13 @@ class Transaction(object): } self.status_string = status_map.get(parts[0], '') self.percentage = int(float(parts[2])) + + +def is_package_manager_busy(): + """Return whether a package manager is running.""" + try: + actions.superuser_run('packages', ['is-package-manager-busy']) + return True + except actions.ActionError: + return False + diff --git a/plinth/setup.py b/plinth/setup.py index 7ea4f1985..484b9a435 100644 --- a/plinth/setup.py +++ b/plinth/setup.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ Plinth module with utilites for performing application setup operations. """ @@ -22,13 +21,20 @@ Plinth module with utilites for performing application setup operations. import apt import logging import threading +import time from . import package from .errors import PackageNotInstalledError import plinth +from plinth import actions +from plinth import package logger = logging.getLogger(__name__) +_is_first_setup = False +is_first_setup_running = False +_is_shutting_down = False + class Helper(object): """Helper routines for modules to show progress.""" @@ -163,12 +169,18 @@ def init(module_name, module): module.setup_helper = Helper(module_name, module) +def stop(): + """Set a flag to indicate that the setup process must stop.""" + global _is_shutting_down + _is_shutting_down = True + + def setup_modules(module_list=None, essential=False, allow_install=True): """Run setup on selected or essential modules.""" logger.info('Running setup for modules, essential - %s, ' 'selected modules - %s', essential, module_list) for module_name, module in plinth.module_loader.loaded_modules.items(): - if essential and not getattr(module, 'is_essential', False): + if essential and not is_module_essential(module): continue if module_list and module_name not in module_list: @@ -180,7 +192,7 @@ def setup_modules(module_list=None, essential=False, allow_install=True): def list_dependencies(module_list=None, essential=False): """Print list of packages required by selected or essential modules.""" for module_name, module in plinth.module_loader.loaded_modules.items(): - if essential and not getattr(module, 'is_essential', False): + if essential and not is_module_essential(module): continue if module_list and module_name not in module_list and \ @@ -189,3 +201,113 @@ def list_dependencies(module_list=None, essential=False): for package_name in getattr(module, 'managed_packages', []): print(package_name) + + +def run_setup_in_background(): + """Run setup in a background thread.""" + global _is_first_setup + _set_is_first_setup() + threading.Thread(target=_run_setup).start() + + +def _run_setup(): + """Run setup with retry till it succeeds.""" + sleep_time = 10 + while True: + try: + if _is_first_setup: + logger.info('Running first setup.') + _run_first_setup() + break + else: + logger.info('Running regular setup.') + _run_regular_setup() + break + except Exception as ex: + logger.warning('Unable to complete setup: %s', ex) + logger.info('Will try again in {} seconds'.format(sleep_time)) + time.sleep(sleep_time) + if _is_shutting_down: + break + + logger.info('Setup thread finished.') + + +def _run_first_setup(): + """Run setup on essential modules on first setup.""" + global is_first_setup_running + is_first_setup_running = True + # TODO When it errors out, show error in the UI + run_setup_on_modules(None, allow_install=False) + is_first_setup_running = False + + +def _run_regular_setup(): + """Run setup on all modules also installing required packages.""" + # TODO show notification that upgrades are running + if package.is_package_manager_busy(): + raise Exception('Package manager is busy.') + + all_modules = _get_modules_for_regular_setup() + run_setup_on_modules(all_modules, allow_install=True) + + +def _get_modules_for_regular_setup(): + all_modules = plinth.module_loader.loaded_modules.items() + + def is_setup_required(module): + """Setup is required for: + 1. essential modules that are not up-to-date + 2. non-essential modules that are installed and need updates + """ + if (is_module_essential(module) and + not module_state_matches(module, 'up-to-date')): + return True + + if module_state_matches(module, 'needs-update'): + return True + + return False + + return [name + for name, module in all_modules + if is_setup_required(module)] + + +def is_module_essential(module): + return getattr(module, 'is_essential', False) + + +def module_state_matches(module, state): + return module.setup_helper.get_state() == state + + +def _set_is_first_setup(): + """Return whether all essential modules have been setup at least once.""" + global _is_first_setup + modules = plinth.module_loader.loaded_modules.values() + _is_first_setup = any( + (module + for module in modules + if is_module_essential(module) and module_state_matches(module, 'needs-setup'))) + + +def run_setup_on_modules(module_list, allow_install=True): + """Run setup on the given list of modules. + + module_list is the list of modules to run setup on. If None is given, run + setup on all essential modules only. + + allow_install with or without package installation. When setting up + essential modules, installing packages is not required as Plinth itself has + dependencies on all essential modules. + + """ + try: + if not module_list: + setup_modules(essential=True, allow_install=allow_install) + else: + setup_modules(module_list, allow_install=allow_install) + except Exception as exception: + logger.error('Error running setup - %s', exception) + raise diff --git a/plinth/templates/first_setup.html b/plinth/templates/first_setup.html new file mode 100644 index 000000000..df63e0417 --- /dev/null +++ b/plinth/templates/first_setup.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block page_head %} + + {% if setup_helper.current_operation %} + + {% endif %} + +{% endblock %} + + +{% block content %} + + {% if is_first_setup_running %} + + {% endif %} + +{% endblock %} + diff --git a/plinth/views.py b/plinth/views.py index 1620a3cb6..66f5c64f4 100644 --- a/plinth/views.py +++ b/plinth/views.py @@ -31,6 +31,7 @@ import time from . import forms, frontpage import plinth from plinth import actions +from plinth import package from plinth.modules.storage import views as disk_views @@ -140,17 +141,9 @@ class SetupView(TemplateView): """Return the context data rendering the template.""" context = super(SetupView, self).get_context_data(**kwargs) context['setup_helper'] = self.kwargs['setup_helper'] - context['package_manager_is_busy'] = self.is_package_manager_busy() + context['package_manager_is_busy'] = package.is_package_manager_busy() return context - def is_package_manager_busy(self): - """Return whether a package manager is running.""" - try: - actions.superuser_run('packages', ['is-package-manager-busy']) - return True - except actions.ActionError: - return False - def post(self, *args, **kwargs): """Handle installing/upgrading applications.