New Upstream Release - dnf-plugins-core

Ready changes

Summary

Merged new upstream version: 4.4.1 (was: 4.3.1).

Diff

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd8a32a..c6c18c3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,7 @@ jobs:
         uses: actions/checkout@v2
         with:
           repository: rpm-software-management/ci-dnf-stack
+          ref: dnf-4-stack
 
       - name: Setup CI
         id: setup-ci
@@ -48,6 +49,7 @@ jobs:
         uses: actions/checkout@v2
         with:
           repository: rpm-software-management/ci-dnf-stack
+          ref: dnf-4-stack
 
       - name: Run Integration Tests
         uses: ./.github/actions/integration-tests
@@ -66,6 +68,7 @@ jobs:
         uses: actions/checkout@v2
         with:
           repository: rpm-software-management/ci-dnf-stack
+          ref: dnf-4-stack
 
       - name: Run Ansible Tests
         uses: ./.github/actions/ansible-tests
diff --git a/AUTHORS b/AUTHORS
index 352e195..a6102ec 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -20,6 +20,7 @@ DNF-PLUGINS-CORE CONTRIBUTORS
     Adam Salih <salih.max@gmail.com>
     Alexander Todorov <atodorov@otb.bg>
     Anders Blomdell <anders.blomdell@gmail.com>
+    Cameron Rodriguez <rod.cam2014+dev@gmail.com>
     Cyril Jouve <jv.cyril@gmail.com>
     David Michael <fedora.dm0@gmail.com>
     François Rigault <francois.rigault@amadeus.com>
@@ -32,6 +33,7 @@ DNF-PLUGINS-CORE CONTRIBUTORS
     Neal Gompa <ngompa13@gmail.com>
     Paul Howarth <paul@city-fan.org>
     Rickard Dybeck <r.dybeck@gmail.com>
+    Tarcísio Ladeia de Oliveira <wyrquill@gmail.com>
     Tomas Babej <tomasbabej@gmail.com>
     Vladan Kudlac <vladankudlac@gmail.com>
     Wieland Hoffmann <themineo@gmail.com>
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 59c4f2a..a1eea7b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -23,6 +23,21 @@ MESSAGE(STATUS "Python install dir is ${PYTHON_INSTALL_DIR}")
 
 SET (SYSCONFDIR /etc)
 
+find_package (PkgConfig)
+
+if (PKG_CONFIG_FOUND)
+  pkg_search_module (SYSTEMD systemd)
+  if (SYSTEMD_FOUND)
+    execute_process (COMMAND ${PKG_CONFIG_EXECUTABLE} --variable=systemdsystemunitdir systemd
+                     OUTPUT_VARIABLE SYSTEMD_DIR
+                     OUTPUT_STRIP_TRAILING_WHITESPACE)
+  endif ()
+endif()
+
+if (NOT SYSTEMD_DIR)
+  set (SYSTEMD_DIR /usr/lib/systemd/system)
+endif ()
+
 ADD_SUBDIRECTORY (libexec)
 ADD_SUBDIRECTORY (doc)
 ADD_SUBDIRECTORY (etc)
diff --git a/README.rst b/README.rst
index 8e2a51a..031c013 100644
--- a/README.rst
+++ b/README.rst
@@ -85,6 +85,6 @@ The DNF-PLUGINS-CORE package distribution contains man pages ``dnf.plugin.*(8)``
  Bug reporting etc.
 ====================
 
-Please report discovered bugs to the `Red Hat bugzilla <https://bugzilla.redhat.com/>`_ following this `guide <https://github.com/rpm-software-management/dnf/wiki/Bug-Reporting>`_. If you planed to propose the patch in the report, consider `contribution`_ instead.
+Please report discovered bugs to the `Red Hat bugzilla <https://bugzilla.redhat.com/>`_ following this `guide <https://github.com/rpm-software-management/dnf/wiki/Bug-Reporting>`_. If you planned to propose the patch in the report, consider `contribution`_ instead.
 
 Freenode's irc channel ``#yum`` is meant for discussions related to both Yum and DNF. Questions should be asked there, issues discussed. Remember: ``#yum`` is not a support channel and prior research is expected from the questioner.
diff --git a/debian/changelog b/debian/changelog
index df93e55..7942081 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+dnf-plugins-core (4.4.1-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Wed, 14 Jun 2023 04:29:41 -0000
+
 dnf-plugins-core (4.3.1-3) unstable; urgency=medium
 
   * d/control: depend on required modules explictly (Closes: #1029290)
diff --git a/debian/patches/0002-compatibility-to-older-dnf-module.patch b/debian/patches/0002-compatibility-to-older-dnf-module.patch
index d0fc87e..dce3137 100644
--- a/debian/patches/0002-compatibility-to-older-dnf-module.patch
+++ b/debian/patches/0002-compatibility-to-older-dnf-module.patch
@@ -6,10 +6,10 @@ Subject: compatibility to older dnf module
  plugins/reposync.py | 10 +++++++++-
  1 file changed, 9 insertions(+), 1 deletion(-)
 
-diff --git a/plugins/reposync.py b/plugins/reposync.py
-index 0ff936f..b6d12bd 100644
---- a/plugins/reposync.py
-+++ b/plugins/reposync.py
+Index: dnf-plugins-core.git/plugins/reposync.py
+===================================================================
+--- dnf-plugins-core.git.orig/plugins/reposync.py
++++ dnf-plugins-core.git/plugins/reposync.py
 @@ -26,6 +26,8 @@ import os
  import shutil
  import types
@@ -19,7 +19,7 @@ index 0ff936f..b6d12bd 100644
  from dnfpluginscore import _, logger
  from dnf.cli.option_parser import OptionParser
  import dnf
-@@ -303,7 +305,13 @@ class RepoSyncCommand(dnf.cli.Command):
+@@ -316,7 +318,13 @@ class RepoSyncCommand(dnf.cli.Command):
                                    progress, 0)
          payloads = [RPMPayloadLocation(pkg, progress, self.pkg_download_path(pkg))
                      for pkg in pkglist]
diff --git a/debian/patches/sphinx-doc-path.patch b/debian/patches/sphinx-doc-path.patch
index 5780b71..98b466c 100644
--- a/debian/patches/sphinx-doc-path.patch
+++ b/debian/patches/sphinx-doc-path.patch
@@ -2,10 +2,10 @@ Description: Use sphinx-build command
 Author: Aron Xu <aron@debian.org>
 Last-Updated: 2021-06-15
 Forwarded: not-needed
-Index: dnf-plugins-core/doc/CMakeLists.txt
+Index: dnf-plugins-core.git/doc/CMakeLists.txt
 ===================================================================
---- dnf-plugins-core.orig/doc/CMakeLists.txt
-+++ dnf-plugins-core/doc/CMakeLists.txt
+--- dnf-plugins-core.git.orig/doc/CMakeLists.txt
++++ dnf-plugins-core.git/doc/CMakeLists.txt
 @@ -4,7 +4,7 @@
  if (${PYTHON_VERSION_MAJOR} STREQUAL "2")
      SET(SPHINX_BUILD_NAME "sphinx-build")
diff --git a/dnf-plugins-core.spec b/dnf-plugins-core.spec
index 787d521..a3f3957 100644
--- a/dnf-plugins-core.spec
+++ b/dnf-plugins-core.spec
@@ -33,10 +33,10 @@
 %endif
 
 Name:           dnf-plugins-core
-Version:        4.3.1
+Version:        4.4.1
 Release:        1%{?dist}
 Summary:        Core Plugins for DNF
-License:        GPLv2+
+License:        GPL-2.0-or-later
 URL:            https://github.com/rpm-software-management/dnf-plugins-core
 Source0:        %{url}/archive/%{version}/%{name}-%{version}.tar.gz
 BuildArch:      noarch
@@ -64,6 +64,9 @@ Provides:       dnf-command(repograph)
 Provides:       dnf-command(repomanage)
 Provides:       dnf-command(reposync)
 Provides:       dnf-command(repodiff)
+Provides:       dnf-command(system-upgrade)
+Provides:       dnf-command(offline-upgrade)
+Provides:       dnf-command(offline-distrosync)
 Provides:       dnf-plugins-extras-debug = %{version}-%{release}
 Provides:       dnf-plugins-extras-repoclosure = %{version}-%{release}
 Provides:       dnf-plugins-extras-repograph = %{version}-%{release}
@@ -80,6 +83,7 @@ Provides:       dnf-plugin-repodiff = %{version}-%{release}
 Provides:       dnf-plugin-repograph = %{version}-%{release}
 Provides:       dnf-plugin-repomanage = %{version}-%{release}
 Provides:       dnf-plugin-reposync = %{version}-%{release}
+Provides:       dnf-plugin-system-upgrade = %{version}-%{release}
 %if %{with yumcompatibility}
 Provides:       yum-plugin-copr = %{version}-%{release}
 Provides:       yum-plugin-changelog = %{version}-%{release}
@@ -133,8 +137,8 @@ Conflicts:      python-%{name} < %{version}-%{release}
 %description -n python2-%{name}
 Core Plugins for DNF, Python 2 interface. This package enhances DNF with builddep,
 config-manager, copr, degug, debuginfo-install, download, needs-restarting,
-groups-manager, repoclosure, repograph, repomanage, reposync, changelog
-and repodiff commands.
+groups-manager, repoclosure, repograph, repomanage, reposync, changelog,
+repodiff, system-upgrade, offline-upgrade and offline-distrosync commands.
 Additionally provides generate_completion_cache passive plugin.
 %endif
 
@@ -145,6 +149,10 @@ Summary:    Core Plugins for DNF
 BuildRequires:  python3-dbus
 BuildRequires:  python3-devel
 BuildRequires:  python3-dnf >= %{dnf_lowest_compatible}
+BuildRequires:  python3-systemd
+BuildRequires:  pkgconfig(systemd)
+BuildRequires:  systemd
+%{?systemd_ordering}
 %if 0%{?fedora}
 Requires:       python3-distro
 %endif
@@ -152,14 +160,17 @@ Requires:       python3-dbus
 Requires:       python3-dnf >= %{dnf_lowest_compatible}
 Requires:       python3-hawkey >= %{hawkey_version}
 Requires:       python3-dateutil
+Requires:       python3-systemd
 Provides:       python3-dnf-plugins-extras-debug = %{version}-%{release}
 Provides:       python3-dnf-plugins-extras-repoclosure = %{version}-%{release}
 Provides:       python3-dnf-plugins-extras-repograph = %{version}-%{release}
 Provides:       python3-dnf-plugins-extras-repomanage = %{version}-%{release}
+Provides:       python3-dnf-plugin-system-upgrade = %{version}-%{release}
 Obsoletes:      python3-dnf-plugins-extras-debug < %{dnf_plugins_extra}
 Obsoletes:      python3-dnf-plugins-extras-repoclosure < %{dnf_plugins_extra}
 Obsoletes:      python3-dnf-plugins-extras-repograph < %{dnf_plugins_extra}
 Obsoletes:      python3-dnf-plugins-extras-repomanage < %{dnf_plugins_extra}
+Obsoletes:      python3-dnf-plugin-system-upgrade < %{version}-%{release}
 
 Conflicts:      %{name} <= 0.1.5
 # let the both python plugin versions be updated simultaneously
@@ -169,8 +180,8 @@ Conflicts:      python-%{name} < %{version}-%{release}
 %description -n python3-%{name}
 Core Plugins for DNF, Python 3 interface. This package enhances DNF with builddep,
 config-manager, copr, debug, debuginfo-install, download, needs-restarting,
-groups-manager, repoclosure, repograph, repomanage, reposync, changelog
-and repodiff commands.
+groups-manager, repoclosure, repograph, repomanage, reposync, changelog,
+repodiff, system-upgrade, offline-upgrade and offline-distrosync commands.
 Additionally provides generate_completion_cache passive plugin.
 %endif
 
@@ -185,7 +196,6 @@ Provides:       yum-utils = %{version}-%{release}
 Provides:       dnf-utils = %{version}-%{release}
 Obsoletes:      dnf-utils < %{version}-%{release}
 %endif
-Requires:       dnf >= %{dnf_lowest_compatible}
 Requires:       %{name} = %{version}-%{release}
 %if %{with python3}
 Requires:       python3-dnf >= %{dnf_lowest_compatible}
@@ -451,6 +461,17 @@ pushd build-py3
   %make_install
 popd
 %endif
+
+%if %{with python3}
+mkdir -p %{buildroot}%{_unitdir}/system-update.target.wants/
+pushd %{buildroot}%{_unitdir}/system-update.target.wants/
+  ln -sr ../dnf-system-upgrade.service
+popd
+
+ln -sf %{_mandir}/man8/dnf-system-upgrade.8.gz %{buildroot}%{_mandir}/man8/dnf-offline-upgrade.8.gz
+ln -sf %{_mandir}/man8/dnf-system-upgrade.8.gz %{buildroot}%{_mandir}/man8/dnf-offline-distrosync.8.gz
+%endif
+
 %find_lang %{name}
 %if %{with yumutils}
   %if %{with python3}
@@ -515,6 +536,9 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/
 %{_mandir}/man8/dnf-repograph.*
 %{_mandir}/man8/dnf-repomanage.*
 %{_mandir}/man8/dnf-reposync.*
+%{_mandir}/man8/dnf-system-upgrade.*
+%{_mandir}/man8/dnf-offline-upgrade.*
+%{_mandir}/man8/dnf-offline-distrosync.*
 %if %{with yumcompatibility}
 %{_mandir}/man1/yum-changelog.*
 %{_mandir}/man8/yum-copr.*
@@ -572,6 +596,7 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/
 %{python3_sitelib}/dnf-plugins/repograph.py
 %{python3_sitelib}/dnf-plugins/repomanage.py
 %{python3_sitelib}/dnf-plugins/reposync.py
+%{python3_sitelib}/dnf-plugins/system_upgrade.py
 %{python3_sitelib}/dnf-plugins/__pycache__/builddep.*
 %{python3_sitelib}/dnf-plugins/__pycache__/changelog.*
 %{python3_sitelib}/dnf-plugins/__pycache__/config_manager.*
@@ -587,7 +612,11 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/
 %{python3_sitelib}/dnf-plugins/__pycache__/repograph.*
 %{python3_sitelib}/dnf-plugins/__pycache__/repomanage.*
 %{python3_sitelib}/dnf-plugins/__pycache__/reposync.*
+%{python3_sitelib}/dnf-plugins/__pycache__/system_upgrade.*
 %{python3_sitelib}/dnfpluginscore/
+%{_unitdir}/dnf-system-upgrade.service
+%{_unitdir}/dnf-system-upgrade-cleanup.service
+%{_unitdir}/system-update.target.wants/dnf-system-upgrade.service
 %endif
 
 %if %{with yumutils}
@@ -783,6 +812,23 @@ ln -sf %{_mandir}/man1/%{yum_utils_subpackage_name}.1.gz %{buildroot}%{_mandir}/
 %endif
 
 %changelog
+* Mon May 15 2023 Jan Kolarik <jkolarik@redhat.com> - 4.4.1-1
+- reposync: Implement --safe-write-path option (RhBug:1898089)
+- needs-restarting: Catch exception when no systemd unit exists for pid (RhBug:2122587)
+- post-transaction-actions: Fix ConfigParser.substitute call
+- builddep: Avoid using obsolete RPM API
+- yum-utils: Only depend on python3-dnf, not dnf
+
+* Wed Apr 05 2023 Jan Kolarik <jkolarik@redhat.com> - 4.4.0-1
+- system-upgrade: Move from extras to core (RhBug:2054235)
+- system-upgrade: Add support for security filters in offline-upgrade (RhBug:1939975)
+- needs-restarting: Fix boot time derivation for systems with no rtc (RhBug:2137935)
+- system-upgrade: Add --poweroff option to reboot
+- download: Skip downloading weak deps when install_weak_deps=False
+- copr: Switch to reading a copr.vendor.conf file to determine a vendor ID
+- config-manager: Allow to specify the "main" section
+- reposync: Documentation update (RhBug:2132383, 2182004)
+
 * Fri Sep 23 2022 Jaroslav Rohel <jrohel@redhat.com> - 4.3.1-1
 - Update translations (fix RhBug:2127011)
 
diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt
index b28d3e3..ee7f26c 100644
--- a/doc/CMakeLists.txt
+++ b/doc/CMakeLists.txt
@@ -38,6 +38,7 @@ INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf-builddep.8
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-reposync.8
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-post-transaction-actions.8
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-show-leaves.8
+    ${CMAKE_CURRENT_BINARY_DIR}/dnf-system-upgrade.8
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-versionlock.8
     ${CMAKE_CURRENT_BINARY_DIR}/yum-copr.8
     ${CMAKE_CURRENT_BINARY_DIR}/yum-versionlock.8
diff --git a/doc/conf.py b/doc/conf.py
index 41d6936..327ac07 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -264,6 +264,7 @@ man_pages = [
     ('post-transaction-actions', 'dnf-post-transaction-actions',
      u'DNF post transaction actions Plugin', AUTHORS, 8),
     ('show-leaves', 'dnf-show-leaves', u'DNF show-leaves Plugin', AUTHORS, 8),
+    ('system-upgrade', 'dnf-system-upgrade', u'DNF system-upgrade Plugin', AUTHORS, 8),
     ('versionlock', 'dnf-versionlock', u'DNF versionlock Plugin', AUTHORS, 8),
 
     # yum3 compatible layer for manpages
diff --git a/doc/copr.rst b/doc/copr.rst
index a019d40..5ce4981 100644
--- a/doc/copr.rst
+++ b/doc/copr.rst
@@ -80,6 +80,7 @@ Configuration (copr)
 
 ``/etc/dnf/plugins/copr.conf``
 ``/etc/dnf/plugins/copr.d/``
+``/usr/share/dnf/plugins/copr.vendor.conf``
 
 Configuration file should contain a section for each hub, each section having ``hostname``
  (mandatory), ``protocol`` (default ``https``) and ``port`` (default ``443``) parameters.::
@@ -89,6 +90,14 @@ Configuration file should contain a section for each hub, each section having ``
   protocol = https
   port = 443
 
+
+There is also a vendor configuration that allows a vendor to specify the distro ID that copr should use by default.
+This is useful for vendors that want to use Copr for their own distro. The vendor configuration is in
+``/usr/share/dnf/plugins/copr.vendor.conf`` (optional) or ``/etc/dnf/plugins/copr.conf``::
+
+  [main]
+  distribution = fedora
+  releasever = 37
 ----------------------
 Arguments (playground)
 ----------------------
diff --git a/doc/index.rst b/doc/index.rst
index 07f6052..251a24e 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -46,6 +46,7 @@ This documents core plugins of DNF:
    repomanage
    reposync
    show-leaves
+   system-upgrade
    versionlock
 
 
diff --git a/doc/needs_restarting.rst b/doc/needs_restarting.rst
index 46cbcb0..88aa4f6 100644
--- a/doc/needs_restarting.rst
+++ b/doc/needs_restarting.rst
@@ -25,7 +25,7 @@ Check for running processes that should be restarted.
 Synopsis
 --------
 
-``dnf needs-restarting [-u] [-r]``
+``dnf needs-restarting [-u] [-r] [-s]``
 
 -----------
 Description
@@ -42,11 +42,9 @@ Options
 All general DNF options are accepted, see `Options` in :manpage:`dnf(8)` for details.
 
 ``-u, --useronly``
-
     Only consider processes belonging to the running user.
 
 ``-r, --reboothint``
-
     Only report whether a reboot is required (exit code 1) or not (exit code 0).
 
 ``-s, --services``
diff --git a/doc/post-transaction-actions.rst b/doc/post-transaction-actions.rst
index 9f5381a..fa7eadc 100644
--- a/doc/post-transaction-actions.rst
+++ b/doc/post-transaction-actions.rst
@@ -50,7 +50,7 @@ Each non-comment line defines an action and consists of three items separated by
 ``package_filter:transaction_state:command``.
 
 ``package_filter``
-   A (glob-like) filtering rule aplied on the package NEVRA (also in the shortened forms) or
+   A (glob-like) filtering rule applied on the package NEVRA (also in the shortened forms) or
    package files.
 
 ``transaction_state``
diff --git a/doc/release_notes.rst b/doc/release_notes.rst
index de0feef..581e234 100644
--- a/doc/release_notes.rst
+++ b/doc/release_notes.rst
@@ -21,6 +21,50 @@ Core DNF Plugins Release Notes
 
 .. contents::
 
+===================
+4.4.1 Release Notes
+===================
+
+- New features:
+  - reposync: Implement --safe-write-path option (RhBug:1898089)
+
+- Bug fixes:
+  - needs-restarting: Catch exception when no systemd unit exists for pid (RhBug:2122587)
+  - post-transaction-actions: Fix ConfigParser.substitute call
+
+- Others:
+  - builddep: Avoid using obsolete RPM API
+  - yum-utils: Only depend on python3-dnf, not dnf
+
+Bugs fixed in 4.4.1:
+
+* :rhbug:`1898089`
+* :rhbug:`2122587`
+
+===================
+4.4.0 Release Notes
+===================
+
+- New features:
+  - system-upgrade: Move from extras to core (RhBug:2054235)
+  - system-upgrade: Add --poweroff option to reboot
+  - copr: Switch to reading a copr.vendor.conf file to determine a vendor ID
+  - config-manager: Allow to specify the "main" section
+
+- Bug fixes:
+  - system-upgrade: Add support for security filters in offline-upgrade (RhBug:1939975)
+  - needs-restarting: Fix boot time derivation for systems with no rtc (RhBug:2137935)
+  - download: Skip downloading weak deps when install_weak_deps=False
+  - reposync: Documentation update (RhBug:2132383, 2182004)
+
+Bugs fixed in 4.4.0:
+
+* :rhbug:`2054235`
+* :rhbug:`1939975`
+* :rhbug:`2137935`
+* :rhbug:`2132383`
+* :rhbug:`2182004`
+
 ===================
 4.3.1 Release Notes
 ===================
diff --git a/doc/reposync.rst b/doc/reposync.rst
index 0df00b9..ede8901 100644
--- a/doc/reposync.rst
+++ b/doc/reposync.rst
@@ -46,7 +46,11 @@ All general DNF options are accepted. Namely, the ``--repoid`` option can be use
     Delete local packages no longer present in repository.
 
 ``--download-metadata``
-    Download all repository metadata. Downloaded copy is instantly usable as a repository, no need to run createrepo_c on it.
+    Download all repository metadata. Downloaded copy is instantly usable as a repository, no need to run createrepo_c
+    on it. When the option is used with `--newest-only`, only latest packages will be downloaded, but metadata will
+    still contain older packages. It might be useful to update metadata using `createrepo_c --update` to remove
+    the packages with missing RPM files from metadata. Otherwise, DNF ends with an error due to the missing files
+    whenever it tries to download older packages.
 
 ``-g, --gpgcheck``
     Remove packages that fail GPG signature checking after downloading. Exit code is ``1`` if at least one package was removed.
@@ -67,6 +71,9 @@ All general DNF options are accepted. Namely, the ``--repoid`` option can be use
 ``-p <download-path>, --download-path=<download-path>``
     Root path under which the downloaded repositories are stored, relative to the current working directory. Defaults to the current working directory. Every downloaded repository has a subdirectory named after its ID under this path.
 
+``--safe-write-path``
+    Specify the filesystem path prefix under which the reposync is allowed to write. If not specified it defaults to download path of the repository. Useful for repositories that use relative locations of packages out of repository directory (e.g. "../packages_store/foo.rpm"). Use with care, any file under the ``safe-write-path`` can be overwritten. Can be only used when syncing a single repository.
+
 ``--remote-time``
     Try to set the timestamps of the downloaded files to those on the remote side.
 
diff --git a/doc/summaries_cache b/doc/summaries_cache
index f126572..2c9156e 100644
--- a/doc/summaries_cache
+++ b/doc/summaries_cache
@@ -690,5 +690,33 @@
     [
         2127011,
         "dnf-plugins-core show errors when use LANG=es_ES-UTF-8"
+    ],
+    [
+        2054235,
+        "Offline updates"
+    ],
+    [
+        1939975,
+        "'dnf offline-upgrade' does not support --advisory properly"
+    ],
+    [
+        2137935,
+        "dnf needs-restarting always true"
+    ],
+    [
+        2132383,
+        "reposync produces wrong metadata when using both -n and --download-metadata options"
+    ],
+    [
+        2182004,
+        "reposync produces wrong metadata when using both -n and --download-metadata options"
+    ],
+    [
+        1898089,
+        "dnf reposync doesn't handle well relative paths"
+    ],
+    [
+        2122587,
+        "needs-restarting -s fail with mysql container"
     ]
 ]
\ No newline at end of file
diff --git a/doc/system-upgrade.rst b/doc/system-upgrade.rst
new file mode 100644
index 0000000..933d847
--- /dev/null
+++ b/doc/system-upgrade.rst
@@ -0,0 +1,217 @@
+..
+  Copyright (C) 2014-2016 Red Hat, Inc.
+
+  This copyrighted material is made available to anyone wishing to use,
+  modify, copy, or redistribute it subject to the terms and conditions of
+  the GNU General Public License v.2, or (at your option) any later version.
+  This program is distributed in the hope that it will be useful, but WITHOUT
+  ANY WARRANTY expressed or implied, including the implied warranties of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
+  Public License for more details.  You should have received a copy of the
+  GNU General Public License along with this program; if not, write to the
+  Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+  02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
+  source code or documentation are not subject to the GNU General Public
+  License and may only be used or replicated with the express permission of
+  Red Hat, Inc.
+
+=========================
+DNF system-upgrade Plugin
+=========================
+
+-----------
+Description
+-----------
+
+DNF system-upgrades plugin provides three commands: ``system-upgrade``, ``offline-upgrade``, and
+``offline-distrosync``. Only ``system-upgrade`` command requires increase of distribution major
+version (``--releasever``) compared to installed version.
+
+``dnf system-upgrade`` is a recommended way to upgrade a system to a new major release.
+It replaces fedup (the old Fedora Upgrade tool). Before you proceed ensure that your system
+is fully upgraded (``dnf --refresh upgrade``).
+
+The ``system-upgrade`` command also performes additional actions necessary for the upgrade of the
+system, for example an upgrade of groups and environments.
+
+--------
+Synopsis
+--------
+
+``dnf system-upgrade download --releasever VERSION [OPTIONS]``
+
+``dnf system-upgrade reboot``
+
+``dnf system-upgrade reboot --poweroff``
+
+``dnf system-upgrade clean``
+
+``dnf system-upgrade log``
+
+``dnf system-upgrade log --number=<number>``
+
+``dnf offline-upgrade download [OPTIONS]``
+
+``dnf offline-upgrade reboot``
+
+``dnf offline-upgrade reboot --poweroff``
+
+``dnf offline-upgrade clean``
+
+``dnf offline-upgrade log``
+
+``dnf offline-upgrade log --number=<number>``
+
+``dnf offline-distrosync download [OPTIONS]``
+
+``dnf offline-distrosync reboot``
+
+``dnf offline-distrosync reboot --poweroff``
+
+``dnf offline-distrosync clean``
+
+``dnf offline-distrosync log``
+
+``dnf offline-distrosync log --number=<number>``
+
+-----------
+Subcommands
+-----------
+
+``download``
+    Downloads everything needed to upgrade to a new major release.
+
+``reboot``
+    Prepares the system to perform the upgrade, and reboots to start the upgrade.
+    This can only be used after the ``download`` command completes successfully.
+
+``clean``
+    Remove previously-downloaded data. This happens automatically at the end of
+    a successful upgrade.
+
+``log``
+    Used to see a list of boots during which an upgrade was attempted, or show
+    the logs from an upgrade attempt. The logs for one of the boots can be shown
+    by specifying one of the numbers in the first column. Negative numbers can
+    be used to number the boots from last to first. For example, ``log --number=-1`` can
+    be used to see the logs for the last upgrade attempt.
+
+-------
+Options
+-------
+
+``--releasever=VERSION``
+    REQUIRED. The version to upgrade to. Sets ``$releasever`` in all enabled
+    repos. Usually a number, or ``rawhide``.
+
+``--downloaddir=<path>``
+    Redirect download of packages to provided ``<path>``. By default, packages
+    are downloaded into (per repository created) subdirectories of
+    /var/lib/dnf/system-upgrade.
+
+``--distro-sync``
+    Behave like ``dnf distro-sync``: always install packages from the new
+    release, even if they are older than the currently-installed version. This
+    is the default behavior.
+
+``--no-downgrade``
+    Behave like ``dnf update``: do not install packages from the new release
+    if they are older than what is currently installed. This is the opposite of
+    ``--distro-sync``. If both are specified, the last option will be used. The option cannot be
+    used with the ``offline-distrosync`` command.
+
+``--poweroff``
+    When applied with the ``reboot`` subcommand, the system will power off after
+    upgrades are completed, instead of restarting.
+
+``--number``
+    Applied with ``log`` subcommand will show the log specified by the number.
+
+-----
+Notes
+-----
+
+``dnf system-upgrade reboot`` does not create a "System Upgrade" boot item. The
+upgrade will start regardless of which boot item is chosen.
+
+The ``DNF_SYSTEM_UPGRADE_NO_REBOOT`` environment variable can be set to a
+non-empty value to disable the actual reboot performed by ``system-upgrade``
+(e.g. for testing purposes).
+
+Since this is a DNF plugin, options accepted by ``dnf`` are also valid here,
+such as ``--allowerasing``.
+See :manpage:`dnf(8)` for more information.
+
+The ``fedup`` command is not provided, not even as an alias for
+``dnf system-upgrade``.
+
+----
+Bugs
+----
+
+Upgrading from install media (e.g. a DVD or .iso file) currently requires the
+user to manually set up a DNF repo and fstab entry for the media.
+
+--------
+Examples
+--------
+
+Typical upgrade usage
+---------------------
+
+``dnf --refresh upgrade``
+
+``dnf system-upgrade download --releasever 26``
+
+``dnf system-upgrade reboot``
+
+Show logs from last upgrade attempt
+-----------------------------------
+
+``dnf system-upgrade log --number=-1``
+
+--------------
+Reporting Bugs
+--------------
+
+Bugs should be filed here:
+
+  https://bugzilla.redhat.com/
+
+For more info on filing bugs, see the Fedora Project wiki:
+
+  https://fedoraproject.org/wiki/How_to_file_a_bug_report
+
+  https://fedoraproject.org/wiki/Bugs_and_feature_requests
+
+Please include ``/var/log/dnf.log`` and the output of
+``dnf system-upgrade log --number=-1`` (if applicable) in your bug reports.
+
+Problems with dependency solving during download are best reported to the
+maintainers of the package(s) with the dependency problems.
+
+Similarly, problems encountered on your system after the upgrade completes
+should be reported to the maintainers of the affected components. In other
+words: if (for example) KDE stops working, it's best if you report that to
+the KDE maintainers.
+
+--------
+See Also
+--------
+
+:manpage:`dnf(8)`,
+:manpage:`dnf.conf(5)`,
+:manpage:`journalctl(1)`.
+
+Project homepage
+----------------
+
+https://github.com/rpm-software-management/dnf-plugins-core
+
+-------
+Authors
+-------
+
+Will Woods <wwoods@redhat.com>
+
+Štěpán Smetana <ssmetana@redhat.com>
diff --git a/etc/CMakeLists.txt b/etc/CMakeLists.txt
index 2e9cccd..a892f8a 100644
--- a/etc/CMakeLists.txt
+++ b/etc/CMakeLists.txt
@@ -1 +1,2 @@
 ADD_SUBDIRECTORY (dnf)
+ADD_SUBDIRECTORY (systemd)
diff --git a/etc/systemd/CMakeLists.txt b/etc/systemd/CMakeLists.txt
new file mode 100644
index 0000000..8a29403
--- /dev/null
+++ b/etc/systemd/CMakeLists.txt
@@ -0,0 +1 @@
+INSTALL (FILES "dnf-system-upgrade.service" "dnf-system-upgrade-cleanup.service" DESTINATION ${SYSTEMD_DIR})
diff --git a/etc/systemd/dnf-system-upgrade-cleanup.service b/etc/systemd/dnf-system-upgrade-cleanup.service
new file mode 100644
index 0000000..49f771c
--- /dev/null
+++ b/etc/systemd/dnf-system-upgrade-cleanup.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=System Upgrade using DNF failed
+DefaultDependencies=no
+
+[Service]
+Type=oneshot
+# Remove the symlink if it's still there, to protect against reboot loops.
+ExecStart=/usr/bin/rm -fv /system-update
+# If anything goes wrong, reboot back to the normal system.
+ExecStart=/usr/bin/systemctl --no-block reboot
+
diff --git a/etc/systemd/dnf-system-upgrade.service b/etc/systemd/dnf-system-upgrade.service
new file mode 100644
index 0000000..5bd7358
--- /dev/null
+++ b/etc/systemd/dnf-system-upgrade.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=System Upgrade using DNF
+ConditionPathExists=/system-update
+Documentation=http://www.freedesktop.org/wiki/Software/systemd/SystemUpdates
+
+DefaultDependencies=no
+Requires=sysinit.target
+After=sysinit.target systemd-journald.socket system-update-pre.target
+Before=shutdown.target system-update.target
+OnFailure=dnf-system-upgrade-cleanup.service
+
+[Service]
+# We are done when the script exits, not before
+Type=oneshot
+# Upgrade output goes to journal and on-screen.
+StandardOutput=journal+console
+ExecStart=/usr/bin/dnf-3 system-upgrade upgrade
+
+[Install]
+WantedBy=system-update.target
diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt
index 59f148f..d004e5e 100644
--- a/plugins/CMakeLists.txt
+++ b/plugins/CMakeLists.txt
@@ -22,6 +22,7 @@ INSTALL (FILES repograph.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
 INSTALL (FILES repomanage.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
 INSTALL (FILES reposync.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
 INSTALL (FILES show_leaves.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
+INSTALL (FILES system_upgrade.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
 INSTALL (FILES modulesync.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
 INSTALL (FILES versionlock.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
 
diff --git a/plugins/builddep.py b/plugins/builddep.py
index e7dac43..578bf19 100644
--- a/plugins/builddep.py
+++ b/plugins/builddep.py
@@ -192,7 +192,7 @@ class BuildDepCommand(dnf.cli.Command):
             os.close(fd)
             raise dnf.exceptions.Error(e)
         os.close(fd)
-        ds = h.dsFromHeader('requirename')
+        ds = rpm.ds(h, 'requirename')
         done = True
         for dep in ds:
             reldep_str = self._rpm_dep2reldep_str(dep)
diff --git a/plugins/config_manager.py b/plugins/config_manager.py
index ec8033a..2ed0e5d 100644
--- a/plugins/config_manager.py
+++ b/plugins/config_manager.py
@@ -41,8 +41,8 @@ class ConfigManagerCommand(dnf.cli.Command):
     @staticmethod
     def set_argparser(parser):
         parser.add_argument(
-            'crepo', nargs='*', metavar='repo',
-            help=_('repo to modify'))
+            'crepo', nargs='*', metavar='section',
+            help=_('configuration sections to manage, "main" or repoid'))
         parser.add_argument(
             '--save', default=False, action='store_true',
             help=_('save the current options (useful with --setopt)'))
@@ -111,6 +111,8 @@ class ConfigManagerCommand(dnf.cli.Command):
         not_matching_repos_id = set()  # IDs of not matched repositories
 
         def match_repos(key, add_matching_repos):
+            if key == 'main':
+                return
             matching = self.base.repos.get_matching(key)
             if not matching:
                 not_matching_repos_id.add(name)
diff --git a/plugins/copr.py b/plugins/copr.py
index 16946b7..0b4e8ab 100644
--- a/plugins/copr.py
+++ b/plugins/copr.py
@@ -138,17 +138,15 @@ class CoprCommand(dnf.cli.Command):
         config_path = self.base.conf.pluginconfpath[0]
 
         default_config_file = os.path.join(config_path, PLUGIN_CONF + ".conf")
+        # would use appdirs, but that would mean a new dependency
+        vendor_config_file = os.path.join("/usr/share/dnf/plugins/", PLUGIN_CONF + ".vendor.conf")
+
         if os.path.isfile(default_config_file):
             config_files.append(default_config_file)
 
-            copr_plugin_config.read(default_config_file)
-            if copr_plugin_config.has_option('main', 'distribution') and\
-                    copr_plugin_config.has_option('main', 'releasever'):
-                distribution = copr_plugin_config.get('main', 'distribution')
-                releasever = copr_plugin_config.get('main', 'releasever')
-                self.chroot_config = [distribution, releasever]
-            else:
-                self.chroot_config = [False, False]
+        if os.path.isfile(vendor_config_file):
+            self._get_copr_chroot(vendor_config_file)
+        self._get_copr_chroot(default_config_file)
 
         for filename in os.listdir(os.path.join(config_path, PLUGIN_CONF + ".d")):
             if filename.endswith('.conf'):
@@ -211,6 +209,25 @@ class CoprCommand(dnf.cli.Command):
         except (NoOptionError, NoSectionError):
             return default
 
+    def _get_copr_chroot(self, config):
+        """
+        Sets the Copr root from a config file
+        This is refactored from configure() to avoid code duplication
+        """
+        copr_plugin_config = ConfigParser()
+        if not os.path.isfile(config):
+            return
+        copr_plugin_config.read(config)
+        if copr_plugin_config.has_option('main', 'distribution') and\
+                copr_plugin_config.has_option('main', 'releasever'):
+            distribution = copr_plugin_config.get('main', 'distribution')
+            releasever = copr_plugin_config.get('main', 'releasever')
+            self.chroot_config = [distribution, releasever]
+        else:
+            # check if the chroot_config is already set from a previous call
+            if not self.chroot_config:
+                self.chroot_config = [False, False]
+
     def _user_warning_before_prompt(self, text):
         sys.stderr.write("{0}\n".format(text.strip()))
 
diff --git a/plugins/download.py b/plugins/download.py
index 92a6df4..d8ce4be 100644
--- a/plugins/download.py
+++ b/plugins/download.py
@@ -253,7 +253,7 @@ class DownloadCommand(dnf.cli.Command):
         for pkg in pkgs:
             goal = hawkey.Goal(self.base.sack)
             goal.install(pkg)
-            rc = goal.run()
+            rc = goal.run(ignore_weak_deps=(not self.base.conf.install_weak_deps))
             if rc:
                 pkg_set.update(goal.list_installs())
                 pkg_set.update(goal.list_upgrades())
diff --git a/plugins/needs_restarting.py b/plugins/needs_restarting.py
index 91dbe66..a3a5f12 100644
--- a/plugins/needs_restarting.py
+++ b/plugins/needs_restarting.py
@@ -34,6 +34,7 @@ import functools
 import os
 import re
 import stat
+import time
 
 
 # For which package updates we should recommend a reboot
@@ -137,10 +138,23 @@ def get_service_dbus(pid):
         systemd_manager_object,
         'org.freedesktop.systemd1.Manager'
     )
-    service_proxy = bus.get_object(
-        'org.freedesktop.systemd1',
-        systemd_manager_interface.GetUnitByPID(pid)
-    )
+
+    service_unit_path = None
+    try:
+        service_unit_path = systemd_manager_interface.GetUnitByPID(pid)
+    except dbus.DBusException as e:
+        # There is no unit for the pid. Usually error is 'NoUnitForPid'.
+        # Considering what we do at the bottom (just return if not service)
+        # Then there's really no reason to exit here on that exception.
+        # Log what's happened then move on.
+        msg = str(e)
+        if msg.startswith('org.freedesktop.systemd1.NoUnitForPID'):
+            logger.warning("Failed to get systemd unit for PID {}: {}".format(pid, msg))
+            return
+        else:
+            raise
+
+    service_proxy = bus.get_object('org.freedesktop.systemd1', service_unit_path)
     service_properties = dbus.Interface(
         service_proxy, dbus_interface="org.freedesktop.DBus.Properties")
     name = service_properties.Get(
@@ -178,7 +192,7 @@ class OpenedFile(object):
     def presumed_name(self):
         """Calculate the name of the file pre-transaction.
 
-        In case of a file that got deleted during the transactionm, possibly
+        In case of a file that got deleted during the transaction, possibly
         just because of an upgrade to a newer version of the same file, RPM
         renames the old file to the same name with a hexadecimal suffix just
         before delting it.
@@ -199,7 +213,28 @@ class ProcessStart(object):
 
     @staticmethod
     def get_boot_time():
-        return int(os.stat('/proc/1').st_mtime)
+        """
+        We have two sources from which to derive the boot time. These values vary
+        depending on containerization, existence of a Real Time Clock, etc.
+        For our purposes we want the latest derived value.
+        - st_mtime of /proc/1
+             Reflects the time the first process was run after booting
+             This works for all known cases except machines without
+             a RTC - they awake at the start of the epoch.
+        - /proc/uptime
+             Seconds field of /proc/uptime subtracted from the current time
+             Works for machines without RTC iff the current time is reasonably correct.
+             Does not work on containers which share their kernel with the
+             host - there the host kernel uptime is returned
+        """
+
+        proc_1_boot_time = int(os.stat('/proc/1').st_mtime)
+        if os.path.isfile('/proc/uptime'):
+            with open('/proc/uptime', 'rb') as f:
+                uptime = f.readline().strip().split()[0].strip()
+                proc_uptime_boot_time = int(time.time() - float(uptime))
+                return max(proc_1_boot_time, proc_uptime_boot_time)
+        return proc_1_boot_time
 
     @staticmethod
     def get_sc_clk_tck():
diff --git a/plugins/post-transaction-actions.py b/plugins/post-transaction-actions.py
index 1520c26..a293fe2 100644
--- a/plugins/post-transaction-actions.py
+++ b/plugins/post-transaction-actions.py
@@ -101,7 +101,7 @@ class PostTransactionActions(dnf.Plugin):
                    "repoid": ts_item.from_repo,
                    "state": action}
 
-        result = libdnf.conf.ConfigParser_substitute(command, vardict)
+        result = libdnf.conf.ConfigParser.substitute(command, vardict)
         return result
 
     def transaction(self):
diff --git a/plugins/reposync.py b/plugins/reposync.py
index 0ff936f..63d8e98 100644
--- a/plugins/reposync.py
+++ b/plugins/reposync.py
@@ -88,6 +88,8 @@ class RepoSyncCommand(dnf.cli.Command):
         parser.add_argument('-u', '--urls', default=False, action='store_true',
                             help=_("Just list urls of what would be downloaded, "
                                    "don't download"))
+        parser.add_argument('--safe-write-path', default=None,
+                            help=_("Filesystem path that is considered safe for writing. Defaults to download path."))
 
     def configure(self):
         demands = self.cli.demands
@@ -108,9 +110,16 @@ class RepoSyncCommand(dnf.cli.Command):
         if self.opts.source:
             repos.enable_source_repos()
 
-        if len(list(repos.iter_enabled())) > 1 and self.opts.norepopath:
-            raise dnf.cli.CliError(
-                _("Can't use --norepopath with multiple repositories"))
+        if self.opts.safe_write_path is not None:
+            self.opts.safe_write_path = os.path.realpath(self.opts.safe_write_path)
+
+        if len(list(repos.iter_enabled())) > 1:
+            if self.opts.norepopath:
+                raise dnf.cli.CliError(
+                    _("Can't use --norepopath with multiple repositories"))
+            elif self.opts.safe_write_path is not None:
+                raise dnf.cli.CliError(
+                    _("Can't use --safe-write-path with multiple repositories"))
 
         for repo in repos.iter_enabled():
             repo._repo.expire()
@@ -188,13 +197,17 @@ class RepoSyncCommand(dnf.cli.Command):
         repo_target = self.repo_target(pkg.repo)
         pkg_download_path = os.path.realpath(
             os.path.join(repo_target, pkg.location))
-        # join() ensures repo_target ends with a path separator (otherwise the
+
+        # join() ensures safe_write_path ends with a path separator (otherwise the
         # check would pass if pkg_download_path was a "sibling" path component
         # of repo_target that has the same prefix).
-        if not pkg_download_path.startswith(os.path.join(repo_target, '')):
+        safe_write_path = os.path.join(self.opts.safe_write_path or repo_target, '')
+
+        if not pkg_download_path.startswith(safe_write_path):
             raise dnf.exceptions.Error(
-                _("Download target '{}' is outside of download path '{}'.").format(
-                    pkg_download_path, repo_target))
+                _("Download target '{0}' for location '{1}' of '{2}' package "
+                  "is outside of safe write path '{3}'.").format(
+                    pkg_download_path, pkg.location, pkg.name, safe_write_path))
         return pkg_download_path
 
     def delete_old_local_packages(self, repo, pkglist):
diff --git a/plugins/system_upgrade.py b/plugins/system_upgrade.py
new file mode 100644
index 0000000..530b680
--- /dev/null
+++ b/plugins/system_upgrade.py
@@ -0,0 +1,720 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2015-2020 Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Author(s): Will Woods <wwoods@redhat.com>
+
+"""system_upgrade.py - DNF plugin to handle major-version system upgrades."""
+
+from subprocess import call, Popen, check_output, CalledProcessError
+import json
+import os
+import os.path
+import re
+import sys
+import uuid
+
+from systemd import journal
+
+from dnfpluginscore import _, logger
+
+import dnf
+import dnf.cli
+from dnf.cli import CliError
+from dnf.i18n import ucd
+import dnf.transaction
+from dnf.transaction_sr import serialize_transaction, TransactionReplay
+
+import libdnf.conf
+
+
+# Translators: This string is only used in unit tests.
+_("the color of the sky")
+
+DOWNLOAD_FINISHED_ID = uuid.UUID('9348174c5cc74001a71ef26bd79d302e')
+REBOOT_REQUESTED_ID = uuid.UUID('fef1cc509d5047268b83a3a553f54b43')
+UPGRADE_STARTED_ID = uuid.UUID('3e0a5636d16b4ca4bbe5321d06c6aa62')
+UPGRADE_FINISHED_ID = uuid.UUID('8cec00a1566f4d3594f116450395f06c')
+
+ID_TO_IDENTIFY_BOOTS = UPGRADE_STARTED_ID
+
+PLYMOUTH = '/usr/bin/plymouth'
+
+RELEASEVER_MSG = _(
+    "Need a --releasever greater than the current system version.")
+DOWNLOAD_FINISHED_MSG = _(  # Translators: do not change "reboot" here
+    "Download complete! Use 'dnf {command} reboot' to start the upgrade.\n"
+    "To remove cached metadata and transaction use 'dnf {command} clean'")
+CANT_RESET_RELEASEVER = _(
+    "Sorry, you need to use 'download --releasever' instead of '--network'")
+
+STATE_VERSION = 3
+
+# --- Miscellaneous helper functions ------------------------------------------
+
+
+def reboot(poweroff = False):
+    if os.getenv("DNF_SYSTEM_UPGRADE_NO_REBOOT", default=False):
+        logger.info(_("Reboot turned off, not rebooting."))
+    else:
+        if poweroff:
+            Popen(["systemctl", "poweroff"])
+        else:
+            Popen(["systemctl", "reboot"])
+
+
+def get_url_from_os_release():
+    key = "UPGRADE_GUIDE_URL="
+    for path in ["/etc/os-release", "/usr/lib/os-release"]:
+        try:
+            with open(path) as release_file:
+                for line in release_file:
+                    line = line.strip()
+                    if line.startswith(key):
+                        return line[len(key):].strip('"')
+        except IOError:
+            continue
+    return None
+
+
+# DNF-FIXME: dnf.util.clear_dir() doesn't delete regular files :/
+def clear_dir(path, ignore=[]):
+    if not os.path.isdir(path):
+        return
+
+    for entry in os.listdir(path):
+        fullpath = os.path.join(path, entry)
+        if fullpath in ignore:
+            continue
+        try:
+            if os.path.isdir(fullpath):
+                dnf.util.rm_rf(fullpath)
+            else:
+                os.unlink(fullpath)
+        except OSError:
+            pass
+
+
+def check_release_ver(conf, target=None):
+    if dnf.rpm.detect_releasever(conf.installroot) == conf.releasever:
+        raise CliError(RELEASEVER_MSG)
+    if target and target != conf.releasever:
+        # it's too late to set releasever here, so this can't work.
+        # (see https://bugzilla.redhat.com/show_bug.cgi?id=1212341)
+        raise CliError(CANT_RESET_RELEASEVER)
+
+
+def disable_blanking():
+    try:
+        tty = open('/dev/tty0', 'wb')
+        tty.write(b'\33[9;0]')
+    except Exception as e:
+        print(_("Screen blanking can't be disabled: %s") % e)
+
+# --- State object - for tracking upgrade state between runs ------------------
+
+
+# DNF-INTEGRATION-NOTE: basically the same thing as dnf.persistor.JSONDB
+class State(object):
+    def __init__(self, statefile):
+        self.statefile = statefile
+        self._data = {}
+        self._read()
+
+    def _read(self):
+        try:
+            with open(self.statefile) as fp:
+                self._data = json.load(fp)
+        except IOError:
+            self._data = {}
+        except ValueError:
+            self._data = {}
+            logger.warning(_("Failed loading state file: %s, continuing with "
+                             "empty state."), self.statefile)
+
+    def write(self):
+        dnf.util.ensure_dir(os.path.dirname(self.statefile))
+        with open(self.statefile, 'w') as outf:
+            json.dump(self._data, outf, indent=4, sort_keys=True)
+
+    def clear(self):
+        if os.path.exists(self.statefile):
+            os.unlink(self.statefile)
+        self._read()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if exc_type is None:
+            self.write()
+
+    # helper function for creating properties. pylint: disable=protected-access
+    def _prop(option):  # pylint: disable=no-self-argument
+        def setprop(self, value):
+            self._data[option] = value
+
+        def getprop(self):
+            return self._data.get(option)
+        return property(getprop, setprop)
+
+    #  !!! Increase STATE_VERSION for any changes in data structure like a new property or a new
+    #  data structure !!!
+    state_version = _prop("state_version")
+    download_status = _prop("download_status")
+    destdir = _prop("destdir")
+    target_releasever = _prop("target_releasever")
+    system_releasever = _prop("system_releasever")
+    gpgcheck = _prop("gpgcheck")
+    # list of repos with gpgcheck=True
+    gpgcheck_repos = _prop("gpgcheck_repos")
+    # list of repos with repo_gpgcheck=True
+    repo_gpgcheck_repos = _prop("repo_gpgcheck_repos")
+    upgrade_status = _prop("upgrade_status")
+    upgrade_command = _prop("upgrade_command")
+    distro_sync = _prop("distro_sync")
+    poweroff_after = _prop("poweroff_after")
+    enable_disable_repos = _prop("enable_disable_repos")
+    module_platform_id = _prop("module_platform_id")
+
+# --- Plymouth output helpers -------------------------------------------------
+
+
+class PlymouthOutput(object):
+    """A plymouth output helper class.
+
+    Filters duplicate calls, and stops calling the plymouth binary if we
+    fail to contact it.
+    """
+
+    def __init__(self):
+        self.alive = True
+        self._last_args = dict()
+        self._last_msg = None
+
+    def _plymouth(self, cmd, *args):
+        dupe_cmd = (args == self._last_args.get(cmd))
+        if (self.alive and not dupe_cmd) or cmd == '--ping':
+            try:
+                self.alive = (call((PLYMOUTH, cmd) + args) == 0)
+            except OSError:
+                self.alive = False
+            self._last_args[cmd] = args
+        return self.alive
+
+    def ping(self):
+        return self._plymouth("--ping")
+
+    def message(self, msg):
+        if self._last_msg and self._last_msg != msg:
+            self._plymouth("hide-message", "--text", self._last_msg)
+        self._last_msg = msg
+        return self._plymouth("display-message", "--text", msg)
+
+    def set_mode(self):
+        mode = 'updates'
+        try:
+            s = check_output([PLYMOUTH, '--help'])
+            if re.search('--system-upgrade', ucd(s)):
+                mode = 'system-upgrade'
+        except (CalledProcessError, OSError):
+            pass
+        return self._plymouth("change-mode", "--" + mode)
+
+    def progress(self, percent):
+        return self._plymouth("system-update", "--progress", str(percent))
+
+
+# A single PlymouthOutput instance for us to use within this module
+Plymouth = PlymouthOutput()
+
+
+# A TransactionProgress class that updates plymouth for us.
+class PlymouthTransactionProgress(dnf.callback.TransactionProgress):
+
+    # pylint: disable=too-many-arguments
+    def progress(self, package, action, ti_done, ti_total, ts_done, ts_total):
+        self._update_plymouth(package, action, ts_done, ts_total)
+
+    def _update_plymouth(self, package, action, current, total):
+        # Prevents quick jumps of progressbar when pretrans scriptlets
+        # and TRANS_PREPARATION are reported as 1/1
+        if total == 1:
+            return
+        # Verification goes through all the packages again,
+        # which resets the "current" param value, this prevents
+        # resetting of the progress bar as well. (Rhbug:1809096)
+        if action != dnf.callback.PKG_VERIFY:
+            Plymouth.progress(int(90.0 * current / total))
+        else:
+            Plymouth.progress(90 + int(10.0 * current / total))
+
+        Plymouth.message(self._fmt_event(package, action, current, total))
+
+    def _fmt_event(self, package, action, current, total):
+        action = dnf.transaction.ACTIONS.get(action, action)
+        return "[%d/%d] %s %s..." % (current, total, action, package)
+
+# --- journal helpers -------------------------------------------------
+
+
+def find_boots(message_id):
+    """Find all boots with this message id.
+
+    Returns the entries of all found boots.
+    """
+    j = journal.Reader()
+    j.add_match(MESSAGE_ID=message_id.hex,  # identify the message
+                _UID=0)                     # prevent spoofing of logs
+
+    oldboot = None
+    for entry in j:
+        boot = entry['_BOOT_ID']
+        if boot == oldboot:
+            continue
+        oldboot = boot
+        yield entry
+
+
+def list_logs():
+    print(_('The following boots appear to contain upgrade logs:'))
+    n = -1
+    for n, entry in enumerate(find_boots(ID_TO_IDENTIFY_BOOTS)):
+        print('{} / {.hex}: {:%Y-%m-%d %H:%M:%S} {}→{}'.format(
+            n + 1,
+            entry['_BOOT_ID'],
+            entry['__REALTIME_TIMESTAMP'],
+            entry.get('SYSTEM_RELEASEVER', '??'),
+            entry.get('TARGET_RELEASEVER', '??')))
+    if n == -1:
+        print(_('-- no logs were found --'))
+
+
+def pick_boot(message_id, n):
+    boots = list(find_boots(message_id))
+    # Positive indices index all found boots starting with 1 and going forward,
+    # zero is the current boot, and -1, -2, -3 are previous going backwards.
+    # This is the same as journalctl.
+    try:
+        if n == 0:
+            raise IndexError
+        if n > 0:
+            n -= 1
+        return boots[n]['_BOOT_ID']
+    except IndexError:
+        raise CliError(_("Cannot find logs with this index."))
+
+
+def show_log(n):
+    boot_id = pick_boot(ID_TO_IDENTIFY_BOOTS, n)
+    process = Popen(['journalctl', '--boot', boot_id.hex])
+    process.wait()
+    rc = process.returncode
+    if rc == 1:
+        raise dnf.exceptions.Error(_("Unable to match systemd journal entry"))
+
+
+CMDS = ['download', 'clean', 'reboot', 'upgrade', 'log']
+
+# --- The actual Plugin and Command objects! ----------------------------------
+
+
+class SystemUpgradePlugin(dnf.Plugin):
+    name = 'system-upgrade'
+
+    def __init__(self, base, cli):
+        super(SystemUpgradePlugin, self).__init__(base, cli)
+        if cli:
+            cli.register_command(SystemUpgradeCommand)
+            cli.register_command(OfflineUpgradeCommand)
+            cli.register_command(OfflineDistrosyncCommand)
+
+
+class SystemUpgradeCommand(dnf.cli.Command):
+    aliases = ('system-upgrade', 'fedup',)
+    summary = _("Prepare system for upgrade to a new release")
+
+    DATADIR = 'var/lib/dnf/system-upgrade'
+
+    def __init__(self, cli):
+        super(SystemUpgradeCommand, self).__init__(cli)
+        self.datadir = os.path.join(cli.base.conf.installroot, self.DATADIR)
+        self.transaction_file = os.path.join(self.datadir, 'system-upgrade-transaction.json')
+        self.magic_symlink = os.path.join(cli.base.conf.installroot, 'system-update')
+
+        self.state = State(os.path.join(self.datadir, 'system-upgrade-state.json'))
+
+    @staticmethod
+    def set_argparser(parser):
+        parser.add_argument("--no-downgrade", dest='distro_sync',
+                            action='store_false',
+                            help=_("keep installed packages if the new "
+                                   "release's version is older"))
+        parser.add_argument('--poweroff', dest='poweroff_after',
+                            action='store_true',
+                            help=_("power off system after the operation "
+                                   "is completed"))
+        parser.add_argument('tid', nargs=1, choices=CMDS,
+                            metavar="[%s]" % "|".join(CMDS))
+        parser.add_argument('--number', type=int, help=_('which logs to show'))
+
+    def log_status(self, message, message_id):
+        """Log directly to the journal."""
+        journal.send(message,
+                     MESSAGE_ID=message_id,
+                     PRIORITY=journal.LOG_NOTICE,
+                     SYSTEM_RELEASEVER=self.state.system_releasever,
+                     TARGET_RELEASEVER=self.state.target_releasever,
+                     DNF_VERSION=dnf.const.VERSION)
+
+    def pre_configure(self):
+        self._call_sub("check")
+        self._call_sub("pre_configure")
+
+    def configure(self):
+        self._call_sub("configure")
+
+    def run(self):
+        self._call_sub("run")
+
+    def run_transaction(self):
+        self._call_sub("transaction")
+
+    def run_resolved(self):
+        self._call_sub("resolved")
+
+    def _call_sub(self, name):
+        subfunc = getattr(self, name + '_' + self.opts.tid[0], None)
+        if callable(subfunc):
+            subfunc()
+
+    def _check_state_version(self, command):
+        if self.state.state_version != STATE_VERSION:
+            msg = _("Incompatible version of data. Rerun 'dnf {command} download [OPTIONS]'"
+                    "").format(command=command)
+            raise CliError(msg)
+
+    def _set_cachedir(self):
+        # set download directories from json state file
+        self.base.conf.cachedir = self.datadir
+        self.base.conf.destdir = self.state.destdir if self.state.destdir else None
+
+    def _get_forward_reverse_pkg_reason_pairs(self):
+        """
+        forward = {repoid:{pkg_nevra: {tsi.action: tsi.reason}}
+        reverse = {pkg_nevra: {tsi.action: tsi.reason}}
+        :return: forward, reverse
+        """
+        backward_action = set(dnf.transaction.BACKWARD_ACTIONS + [libdnf.transaction.TransactionItemAction_REINSTALLED])
+        forward_actions = set(dnf.transaction.FORWARD_ACTIONS)
+
+        forward = {}
+        reverse = {}
+        for tsi in self.cli.base.transaction:
+            if tsi.action in forward_actions:
+                pkg = tsi.pkg
+                forward.setdefault(pkg.repo.id, {}).setdefault(
+                    str(pkg), {})[tsi.action] = tsi.reason
+            elif tsi.action in backward_action:
+                reverse.setdefault(str(tsi.pkg), {})[tsi.action] = tsi.reason
+        return forward, reverse
+
+    # == pre_configure_*: set up action-specific demands ==========================
+    def pre_configure_download(self):
+        # only download subcommand accepts --destdir command line option
+        self.base.conf.cachedir = self.datadir
+        self.base.conf.destdir = self.opts.destdir if self.opts.destdir else None
+        if 'offline-distrosync' == self.opts.command and not self.opts.distro_sync:
+            raise CliError(
+                _("Command 'offline-distrosync' cannot be used with --no-downgrade option"))
+        elif 'offline-upgrade' == self.opts.command:
+            self.opts.distro_sync = False
+
+    def pre_configure_reboot(self):
+        self._set_cachedir()
+
+    def pre_configure_upgrade(self):
+        self._set_cachedir()
+        if self.state.enable_disable_repos:
+            self.opts.repos_ed = self.state.enable_disable_repos
+        self.base.conf.releasever = self.state.target_releasever
+
+    def pre_configure_clean(self):
+        self._set_cachedir()
+
+    # == configure_*: set up action-specific demands ==========================
+
+    def configure_download(self):
+        if 'system-upgrade' == self.opts.command or 'fedup' == self.opts.command:
+            help_url = get_url_from_os_release()
+            if help_url:
+                msg = _('Additional information for System Upgrade: {}')
+                logger.info(msg.format(ucd(help_url)))
+            if self.base._promptWanted():
+                msg = _('Before you continue ensure that your system is fully upgraded by running '
+                        '"dnf --refresh upgrade". Do you want to continue')
+                if self.base.conf.assumeno or not self.base.output.userconfirm(
+                        msg='{} [y/N]: '.format(msg), defaultyes_msg='{} [Y/n]: '.format(msg)):
+                    logger.error(_("Operation aborted."))
+                    sys.exit(1)
+            check_release_ver(self.base.conf, target=self.opts.releasever)
+        elif 'offline-upgrade' == self.opts.command:
+            self.cli._populate_update_security_filter(self.opts)
+
+        self.cli.demands.root_user = True
+        self.cli.demands.resolving = True
+        self.cli.demands.available_repos = True
+        self.cli.demands.sack_activation = True
+        self.cli.demands.freshest_metadata = True
+        # We want to do the depsolve / download / transaction-test, but *not*
+        # run the actual RPM transaction to install the downloaded packages.
+        # Setting the "test" flag makes the RPM transaction a test transaction,
+        # so nothing actually gets installed.
+        # (It also means that we run two test transactions in a row, which is
+        # kind of silly, but that's something for DNF to fix...)
+        self.base.conf.tsflags += ["test"]
+
+    def configure_reboot(self):
+        # FUTURE: add a --debug-shell option to enable debug shell:
+        # systemctl add-wants system-update.target debug-shell.service
+        self.cli.demands.root_user = True
+
+    def configure_upgrade(self):
+        # same as the download, but offline and non-interactive. so...
+        self.cli.demands.root_user = True
+        self.cli.demands.resolving = True
+        self.cli.demands.available_repos = True
+        self.cli.demands.sack_activation = True
+        # use the saved value for --allowerasing, etc.
+        self.opts.distro_sync = self.state.distro_sync
+        if self.state.gpgcheck is not None:
+            self.base.conf.gpgcheck = self.state.gpgcheck
+        if self.state.gpgcheck_repos is not None:
+            for repo in self.base.repos.values():
+                repo.gpgcheck = repo.id in self.state.gpgcheck_repos
+        if self.state.repo_gpgcheck_repos is not None:
+            for repo in self.base.repos.values():
+                repo.repo_gpgcheck = repo.id in self.state.repo_gpgcheck_repos
+        self.base.conf.module_platform_id = self.state.module_platform_id
+        # don't try to get new metadata, 'cuz we're offline
+        self.cli.demands.cacheonly = True
+        # and don't ask any questions (we confirmed all this beforehand)
+        self.base.conf.assumeyes = True
+        self.cli.demands.transaction_display = PlymouthTransactionProgress()
+        # upgrade operation already removes all element that must be removed. Additional removal
+        # could trigger unwanted changes in transaction.
+        self.base.conf.clean_requirements_on_remove = False
+        self.base.conf.install_weak_deps = False
+
+    def configure_clean(self):
+        self.cli.demands.root_user = True
+
+    def configure_log(self):
+        pass
+
+    # == check_*: do any action-specific checks ===============================
+
+    def check_reboot(self):
+        if not self.state.download_status == 'complete':
+            raise CliError(_("system is not ready for upgrade"))
+        self._check_state_version(self.opts.command)
+        if self.state.upgrade_command != self.opts.command:
+            msg = _("the transaction was not prepared for '{command}'. "
+                    "Rerun 'dnf {command} download [OPTIONS]'").format(command=self.opts.command)
+            raise CliError(msg)
+        if os.path.lexists(self.magic_symlink):
+            raise CliError(_("upgrade is already scheduled"))
+        dnf.util.ensure_dir(self.datadir)
+        # FUTURE: checkRPMDBStatus(self.state.download_transaction_id)
+
+    def check_upgrade(self):
+        if not os.path.lexists(self.magic_symlink):
+            logger.info(_("trigger file does not exist. exiting quietly."))
+            raise SystemExit(0)
+        if os.readlink(self.magic_symlink) != self.datadir:
+            logger.info(_("another upgrade tool is running. exiting quietly."))
+            raise SystemExit(0)
+        # Delete symlink ASAP to avoid reboot loops
+        dnf.yum.misc.unlink_f(self.magic_symlink)
+        command = self.state.upgrade_command
+        if not command:
+            command = self.opts.command
+        self._check_state_version(command)
+        if not self.state.upgrade_status == 'ready':
+            msg = _("use 'dnf {command} reboot' to begin the upgrade").format(command=command)
+            raise CliError(msg)
+
+    # == run_*: run the action/prep the transaction ===========================
+
+    def run_prepare(self):
+        # make the magic symlink
+        os.symlink(self.datadir, self.magic_symlink)
+        # set upgrade_status so that the upgrade can run
+        with self.state as state:
+            state.upgrade_status = 'ready'
+
+    def run_reboot(self):
+        self.run_prepare()
+
+        if not self.opts.tid[0] == "reboot":
+            return
+
+        self.state.poweroff_after = self.opts.poweroff_after
+
+        self.log_status(_("Rebooting to perform upgrade."),
+                        REBOOT_REQUESTED_ID)
+
+        # Explicit write since __exit__ doesn't seem to get called when rebooting
+        self.state.write()
+        reboot()
+
+    def run_download(self):
+        # Mark everything in the world for upgrade/sync
+        if self.opts.distro_sync:
+            self.base.distro_sync()
+        else:
+            self.base.upgrade_all()
+
+        if self.opts.command not in ['offline-upgrade', 'offline-distrosync']:
+            # Mark all installed groups and environments for upgrade
+            self.base.read_comps()
+            installed_groups = [g.id for g in self.base.comps.groups if self.base.history.group.get(g.id)]
+            if installed_groups:
+                self.base.env_group_upgrade(installed_groups)
+            installed_environments = [g.id for g in self.base.comps.environments if self.base.history.env.get(g.id)]
+            if installed_environments:
+                self.base.env_group_upgrade(installed_environments)
+
+        with self.state as state:
+            state.download_status = 'downloading'
+            state.target_releasever = self.base.conf.releasever
+            state.destdir = self.base.conf.destdir
+
+    def run_upgrade(self):
+        # change the upgrade status (so we can detect crashed upgrades later)
+        command = ''
+        with self.state as state:
+            state.upgrade_status = 'incomplete'
+            command = state.upgrade_command
+        if command == 'offline-upgrade':
+            msg = _("Starting offline upgrade. This will take a while.")
+        elif command == 'offline-distrosync':
+            msg = _("Starting offline distrosync. This will take a while.")
+        else:
+            msg = _("Starting system upgrade. This will take a while.")
+
+        self.log_status(msg, UPGRADE_STARTED_ID)
+
+        # reset the splash mode and let the user know we're running
+        Plymouth.set_mode()
+        Plymouth.progress(0)
+        Plymouth.message(msg)
+
+        # disable screen blanking
+        disable_blanking()
+
+        self.replay = TransactionReplay(self.base, self.transaction_file)
+        self.replay.run()
+
+    def run_clean(self):
+        logger.info(_("Cleaning up downloaded data..."))
+        # Don't delete persistor, it contains paths for downloaded packages
+        # that are used by dnf during finalizing base to clean them up
+        clear_dir(self.base.conf.cachedir,
+                  [dnf.persistor.TempfilePersistor(self.base.conf.cachedir).db_path])
+        with self.state as state:
+            state.download_status = None
+            state.state_version = None
+            state.upgrade_status = None
+            state.upgrade_command = None
+            state.destdir = None
+
+    def run_log(self):
+        if self.opts.number:
+            show_log(self.opts.number)
+        else:
+            list_logs()
+
+    # == resolved_*: do staff after succesful resolvement =====================
+
+    def resolved_upgrade(self):
+        """Adjust transaction reasons according to stored values"""
+        self.replay.post_transaction()
+
+    # == transaction_*: do stuff after a successful transaction ===============
+
+    def transaction_download(self):
+        transaction = self.base.history.get_current()
+
+        if not transaction.packages():
+            logger.info(_("The system-upgrade transaction is empty, your system is already up-to-date."))
+            return
+
+        data = serialize_transaction(transaction)
+        try:
+            with open(self.transaction_file, "w") as f:
+                json.dump(data, f, indent=4, sort_keys=True)
+                f.write("\n")
+
+            print(_("Transaction saved to {}.").format(self.transaction_file))
+
+        except OSError as e:
+            raise dnf.cli.CliError(_('Error storing transaction: {}').format(str(e)))
+
+        # Okay! Write out the state so the upgrade can use it.
+        system_ver = dnf.rpm.detect_releasever(self.base.conf.installroot)
+        with self.state as state:
+            state.download_status = 'complete'
+            state.state_version = STATE_VERSION
+            state.distro_sync = self.opts.distro_sync
+            state.gpgcheck = self.base.conf.gpgcheck
+            state.gpgcheck_repos = [
+                repo.id for repo in self.base.repos.values() if repo.gpgcheck]
+            state.repo_gpgcheck_repos = [
+                repo.id for repo in self.base.repos.values() if repo.repo_gpgcheck]
+            state.system_releasever = system_ver
+            state.target_releasever = self.base.conf.releasever
+            state.module_platform_id = self.base.conf.module_platform_id
+            state.enable_disable_repos = self.opts.repos_ed
+            state.destdir = self.base.conf.destdir
+            state.upgrade_command = self.opts.command
+
+        msg = DOWNLOAD_FINISHED_MSG.format(command=self.opts.command)
+        logger.info(msg)
+        self.log_status(_("Download finished."), DOWNLOAD_FINISHED_ID)
+
+    def transaction_upgrade(self):
+        if self.state.poweroff_after:
+            upgrade_complete_msg = _("Upgrade complete! Cleaning up and powering off...")
+        else:
+            upgrade_complete_msg = _("Upgrade complete! Cleaning up and rebooting...")
+
+        Plymouth.message(upgrade_complete_msg)
+        self.log_status(upgrade_complete_msg, UPGRADE_FINISHED_ID)
+
+        self.run_clean()
+        if self.opts.tid[0] == "upgrade":
+            reboot(self.state.poweroff_after)
+
+
+class OfflineUpgradeCommand(SystemUpgradeCommand):
+    aliases = ('offline-upgrade',)
+    summary = _("Prepare offline upgrade of the system")
+
+
+class OfflineDistrosyncCommand(SystemUpgradeCommand):
+    aliases = ('offline-distrosync',)
+    summary = _("Prepare offline distrosync of the system")
diff --git a/tests/test_needs_restarting.py b/tests/test_needs_restarting.py
index 0ad70a5..7b629b4 100644
--- a/tests/test_needs_restarting.py
+++ b/tests/test_needs_restarting.py
@@ -20,6 +20,8 @@ from __future__ import absolute_import
 from __future__ import print_function
 from __future__ import unicode_literals
 
+from unittest.mock import patch, Mock
+import dbus
 import needs_restarting
 import tests.support
 
@@ -29,8 +31,6 @@ MM_FILE = '7fc4e1168000-7fc4e1169000 rw-s 1096dd000 00:05 7749' \
           '                      /dev/dri/card0'
 SO_FILE = '30efe06000-30efe07000 r--p 00006000 08:02 139936' \
           '                         /usr/lib64/libSM.so.6.0.1'
-
-
 class NeedsRestartingTest(tests.support.TestCase):
     def test_smap2opened_file(self):
         func = needs_restarting.smap2opened_file
@@ -46,6 +46,17 @@ class NeedsRestartingTest(tests.support.TestCase):
         self.assertTrue(ofile.deleted)
         self.assertEqual(ofile.name, '/usr/lib64/libXfont.so.1.4.1;5408628d')
 
+    def test_get_service_dbus_nounitforpid(self):
+        func = needs_restarting.get_service_dbus
+        # So, This is gonna look kinda screwy unless you are aware of what
+        # this proxies interface is actually doing. The GetUnitByPid function
+        # is normally "dynamically" defined by the get_dbus_method at runtime.
+        # As such there's no actual way to mock it out in any meaningful way
+        # without create=True.
+        with patch( "dbus.proxies.Interface.GetUnitByPID", create=True, side_effect=dbus.DBusException('org.freedesktop.systemd1.NoUnitForPID: PID 1234 does not belong to any loaded unit.') ), \
+             patch( "dbus.SystemBus", return_value=Mock(spec=dbus.Bus) ), \
+             patch( "dbus.bus.BusConnection.__new__", side_effect=dbus.DBusException("Never should hit this exception if mock above works")):
+                 self.assertIsNone(func(1234))
 
 class OpenedFileTest(tests.support.TestCase):
     def test_presumed_name(self):
diff --git a/tests/test_system_upgrade.py b/tests/test_system_upgrade.py
new file mode 100644
index 0000000..769720d
--- /dev/null
+++ b/tests/test_system_upgrade.py
@@ -0,0 +1,518 @@
+# test_system_upgrade.py - unit tests for system-upgrade plugin
+
+import system_upgrade
+
+from system_upgrade import PLYMOUTH, CliError
+
+import os
+import tempfile
+import shutil
+import gettext
+
+from dnf.callback import (PKG_CLEANUP, PKG_DOWNGRADE, PKG_INSTALL,
+                          PKG_OBSOLETE, PKG_REINSTALL, PKG_REMOVE, PKG_UPGRADE,
+                          PKG_VERIFY, TRANS_POST)
+
+import unittest
+
+from tests.support import mock
+patch = mock.patch
+
+
+@patch('system_upgrade.call', return_value=0)
+class PlymouthTestCase(unittest.TestCase):
+    def setUp(self):
+        self.ply = system_upgrade.PlymouthOutput()
+        self.msg = "Hello, plymouth."
+        self.msg_args = (PLYMOUTH, "display-message", "--text", self.msg)
+
+    def test_ping(self, call):
+        self.ply.ping()
+        call.assert_called_once_with((PLYMOUTH, "--ping"))
+        self.assertTrue(self.ply.alive)
+
+    def test_ping_when_dead(self, call):
+        call.return_value = 1
+        self.ply.ping()
+        self.assertFalse(self.ply.alive)
+        call.return_value = 0
+        self.ply.ping()
+        self.assertEqual(call.call_count, 2)
+        self.assertTrue(self.ply.alive)
+
+    def test_message(self, call):
+        self.ply.message(self.msg)
+        call.assert_called_once_with(self.msg_args)
+
+    def test_hide_message(self, call):
+        messages = ("first", "middle", "BONUS", "last")
+        for m in messages:
+            self.ply.message(m)
+
+        def hidem(m):
+            return mock.call((PLYMOUTH, "hide-message", "--text", m))
+
+        def dispm(m):
+            return mock.call((PLYMOUTH, "display-message", "--text", m))
+        m1, m2, m3, m4 = messages
+        call.assert_has_calls([
+            dispm(m1),
+            hidem(m1), dispm(m2),
+            hidem(m2), dispm(m3),
+            hidem(m3), dispm(m4),
+        ])
+
+    def test_message_dupe(self, call):
+        self.ply.message(self.msg)
+        self.ply.message(self.msg)
+        call.assert_called_once_with(self.msg_args)
+
+    def test_message_dead(self, call):
+        call.return_value = 1
+        self.ply.message(self.msg)
+        self.assertFalse(self.ply.alive)
+        self.ply.message("not even gonna bother")
+        call.assert_called_once_with(self.msg_args)
+
+    def test_progress(self, call):
+        self.ply.progress(27)
+        call.assert_called_once_with(
+            (PLYMOUTH, "system-update", "--progress", str(27)))
+
+    @patch('system_upgrade.check_output',
+           return_value="this plymouth does support --system-upgrade mode")
+    def test_mode(self, check_output, call):
+        self.ply.set_mode()
+        call.assert_called_once_with((PLYMOUTH, "change-mode", "--system-upgrade"))
+
+    @patch('system_upgrade.check_output',
+           return_value="this plymouth doesn't support system-upgrade mode")
+    def test_mode_no_system_upgrade_plymouth(self, check_output, call):
+        self.ply.set_mode()
+        call.assert_called_once_with((PLYMOUTH, "change-mode", "--updates"))
+
+    def test_mode_no_plymouth(self, call):
+        call.side_effect = OSError(2, 'No such file or directory')
+        self.ply.set_mode()
+        self.assertFalse(self.ply.alive)
+
+
+@patch('system_upgrade.call', return_value=0)
+class PlymouthTransactionProgressTestCase(unittest.TestCase):
+    actions = (PKG_CLEANUP, PKG_DOWNGRADE, PKG_INSTALL, PKG_OBSOLETE,
+               PKG_REINSTALL, PKG_REMOVE, PKG_UPGRADE, PKG_VERIFY,
+               TRANS_POST)
+
+    # pylint: disable=protected-access
+    def setUp(self):
+        system_upgrade.Plymouth = system_upgrade.PlymouthOutput()
+        self.display = system_upgrade.PlymouthTransactionProgress()
+        self.pkg = "testpackage"
+
+    def test_display(self, call):
+        for action in self.actions:
+            self.display.progress(self.pkg, action, 0, 100, 1, 1000)
+            msg = self.display._fmt_event(self.pkg, action, 1, 1000)
+            # updating plymouth display means two plymouth calls
+            call.assert_has_calls([
+                mock.call((PLYMOUTH, "system-update", "--progress", "0")),
+                mock.call((PLYMOUTH, "display-message", "--text", msg))
+            ], any_order=True)
+
+    def test_filter_calls(self, call):
+        action = PKG_INSTALL
+        # first display update -> set percentage and text
+        self.display.progress(self.pkg, action, 0, 100, 1, 1000)
+        msg1 = self.display._fmt_event(self.pkg, action, 1, 1000)
+        call.assert_has_calls([
+            mock.call((PLYMOUTH, "system-update", "--progress", "0")),
+            mock.call((PLYMOUTH, "display-message", "--text", msg1)),
+        ])
+
+        # event progress on the same transaction item.
+        # no new calls to plymouth because the percentage and text don't change
+        for te_cur in range(1, 100):
+            self.display.progress(self.pkg, action, te_cur, 100, 1, 1000)
+        call.assert_has_calls([
+            mock.call((PLYMOUTH, "system-update", "--progress", "0")),
+            mock.call((PLYMOUTH, "display-message", "--text", msg1)),
+        ])
+
+        # new item: new message ("[2/1000] ..."), but percentage still 0..
+        self.display.progress(self.pkg, action, 0, 100, 2, 1000)
+        # old message hidden, new message displayed. no new percentage.
+        msg2 = self.display._fmt_event(self.pkg, action, 2, 1000)
+        call.assert_has_calls([
+            mock.call((PLYMOUTH, "system-update", "--progress", "0")),
+            mock.call((PLYMOUTH, "display-message", "--text", msg1)),
+            mock.call((PLYMOUTH, "hide-message", "--text", msg1)),
+            mock.call((PLYMOUTH, "display-message", "--text", msg2)),
+        ])
+
+
+TESTLANG = "zh_CN"
+TESTLANG_MO = "po/%s.mo" % TESTLANG
+
+
+@unittest.skipUnless(os.path.exists(TESTLANG_MO), "make %s first" %
+                     TESTLANG_MO)
+# @unittest.skip("There is no translation yet to system-upgrade")
+class I18NTestCaseBase(unittest.TestCase):
+    @classmethod
+    @unittest.skip("There is no translation yet to system-upgrade")
+    def setUpClass(cls):
+        cls.localedir = tempfile.mkdtemp(prefix='system_upgrade_test_i18n-')
+        cls.msgdir = os.path.join(cls.localedir, TESTLANG + "/LC_MESSAGES")
+        cls.msgfile = "dnf-plugins-extras" + ".mo"
+        os.makedirs(cls.msgdir)
+        shutil.copy2(TESTLANG_MO, os.path.join(cls.msgdir, cls.msgfile))
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.localedir)
+
+    def setUp(self):
+        self.t = gettext.translation("dnf-plugins-extras", self.localedir,
+                                     languages=[TESTLANG], fallback=True)
+        self.gettext = self.t.gettext
+
+
+class I18NTestCase(I18NTestCaseBase):
+    @unittest.skip("There is no translation yet to system-upgrade")
+    def test_selftest(self):
+        self.assertIn(self.msgfile, os.listdir(self.msgdir))
+        self.assertIn(TESTLANG, os.listdir(self.localedir))
+        t = gettext.translation("dnf-plugins-extras", self.localedir,
+                                languages=[TESTLANG], fallback=False)
+        info = t.info()
+        self.assertIn("language", info)
+        self.assertEqual(info["language"], TESTLANG.replace("_", "-"))
+
+    @unittest.skip("There is no translation yet to system-upgrade")
+    def test_fallback(self):
+        msg = "THIS STRING DOES NOT EXIST"
+        trans_msg = self.gettext(msg)
+        self.assertEqual(msg, trans_msg)
+
+    @unittest.skip("There is no translation yet to system-upgrade")
+    def test_translation(self):
+        msg = "the color of the sky"
+        trans_msg = self.gettext(msg)
+        self.assertNotEqual(msg, trans_msg)
+
+
+class StateTestCase(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.statedir = tempfile.mkdtemp(prefix="system_upgrade_test_state-")
+        cls.StateClass = system_upgrade.State
+
+    def setUp(self):
+        self.state = self.StateClass(os.path.join(self.statedir, "state"))
+
+    def test_bool_value(self):
+        with self.state:
+            self.state.distro_sync = True
+        del self.state
+        self.state = self.StateClass(os.path.join(self.statedir, "state"))
+        self.assertIs(self.state.distro_sync, True)
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.statedir)
+
+
+class UtilTestCase(unittest.TestCase):
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp(prefix='system_upgrade_test_util-')
+        self.dirs = ["dir1", "dir2"]
+        self.files = ["file1", "dir2/file2"]
+        for d in self.dirs:
+            os.makedirs(os.path.join(self.tmpdir, d))
+        for f in self.files:
+            with open(os.path.join(self.tmpdir, f), 'wt') as fobj:
+                fobj.write("hi there\n")
+
+    def test_self_test(self):
+        for d in self.dirs:
+            self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, d)))
+        for f in self.files:
+            self.assertTrue(os.path.exists(os.path.join(self.tmpdir, f)))
+
+    def test_clear_dir(self):
+        self.assertTrue(os.path.isdir(self.tmpdir))
+        system_upgrade.clear_dir(self.tmpdir)
+        self.assertTrue(os.path.isdir(self.tmpdir))
+        self.assertEqual(os.listdir(self.tmpdir), [])
+
+    def tearDown(self):
+        shutil.rmtree(self.tmpdir)
+
+
+class CommandTestCaseBase(unittest.TestCase):
+    def setUp(self):
+        self.datadir = tempfile.mkdtemp(prefix="system_upgrade_test_datadir-")
+        self.installroot = tempfile.TemporaryDirectory(prefix="system_upgrade_test_installroot-")
+        system_upgrade.SystemUpgradeCommand.DATADIR = self.datadir
+        self.cli = mock.MagicMock()
+        # the installroot is not strictly necessary for the test, but
+        # releasever detection is accessing host system files without it, and
+        # this fails on permissions in COPR srpm builds (e.g. from rpm-gitoverlay)
+        self.cli.base.conf.installroot = self.installroot.name
+        self.command = system_upgrade.SystemUpgradeCommand(cli=self.cli)
+        self.command.base.conf.cachedir = os.path.join(self.datadir, "cache")
+        self.command.base.conf.destdir = None
+
+    def tearDown(self):
+        shutil.rmtree(self.datadir)
+        self.installroot.cleanup()
+
+
+class CommandTestCase(CommandTestCaseBase):
+    # self-tests for the command test cases
+    def test_state(self):
+        # initial state: no status
+        self.assertIsNone(self.command.state.download_status)
+        self.assertIsNone(self.command.state.upgrade_status)
+
+
+class CleanCommandTestCase(CommandTestCaseBase):
+    def test_pre_configure_clean(self):
+        with self.command.state as state:
+            state.destdir = "/grape/wine"
+        self.command.pre_configure_clean()
+        self.assertEqual(self.command.base.conf.destdir, "/grape/wine")
+
+    def test_configure_clean(self):
+        self.cli.demands.root_user = None
+        self.command.configure_clean()
+        self.assertTrue(self.cli.demands.root_user)
+
+    def test_run_clean(self):
+        with self.command.state as state:
+            state.download_status = "complete"
+            state.upgrade_status = "ready"
+        # make sure the datadir and state info is set up OK
+        self.assertEqual(self.command.state.download_status, "complete")
+        self.assertEqual(self.command.state.upgrade_status, "ready")
+        # run cleanup
+        self.command.run_clean()
+        # state is cleared
+        self.assertIsNone(self.command.state.download_status)
+        self.assertIsNone(self.command.state.upgrade_status)
+
+
+class RebootCheckCommandTestCase(CommandTestCaseBase):
+    def setUp(self):
+        super(RebootCheckCommandTestCase, self).setUp()
+        self.magic_symlink = self.datadir + '/symlink'
+        self.command.magic_symlink = self.magic_symlink
+
+    def test_pre_configure_reboot(self):
+        with self.command.state as state:
+            state.destdir = "/grape/wine"
+        self.command.pre_configure_reboot()
+        self.assertEqual(self.command.base.conf.destdir, "/grape/wine")
+
+    def test_configure_reboot(self):
+        self.cli.demands.root_user = None
+        self.command.configure_reboot()
+        self.assertTrue(self.cli.demands.root_user)
+
+    def check_reboot(self, status='complete', lexists=False, command='system-upgrade',
+                     state_command='system-upgrade'):
+        with patch('system_upgrade.os.path.lexists') as lexists_func:
+            self.command.state.state_version = 3
+            self.command.state.download_status = status
+            self.command.opts = mock.MagicMock()
+            self.command.opts.command = command
+            self.command.state.upgrade_command = state_command
+            lexists_func.return_value = lexists
+            self.command.check_reboot()
+
+    def test_check_reboot_ok(self):
+        self.check_reboot(status='complete', lexists=False)
+
+    def test_check_reboot_different_command(self):
+        with self.assertRaises(CliError):
+            self.check_reboot(status='complete', lexists=False, command='system-upgrade',
+                              state_command='offline-upgrade')
+
+    def test_check_reboot_no_download(self):
+        with self.assertRaises(CliError):
+            self.check_reboot(status=None, lexists=False)
+
+    def test_check_reboot_link_exists(self):
+        with self.assertRaises(CliError):
+            self.check_reboot(status='complete', lexists=True)
+
+    def test_run_prepare(self):
+        self.command.run_prepare()
+        self.assertEqual(os.readlink(self.magic_symlink), self.datadir)
+        self.assertEqual(self.command.state.upgrade_status, 'ready')
+
+    @patch('system_upgrade.SystemUpgradeCommand.run_prepare')
+    @patch('system_upgrade.SystemUpgradeCommand.log_status')
+    @patch('system_upgrade.reboot')
+    def test_run_reboot(self, reboot, log_status, run_prepare):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.poweroff_after = False
+        self.command.opts.tid = ["reboot"]
+        self.command.run_reboot()
+        run_prepare.assert_called_once_with()
+        self.assertEqual(system_upgrade.REBOOT_REQUESTED_ID,
+                         log_status.call_args[0][1])
+        self.assertTrue(reboot.called)
+
+    @patch('system_upgrade.SystemUpgradeCommand.run_prepare')
+    @patch('system_upgrade.SystemUpgradeCommand.log_status')
+    @patch('system_upgrade.reboot')
+    def test_reboot_poweroff_after(self, reboot, log_status, run_prepare):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.tid = ["reboot"]
+        self.command.opts.poweroff_after = True
+        self.command.run_reboot()
+        run_prepare.assert_called_with()
+        self.assertEqual(system_upgrade.REBOOT_REQUESTED_ID,
+                         log_status.call_args[0][1])
+        self.assertTrue(self.command.state.poweroff_after)
+        self.assertTrue(reboot.called)
+
+
+    @patch('system_upgrade.SystemUpgradeCommand.run_prepare')
+    @patch('system_upgrade.SystemUpgradeCommand.log_status')
+    @patch('system_upgrade.reboot')
+    def test_reboot_prepare_only(self, reboot, log_status, run_prepare):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.tid = [None]
+        self.command.run_reboot()
+        run_prepare.assert_called_once_with()
+        self.assertFalse(log_status.called)
+        self.assertFalse(reboot.called)
+
+
+class DownloadCommandTestCase(CommandTestCase):
+    def test_pre_configure_download_default(self):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.destdir = None
+        self.command.base.conf.destdir = None
+        self.command.pre_configure_download()
+        self.assertEqual(self.command.base.conf.cachedir, self.datadir)
+
+    def test_pre_configure_download_destdir(self):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.destdir = self.datadir
+        self.command.pre_configure_download()
+        self.assertEqual(self.command.base.conf.destdir, self.datadir)
+
+    def test_configure_download(self):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.tid = "download"
+        self.command.configure()
+        self.assertTrue(self.cli.demands.root_user)
+        self.assertTrue(self.cli.demands.resolving)
+        self.assertTrue(self.cli.demands.sack_activation)
+        self.assertTrue(self.cli.demands.available_repos)
+
+    def test_transaction_download(self):
+        pkg = mock.MagicMock()
+        repo = mock.MagicMock()
+        repo.id = 'test'
+        pkg.name = "kernel"
+        pkg.repo = repo
+        self.cli.base.transaction.install_set = [pkg]
+        self.command.opts = mock.MagicMock()
+        self.command.opts.distro_sync = True
+        self.command.opts.command = "system_upgrade"
+        self.command.opts.repos_ed = []
+        self.cli.demands.allow_erasing = "allow_erasing"
+        self.command.base.conf.best = True
+        self.command.base.conf.releasever = "35"
+        self.command.base.conf.gpgcheck = True
+        self.command.opts.destdir = self.datadir
+        self.command.base.conf.install_weak_deps = True
+        self.command.base.conf.module_platform_id = ''
+        self.command.pre_configure_download()
+        self.command.transaction_download()
+        with system_upgrade.State(self.command.state.statefile) as state:
+            self.assertEqual(state.state_version, system_upgrade.STATE_VERSION)
+            self.assertEqual(state.download_status, "complete")
+            self.assertEqual(state.distro_sync, True)
+            self.assertEqual(state.destdir, self.datadir)
+            self.assertEqual(state.upgrade_command, "system_upgrade")
+
+    def test_transaction_download_offline_upgrade(self):
+        pkg = mock.MagicMock()
+        repo = mock.MagicMock()
+        repo.id = 'test'
+        pkg.name = "kernel"
+        pkg.repo = repo
+        self.cli.base.transaction.install_set = [pkg]
+        self.command.opts = mock.MagicMock()
+        self.command.opts.distro_sync = True
+        self.command.opts.command = "offline-upgrade"
+        self.command.opts.repos_ed = []
+        self.cli.demands.allow_erasing = "allow_erasing"
+        self.command.base.conf.best = True
+        self.command.base.conf.releasever = "35"
+        self.command.base.conf.gpgcheck = True
+        self.command.opts.destdir = self.datadir
+        self.command.base.conf.install_weak_deps = True
+        self.command.base.conf.module_platform_id = ''
+        self.command.pre_configure_download()
+        self.command.transaction_download()
+        with system_upgrade.State(self.command.state.statefile) as state:
+            self.assertEqual(state.download_status, "complete")
+            self.assertEqual(state.distro_sync, False)
+            self.assertEqual(state.destdir, self.datadir)
+            self.assertEqual(state.upgrade_command, "offline-upgrade")
+
+
+class UpgradeCommandTestCase(CommandTestCase):
+    def test_pre_configure_upgrade(self):
+        with self.command.state as state:
+            state.destdir = "/grape/wine"
+            state.target_releasever = "35"
+        self.command.pre_configure_upgrade()
+        self.assertEqual(self.command.base.conf.destdir, "/grape/wine")
+        self.assertEqual(self.command.base.conf.releasever, "35")
+
+    def test_configure_upgrade(self):
+        # write state like download would have
+        with self.command.state as state:
+            state.download_status = "complete"
+            state.distro_sync = True
+            state.allow_erasing = True
+            state.best = True
+        # okay, now configure upgrade
+        self.command.opts = mock.MagicMock()
+        self.command.opts.tid = "upgrade"
+        self.command.configure()
+        # did we reset the depsolving flags?
+        self.assertTrue(self.command.opts.distro_sync)
+        self.assertTrue(self.cli.demands.allow_erasing)
+        self.assertTrue(self.command.base.conf.best)
+        # are we on autopilot?
+        self.assertTrue(self.command.base.conf.assumeyes)
+        self.assertTrue(self.cli.demands.cacheonly)
+
+
+class LogCommandTestCase(CommandTestCase):
+    def test_configure_log(self):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.tid = "log"
+        self.command.configure()
+
+    def test_run_log_list(self):
+        self.command.opts = mock.MagicMock()
+        self.command.opts.number = None
+        with patch('system_upgrade.list_logs') as list_logs:
+            self.command.run_log()
+        list_logs.assert_called_once_with()
+
+    def test_run_log_prev(self):
+        with patch('system_upgrade.show_log') as show_log:
+            self.command.opts = mock.MagicMock()
+            self.command.opts.number = -2
+            self.command.run_log()
+        show_log.assert_called_once_with(-2)

More details

Full run details

Historical runs