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:
Sunil Mohan Adapa 2025-03-27 21:32:38 -07:00 committed by James Valleroy
parent 97bae21e65
commit 0023406e6e
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
4 changed files with 273 additions and 3 deletions

View File

@ -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

View File

@ -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";

View File

@ -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)

View File

@ -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: