mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
setup: Implement mechanism to rerun setup when apt is updated
Closes: #1447 Find and rerun setup for apps after a dpkg operation is completed. This is needed in a couple of situations: 1) Some Debian packages don't manage the database used by the package. When these packages are updated, their database schema is left at an older version and service might become unavailable. FreedomBox can perform the database schema upgrade. However, FreedomBox needs to know when a package has been updated so that database schema can be upgraded. 2) A package is installed but FreedomBox has not modified its configuration. Newer version of package becomes available with a new configuration file. Since the original configuration file has not changed at all, the new configuration file overwrites the old one and unattended-upgrades deals with this case. Now, say, the configuration file modifies some defaults that FreedomBox expects things might break. In this case, FreedomBox can apply the require configuration changes but it needs to notified as soon as the package has been updated. When apt runs dpkg, after the operation is completed it triggers commands listed under the configuration 'Dpkg::Post-Invoke'. This in turn calls this class via a DBus notification. Here, we iterate through all the apps. If an app is currently installed and interested in rerunning setup after dpkg operations, then its setup is rerun. Interest is expressed using the 'rerun_setup_on_upgrade' flag on the Package() component. If all packages of the app have not be upgraded since the last check, we skip the operation. Tests: - When an app is installed from FreedomBox, the trigger is not run. - When a package is installed from command line with apt, the trigger is run. It does nothing. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
97bae21e65
commit
0023406e6e
@ -27,6 +27,7 @@ class PackageHandler():
|
||||
<node name="/org/freedombox/Service/PackageHandler">
|
||||
<interface name="org.freedombox.Service.PackageHandler">
|
||||
<method name="CacheUpdated"/>
|
||||
<method name="DpkgInvoked"/>
|
||||
</interface>
|
||||
</node>
|
||||
'''
|
||||
@ -47,12 +48,15 @@ class PackageHandler():
|
||||
|
||||
No need to check all the incoming parameters as D-Bus will validate all
|
||||
the incoming parameters using introspection data.
|
||||
|
||||
"""
|
||||
if method_name == 'CacheUpdated':
|
||||
self.on_cache_updated()
|
||||
invocation.return_value()
|
||||
|
||||
if method_name == 'DpkgInvoked':
|
||||
self.on_dpkg_invoked()
|
||||
invocation.return_value()
|
||||
|
||||
@staticmethod
|
||||
def on_cache_updated():
|
||||
"""Called when system package cache is updated."""
|
||||
@ -62,9 +66,19 @@ class PackageHandler():
|
||||
# Glib main loop.
|
||||
threading.Thread(target=setup.on_package_cache_updated).start()
|
||||
|
||||
@staticmethod
|
||||
def on_dpkg_invoked():
|
||||
"""Called when dpkg has been invoked."""
|
||||
logger.info('Dpkg invoked outside of FreedomBox.')
|
||||
|
||||
# Run in a new thread because we don't want to block the thread running
|
||||
# Glib main loop.
|
||||
threading.Thread(target=setup.on_dpkg_invoked).start()
|
||||
|
||||
|
||||
class DBusServer():
|
||||
"""Abstraction over a connection to D-Bus."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the server object."""
|
||||
self.package_handler = None
|
||||
|
||||
@ -7,5 +7,11 @@ APT::Update::Post-Invoke-Success {
|
||||
"/usr/bin/test x$FREEDOMBOX_INVOKED != 'xtrue' && /usr/bin/test -S /var/run/dbus/system_bus_socket && /usr/bin/gdbus call --system --dest org.freedombox.Service --object-path /org/freedombox/Service/PackageHandler --timeout 10 --method org.freedombox.Service.PackageHandler.CacheUpdated 2> /dev/null > /dev/null; /bin/echo > /dev/null";
|
||||
};
|
||||
|
||||
// When Apt finishes an operation notify FreedomBox service via it's D-Bus API.
|
||||
// FreedomBox may then handle post-install actions for some packages.
|
||||
DPkg::Post-Invoke {
|
||||
"/usr/bin/test x$FREEDOMBOX_INVOKED != 'xtrue' && /usr/bin/test -S /var/run/dbus/system_bus_socket && /usr/bin/gdbus call --system --dest org.freedombox.Service --object-path /org/freedombox/Service/PackageHandler --timeout 10 --method org.freedombox.Service.PackageHandler.DpkgInvoked 2> /dev/null > /dev/null; /bin/echo > /dev/null";
|
||||
};
|
||||
|
||||
// Clean apt cache every 7 days
|
||||
APT::Periodic::CleanInterval "7";
|
||||
|
||||
@ -18,6 +18,7 @@ from plinth.diagnostic_check import (DiagnosticCheck,
|
||||
from plinth.errors import MissingPackageError
|
||||
from plinth.utils import format_lazy
|
||||
|
||||
from . import kvstore
|
||||
from . import operation as operation_module
|
||||
from .errors import PackageNotInstalledError
|
||||
|
||||
@ -469,6 +470,7 @@ def install(package_names, skip_recommends=False, force_configuration=None,
|
||||
operation.thread_data['transaction'] = transaction
|
||||
transaction.install(skip_recommends, force_configuration, reinstall,
|
||||
force_missing_configuration)
|
||||
mark_known(package_names)
|
||||
|
||||
|
||||
def uninstall(package_names, purge):
|
||||
@ -493,6 +495,7 @@ def uninstall(package_names, purge):
|
||||
transaction = package.Transaction(operation.app_id, package_names)
|
||||
operation.thread_data['transaction'] = transaction
|
||||
transaction.uninstall(purge)
|
||||
unmark_known(package_names)
|
||||
|
||||
|
||||
def is_package_manager_busy():
|
||||
@ -535,3 +538,49 @@ def packages_installed(candidates: list | tuple) -> list:
|
||||
pass
|
||||
|
||||
return installed_packages
|
||||
|
||||
|
||||
def get_known() -> dict[str, dict]:
|
||||
"""Return all the known packages and their versions.
|
||||
|
||||
If a package is not known or has a version lower than the currently
|
||||
installed version, it means that the package has been installe or updated
|
||||
outside of FreedomBox. Some app, may use this information to rerun the
|
||||
setup on the app so that configuration is updated.
|
||||
"""
|
||||
return kvstore.get_default('packages_known', {})
|
||||
|
||||
|
||||
def mark_known(packages: list[str]):
|
||||
"""Mark a given list of packages as known."""
|
||||
packages_known = get_known()
|
||||
cache = apt.Cache()
|
||||
for package_ in packages:
|
||||
try:
|
||||
cache_package = cache[package_]
|
||||
except KeyError:
|
||||
logger.warn('Package %s is not found when marking known', package_)
|
||||
continue
|
||||
|
||||
if not cache_package.installed:
|
||||
logger.warn('Package %s is not installed when marking known',
|
||||
package_)
|
||||
continue
|
||||
|
||||
installed_version = cache_package.installed.version
|
||||
package_known = packages_known.setdefault(package_, {})
|
||||
package_known['version'] = installed_version
|
||||
|
||||
kvstore.set('packages_known', packages_known)
|
||||
|
||||
|
||||
def unmark_known(packages: list[str]):
|
||||
"""Mark a give list of packages unknown."""
|
||||
packages_known = get_known()
|
||||
for package_ in packages:
|
||||
try:
|
||||
packages_known.pop(package_)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
kvstore.set('packages_known', packages_known)
|
||||
|
||||
205
plinth/setup.py
205
plinth/setup.py
@ -3,10 +3,12 @@
|
||||
Utilities for performing application setup operations.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Union
|
||||
|
||||
import apt
|
||||
from django.utils.translation import gettext_noop
|
||||
@ -14,6 +16,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.errors import MissingPackageError
|
||||
from plinth.package import Packages
|
||||
from plinth.signals import post_setup
|
||||
|
||||
@ -234,6 +237,9 @@ def stop():
|
||||
force_upgrader = ForceUpgrader.get_instance()
|
||||
force_upgrader.shutdown()
|
||||
|
||||
dpkg_handler = DpkgHandler.get_instance()
|
||||
dpkg_handler.shutdown()
|
||||
|
||||
|
||||
def setup_apps(app_ids=None, essential=False, allow_install=True):
|
||||
"""Run setup on selected or essential apps."""
|
||||
@ -317,8 +323,8 @@ def _get_apps_for_regular_setup():
|
||||
1. essential apps that are not up-to-date
|
||||
2. non-essential app that are installed and need updates
|
||||
"""
|
||||
if (app.info.is_essential and
|
||||
app.get_setup_state() != app_module.App.SetupState.UP_TO_DATE):
|
||||
if (app.info.is_essential and app.get_setup_state()
|
||||
!= app_module.App.SetupState.UP_TO_DATE):
|
||||
return True
|
||||
|
||||
if app.get_setup_state() == app_module.App.SetupState.NEEDS_UPDATE:
|
||||
@ -483,6 +489,7 @@ class ForceUpgrader():
|
||||
try:
|
||||
logger.info('Attempting to perform upgrade')
|
||||
self._attempt_upgrade()
|
||||
logger.info('Completed upgrade')
|
||||
return
|
||||
except self.TemporaryFailure as exception:
|
||||
logger.info('Cannot perform upgrade now: %s', exception)
|
||||
@ -671,6 +678,200 @@ def on_package_cache_updated():
|
||||
force_upgrader.on_package_cache_updated()
|
||||
|
||||
|
||||
class DpkgHandler:
|
||||
"""Find and rerun setup for apps after a dpkg operation is completed.
|
||||
|
||||
This is needed in a couple of situations:
|
||||
|
||||
1) Some Debian packages don't manage the database used by the package. When
|
||||
these packages are updated, their database schema is left at an older
|
||||
version and service might become unavailable. FreedomBox can perform the
|
||||
database schema upgrade. However, FreedomBox needs to know when a package
|
||||
has been updated so that database schema can be upgraded.
|
||||
|
||||
2) A package is installed but FreedomBox has not modified its
|
||||
configuration. Newer version of package becomes available with a new
|
||||
configuration file. Since the original configuration file has not changed
|
||||
at all, the new configuration file overwrites the old one and
|
||||
unattended-upgrades deals with this case. Now, say, the configuration file
|
||||
modifies some defaults that FreedomBox expects things might break. In this
|
||||
case, FreedomBox can apply the require configuration changes but it needs
|
||||
to notified as soon as the package has been updated.
|
||||
|
||||
When apt runs dpkg, after the operation is completed it triggers commands
|
||||
listed under the configuration 'Dpkg::Post-Invoke'. This in turn calls this
|
||||
class via a DBus notification. Here, we iterate through all the apps. If an
|
||||
app is currently installed and interested in rerunning setup after dpkg
|
||||
operations, then its setup is rerun. Interest is expressed using the
|
||||
'rerun_setup_on_upgrade' flag on the Package() component. If all packages
|
||||
of the app have not be upgraded since the last check, we skip the
|
||||
operation.
|
||||
"""
|
||||
|
||||
_instance: Union['DpkgHandler', None] = None
|
||||
_run_lock = threading.Lock()
|
||||
_wait_event = threading.Event()
|
||||
|
||||
HANDLE_ATTEMPTS: int = 12
|
||||
|
||||
HANDLE_ATTEMPT_WAIT_SECONDS: int = 30 * 60
|
||||
|
||||
class TemporaryFailure(Exception):
|
||||
"""Raised when post-dpkg operations fails but can be tried again."""
|
||||
|
||||
class PermanentFailure(Exception):
|
||||
"""Raised when post-dpkg operations fails.
|
||||
|
||||
And there is nothing more we wish to do.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'DpkgHandler':
|
||||
"""Return a single instance of a the class."""
|
||||
if not cls._instance:
|
||||
cls._instance = DpkgHandler()
|
||||
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the dpkg handler."""
|
||||
if plinth.cfg.develop:
|
||||
self.HANDLE_ATTEMPT_WAIT_SECONDS = 10
|
||||
|
||||
def on_dpkg_invoked(self) -> None:
|
||||
"""Trigger post-dpkg operations when notified about dpkg invocation.
|
||||
|
||||
Call the post-dpkg operations guaranteeing that it will not run more
|
||||
than once simultaneously.
|
||||
"""
|
||||
if not self._run_lock.acquire(blocking=False):
|
||||
logger.info('Post dpkg operations already in process')
|
||||
return
|
||||
|
||||
try:
|
||||
self._on_dpkg_invoked()
|
||||
finally:
|
||||
self._run_lock.release()
|
||||
|
||||
def _on_dpkg_invoked(self) -> None:
|
||||
"""Attempt the post-dpkg operations multiple times.
|
||||
|
||||
This method is guaranteed to not to run more than once simultaneously.
|
||||
"""
|
||||
for _ in range(self.HANDLE_ATTEMPTS):
|
||||
logger.info(
|
||||
'Waiting for %s seconds before attempting post-dpkg '
|
||||
'operations', self.HANDLE_ATTEMPT_WAIT_SECONDS)
|
||||
if self._wait_event.wait(self.HANDLE_ATTEMPT_WAIT_SECONDS):
|
||||
logger.info(
|
||||
'Stopping post-dpkg operation attempts due to shutdown')
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info('Attempting to perform post-dpkg operations')
|
||||
self._attempt_post_invoke()
|
||||
logger.info('Completed post-dpkg operations')
|
||||
return
|
||||
except self.TemporaryFailure as exception:
|
||||
logger.info('Cannot perform post-dpkg operations now: %s',
|
||||
exception)
|
||||
except self.PermanentFailure as exception:
|
||||
logger.error('Post-dpkg operations failed: %s', exception)
|
||||
return
|
||||
except Exception as exception:
|
||||
# Assume all other errors are temporary
|
||||
logger.exception('Unknown exception: %s', exception)
|
||||
|
||||
logger.info('Giving up on post-dpkg operations after too many retries')
|
||||
|
||||
def _attempt_post_invoke(self) -> None:
|
||||
"""Run post-dpkg invoke operations on all interested app."""
|
||||
if _is_shutting_down:
|
||||
raise self.PermanentFailure('Service is shutting down')
|
||||
|
||||
if packages_privileged.is_package_manager_busy():
|
||||
raise self.TemporaryFailure('Package manager is busy')
|
||||
|
||||
for app in app_module.App.list():
|
||||
self._post_invoke_on_app(app)
|
||||
|
||||
def _post_invoke_on_app(self, app: app_module.App) -> None:
|
||||
"""Run post-dpkg invoke operations on a single app."""
|
||||
components = list(app.get_components_of_type(Packages))
|
||||
app_interested = any(
|
||||
(component.rerun_setup_on_upgrade for component in components))
|
||||
if not app_interested:
|
||||
# App is not interested in re-running setup after a package has
|
||||
# been updated.
|
||||
return
|
||||
|
||||
if app.get_setup_state() == app_module.App.SetupState.NEEDS_SETUP:
|
||||
# The app is not installed. Don't try to set it up.
|
||||
return
|
||||
|
||||
try:
|
||||
packages = list(
|
||||
itertools.chain.from_iterable(component.get_actual_packages()
|
||||
for component in components))
|
||||
except MissingPackageError:
|
||||
# If there are some packages needed by this app that are missing,
|
||||
# there is no way we can rerun setup for this app. Give up, don't
|
||||
# retry.
|
||||
return
|
||||
|
||||
if not self._app_needs_setup_rerun(app, packages):
|
||||
# App does not need a setup rerun
|
||||
return
|
||||
|
||||
operation = run_setup_on_app(app.app_id, rerun=True)
|
||||
if operation:
|
||||
operation.join()
|
||||
|
||||
def _app_needs_setup_rerun(self, app: app_module.App,
|
||||
packages: list[str]) -> bool:
|
||||
"""Return whether an app needs an rerun."""
|
||||
packages_known = package.get_known()
|
||||
cache = apt.Cache()
|
||||
for package_ in packages:
|
||||
try:
|
||||
cache_package = cache[package_]
|
||||
except KeyError:
|
||||
logger.warning('For installed app %s, package %s is not known',
|
||||
app.app_id, package_)
|
||||
return False
|
||||
|
||||
if not cache_package.installed:
|
||||
# App is installed but one of the needed packages is not
|
||||
# installed. Don't know what to do. Don't rerun.
|
||||
logger.warning(
|
||||
'For installed app %s, package %s is not installed',
|
||||
app.app_id, package_)
|
||||
return False
|
||||
|
||||
installed_version = cache_package.installed.version
|
||||
package_known = packages_known.get(package_, {})
|
||||
version_known = package_known.get('version')
|
||||
if installed_version != version_known:
|
||||
return True
|
||||
|
||||
# Latest versions of all packages of the app have already been
|
||||
# processed (and thus known).
|
||||
return False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""If we are sleeping for next attempt, cancel it.
|
||||
|
||||
If we are actually performing operations, do nothing.
|
||||
"""
|
||||
self._wait_event.set()
|
||||
|
||||
|
||||
def on_dpkg_invoked():
|
||||
"""Called by D-Bus service when dpkg has been invoked."""
|
||||
dpkg_handler = DpkgHandler.get_instance()
|
||||
dpkg_handler.on_dpkg_invoked()
|
||||
|
||||
|
||||
def store_error_message(error_message: str):
|
||||
"""Add an error message to thread local storage."""
|
||||
try:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user