From 02887d4f8fb944fa190e3c52a506f3626b3f7138 Mon Sep 17 00:00:00 2001 From: Joey Riches Date: Wed, 27 Mar 2024 12:17:30 +0000 Subject: [PATCH] Initial support for offline upgrades https://www.freedesktop.org/software/systemd/man/latest/systemd.offline-updates.html Resolves #45. --- eopkg-offline-update.service | 14 +++++++++++ pisi/api.py | 31 +++++++++++++++++++++++ pisi/cli/updaterepo.py | 8 ++++++ pisi/cli/upgrade.py | 20 ++++++++++++++- pisi/operations/upgrade.py | 48 ++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 eopkg-offline-update.service diff --git a/eopkg-offline-update.service b/eopkg-offline-update.service new file mode 100644 index 0000000..a081dad --- /dev/null +++ b/eopkg-offline-update.service @@ -0,0 +1,14 @@ +[Unit] +Description=Update the operating system whilst offline + +DefaultDependencies=no +Requires=sysinit.target dbus.socket +After=sysinit.target dbus.socket systemd-journald.socket system-update-pre.target +Before=shutdown.target system-update.target + +[Service] +Type=oneshot +ExecStart=eopkg up --bypass-update-repo --yes-all +StandardOutput=journal+console + +FailureAction=reboot diff --git a/pisi/api.py b/pisi/api.py index 74090cf..5cd33ba 100644 --- a/pisi/api.py +++ b/pisi/api.py @@ -775,8 +775,33 @@ def remove_repo(name): raise pisi.Error(_('Repository %s does not exist. Cannot remove.') % name) +def is_offline_upgrade_prepared(): + if os.path.exists('/etc/system-update') or os.path.exists('/system-update'): + return True + + return False + +def clear_prepared_offline_upgrade(): + try: + if os.path.exists('/etc/system-update'): + os.remove('/etc/system-update') + ctx.ui.debug(_('Deleted /etc/system-update')) + if os.path.exists('/system-update'): + os.remove('/system-update') + ctx.ui.debug(_('Deleted /system-update')) + except IOError as e: + ctx.ui.error(_('Failed to remove prepared offline upgrade, reason: %s') % e) + finally: + return True + @locked def update_repos(repos, force=False): + if is_offline_upgrade_prepared() is True: + if force is True: + pisi.api.clear_prepared_offline_upgrade() + else: + ctx.ui.error(_('An offline upgrade is prepared for installation, blocking.')) + pisi.db.historydb.HistoryDB().create_history("repoupdate") updated = False try: @@ -788,6 +813,12 @@ def update_repos(repos, force=False): @locked def update_repo(repo, force=False): + if is_offline_upgrade_prepared() is True: + if force is True: + pisi.api.clear_prepared_offline_upgrade() + else: + ctx.ui.error(_('An offline upgrade is prepared for installation, blocking.')) + pisi.db.historydb.HistoryDB().create_history("repoupdate") updated = __update_repo(repo, force) if updated: diff --git a/pisi/cli/updaterepo.py b/pisi/cli/updaterepo.py index 6c0a27d..17d14b1 100644 --- a/pisi/cli/updaterepo.py +++ b/pisi/cli/updaterepo.py @@ -48,6 +48,14 @@ def options(self): def run(self): self.init(database = True) + if pisi.api.is_offline_upgrade_prepared() is True: + ctx.ui.warning(_('An offline update is already prepared')) + if ctx.ui.confirm(_('Do you wish to clear the previously prepared offline update?')): + if pisi.api.clear_prepared_offline_upgrade() is False: + return + else: + return + if self.args: repos = self.args else: diff --git a/pisi/cli/upgrade.py b/pisi/cli/upgrade.py index 2c6eff2..5cc2ab0 100644 --- a/pisi/cli/upgrade.py +++ b/pisi/cli/upgrade.py @@ -11,11 +11,14 @@ # import optparse +import os +import subprocess from pisi import translate as _ import pisi.cli.command as command import pisi.context as ctx +import pisi.util as util import pisi.api import pisi.db @@ -62,6 +65,7 @@ def options(self): type="string", default=None, help=_('Name of the to be upgraded packages\' repository')) group.add_option("-f", "--fetch-only", action="store_true", default=False, help=_("Fetch upgrades but do not install.")) + group.add_option("--offline", action="store_true", default=False, help=_("Perform upgrades offline")) group.add_option("-x", "--exclude", action="append", default=None, help=_("When upgrading system, ignore packages and components whose basenames match pattern.")) group.add_option("--exclude-from", action="store", @@ -74,11 +78,19 @@ def options(self): def run(self): - if self.options.fetch_only: + if self.options.fetch_only or self.options.offline: self.init(database=True, write=False) else: self.init() + if pisi.api.is_offline_upgrade_prepared() is True: + ctx.ui.warning(_('An offline update is already prepared')) + if ctx.ui.confirm(_('Do you wish to clear the previously prepared offline update?')): + if pisi.api.clear_prepared_offline_upgrade() is False: + return + else: + return + if not ctx.get_option('bypass_update_repo'): ctx.ui.info(_('Updating repositories')) repos = pisi.api.list_repos() @@ -100,3 +112,9 @@ def run(self): packages.extend(self.args) pisi.api.upgrade(packages, repository) + + if self.options.offline: + offline_file = os.path.join(ctx.config.history_dir(), 'prepared-offline-update') + if os.path.exists(offline_file): + if ctx.ui.confirm(_('The updates will be applied on next reboot. Do you wish to reboot now?')): + subprocess.Popen(["systemctl", "soft-reboot"]) diff --git a/pisi/operations/upgrade.py b/pisi/operations/upgrade.py index 8c076f2..f89758e 100644 --- a/pisi/operations/upgrade.py +++ b/pisi/operations/upgrade.py @@ -10,6 +10,7 @@ # Please read the COPYING file. # +import os import sys from pisi import translate as _ @@ -190,6 +191,12 @@ def upgrade(A=[], repo=None): if ctx.get_option('fetch_only'): return + # For offline upgrades return here after fetching of upgrades + # but prepare it first + if ctx.get_option('offline'): + prepare_offline_upgrade(order) + return + if conflicts: operations.remove.remove_conflicting_packages(conflicts) @@ -207,6 +214,47 @@ def upgrade(A=[], repo=None): finally: ctx.exec_usysconf() +def prepare_offline_upgrade(order): + # https://www.freedesktop.org/software/systemd/man/latest/systemd.offline-updates.html + # + # For a high-level overview + # 1. Fetch upgrades with eopkg up --fetch-only + # 2. Create symlink to /system-update to instruct systemd to perform offline upgrades + # 3. Soft reboot to initiate offline upgrades + # 3. eopkg-offline-upgrades.service will run eopkg up --bypass-update-repo --yes-all + # 4. System reboots and /system-update file is removed + + # a bit of a hard coded safety hack here + if not os.path.exists('/usr/lib/systemd/system/system-update.target.wants/eopkg-offline-update.service'): + ctx.ui.error(_("eopkg-offline-update.service doesn't exist, check your installation")) + return + + # If we get to this point just unconditionally clear any previously + # prepared offline upgrades to avoid issues. + if os.path.exists('/system-update'): + os.remove('/system-update') + if os.path.exists('/etc/system-update'): + os.remove('/etc/system-update') + + # Nothing special about this file but we need to create some sort of symlink to /system-update + # to instruct systemd to perform an offline update + offline_file = os.path.join(ctx.config.history_dir(), 'prepared-offline-update') + + packagedb = pisi.db.packagedb.PackageDB() + + try: + with open(offline_file, 'w') as f: + for pkg in order: + info = packagedb.get_package(pkg) + f.write("%s\n\n" % unicode(info)) + os.symlink(offline_file, '/system-update') + ctx.ui.debug(_('Created symlink from %s to /system-update') % offline_file) + except IOError: + ctx.ui.error(_('Failed to prepare offline upgrade')) + return + finally: + ctx.ui.info(util.colorize(_('Successfully prepared offline upgrade'), 'green')) + def plan_upgrade(A, force_replaced=True, replaces=None): # FIXME: remove force_replaced # try to construct a pisi graph of packages to