diff --git a/heat_cfntools/cfntools/cfn_helper.py b/heat_cfntools/cfntools/cfn_helper.py index 42fab8d..d3de01c 100644 --- a/heat_cfntools/cfntools/cfn_helper.py +++ b/heat_cfntools/cfntools/cfn_helper.py @@ -290,6 +290,20 @@ class RpmHelper(object): command = CommandRunner(cmd_str).run() return command.status == 0 + @classmethod + def dnf_package_available(cls, pkg): + """Indicates whether pkg is available via dnf. + + Arguments: + pkg -- A package name (with optional version and release spec). + e.g., httpd + e.g., httpd-2.2.22 + e.g., httpd-2.2.22-1.fc21 + """ + cmd_str = "dnf -y --showduplicates list available %s" % pkg + command = CommandRunner(cmd_str).run() + return command.status == 0 + @classmethod def zypper_package_available(cls, pkg): """Indicates whether pkg is available via zypper. @@ -305,8 +319,8 @@ class RpmHelper(object): return command.status == 0 @classmethod - def install(cls, packages, rpms=True, zypper=False): - """Installs (or upgrades) a set of packages via RPM or via Yum. + def install(cls, packages, rpms=True, zypper=False, dnf=False): + """Installs (or upgrades) packages via RPM, yum, dnf, or zypper. Arguments: packages -- a list of packages to install @@ -320,6 +334,11 @@ class RpmHelper(object): - pkg name with version spec (httpd-2.2.22), or - pkg name with version-release spec (httpd-2.2.22-1.fc16) + zypper -- if True: + * overrides use of yum, use zypper instead + dnf -- if True: + * overrides use of yum, use dnf instead + * packages must be in same format as yum pkg list """ if rpms: cmd = "rpm -U --force --nosignature " @@ -329,6 +348,11 @@ class RpmHelper(object): cmd = "zypper -n install " cmd += " ".join(packages) LOG.info("Installing packages: %s" % cmd) + elif dnf: + # use dnf --best to upgrade outdated-but-installed packages + cmd = "dnf -y --best install " + cmd += " ".join(packages) + LOG.info("Installing packages: %s" % cmd) else: cmd = "yum -y install " cmd += " ".join(packages) @@ -338,8 +362,8 @@ class RpmHelper(object): LOG.warn("Failed to install packages: %s" % cmd) @classmethod - def downgrade(cls, packages, rpms=True, zypper=False): - """Downgrades a set of packages via RPM or via Yum. + def downgrade(cls, packages, rpms=True, zypper=False, dnf=False): + """Downgrades a set of packages via RPM, yum, dnf, or zypper. Arguments: packages -- a list of packages to downgrade @@ -352,6 +376,8 @@ class RpmHelper(object): - pkg name with version spec (httpd-2.2.22), or - pkg name with version-release spec (httpd-2.2.22-1.fc16) + dnf -- if True: + * Use dnf instead of RPM/yum """ if rpms: cls.install(packages) @@ -362,6 +388,13 @@ class RpmHelper(object): command = CommandRunner(cmd).run() if command.status: LOG.warn("Failed to downgrade packages: %s" % cmd) + elif dnf: + cmd = "dnf -y downgrade " + cmd += " ".join(packages) + LOG.info("Downgrading packages: %s", cmd) + command = CommandRunner(cmd).run() + if command.status: + LOG.warn("Failed to downgrade packages: %s" % cmd) else: cmd = "yum -y downgrade " cmd += " ".join(packages) @@ -374,7 +407,7 @@ class RpmHelper(object): class PackagesHandler(object): _packages = {} - _package_order = ["dpkg", "rpm", "apt", "yum"] + _package_order = ["dpkg", "rpm", "apt", "yum", "dnf"] @staticmethod def _pkgsort(pkg1, pkg2): @@ -460,6 +493,51 @@ class PackagesHandler(object): if downgrades: RpmHelper.downgrade(downgrades, zypper=True) + def _handle_dnf_packages(self, packages): + """Handle installation, upgrade, or downgrade of packages via dnf. + + Arguments: + packages -- a package entries map of the form: + "pkg_name" : "version", + "pkg_name" : ["v1", "v2"], + "pkg_name" : [] + + For each package entry: + * if no version is supplied and the package is already installed, do + nothing + * if no version is supplied and the package is _not_ already + installed, install it + * if a version string is supplied, and the package is already + installed, determine whether to downgrade or upgrade (or do nothing + if version matches installed package) + * if a version array is supplied, choose the highest version from the + array and follow same logic for version string above + """ + # collect pkgs for batch processing at end + installs = [] + downgrades = [] + for pkg_name, versions in packages.iteritems(): + ver = RpmHelper.newest_rpm_version(versions) + pkg = "%s-%s" % (pkg_name, ver) if ver else pkg_name + if RpmHelper.rpm_package_installed(pkg): + # FIXME:print non-error, but skipping pkg + pass + elif not RpmHelper.dnf_package_available(pkg): + LOG.warn("Skipping package '%s'. Not available via yum" % pkg) + elif not ver: + installs.append(pkg) + else: + current_ver = RpmHelper.rpm_package_version(pkg) + rc = RpmHelper.compare_rpm_versions(current_ver, ver) + if rc < 0: + installs.append(pkg) + elif rc > 0: + downgrades.append(pkg) + if installs: + RpmHelper.install(installs, rpms=False, dnf=True) + if downgrades: + RpmHelper.downgrade(downgrades, rpms=False, dnf=True) + def _handle_yum_packages(self, packages): """Handle installation, upgrade, or downgrade of packages via yum. @@ -480,6 +558,17 @@ class PackagesHandler(object): * if a version array is supplied, choose the highest version from the array and follow same logic for version string above """ + + cmd = CommandRunner("which yum").run() + if cmd.status == 1: + # yum not available, use DNF if available + self._handle_dnf_packages(packages) + return + elif cmd.status == 127: + # `which` command not found + LOG.info("`which` not found. Using yum without checking if dnf " + "is available") + # collect pkgs for batch processing at end installs = [] downgrades = [] @@ -531,6 +620,7 @@ class PackagesHandler(object): # map of function pointers to handle different package managers _package_handlers = {"yum": _handle_yum_packages, + "dnf": _handle_dnf_packages, "zypper": _handle_zypper_packages, "rpm": _handle_rpm_packages, "apt": _handle_apt_packages, @@ -552,6 +642,7 @@ class PackagesHandler(object): * rpm * apt * yum + * dnf """ if not self._packages: return diff --git a/heat_cfntools/tests/test_cfn_helper.py b/heat_cfntools/tests/test_cfn_helper.py index 42fe7e2..85b41da 100644 --- a/heat_cfntools/tests/test_cfn_helper.py +++ b/heat_cfntools/tests/test_cfn_helper.py @@ -82,6 +82,9 @@ class TestPackages(MockPopenTestCase): def test_yum_install(self): install_list = [] + self.mock_unorder_cmd_run( + ['su', 'root', '-c', 'which yum']) \ + .AndReturn(FakePOpen(returncode=0)) for pack in ('httpd', 'wordpress', 'mysql-server'): self.mock_unorder_cmd_run( ['su', 'root', '-c', 'rpm -q %s' % pack]) \ @@ -110,6 +113,71 @@ class TestPackages(MockPopenTestCase): cfn_helper.PackagesHandler(packages).apply_packages() self.m.VerifyAll() + def test_dnf_install_yum_unavailable(self): + install_list = [] + self.mock_unorder_cmd_run( + ['su', 'root', '-c', 'which yum']) \ + .AndReturn(FakePOpen(returncode=1)) + pkgs = ('httpd', 'mysql-server', 'wordpress') + for pack in pkgs: + self.mock_unorder_cmd_run( + ['su', 'root', '-c', 'rpm -q %s' % pack]) \ + .AndReturn(FakePOpen(returncode=1)) + self.mock_unorder_cmd_run( + ['su', 'root', '-c', + 'dnf -y --showduplicates list available %s' % pack]) \ + .AndReturn(FakePOpen(returncode=0)) + install_list.append(pack) + + # This mock call corresponding to 'su root -c dnf -y list upgrades .*' + # and 'su root -c dnf -y install .*' + # But there is no way to ignore the order of the parameters, so only + # check the return value. + self.mock_cmd_run(mox.IgnoreArg()).AndReturn(FakePOpen( + returncode=0)) + + self.m.ReplayAll() + packages = { + "yum": { + "mysql-server": [], + "httpd": [], + "wordpress": [] + } + } + + cfn_helper.PackagesHandler(packages).apply_packages() + self.m.VerifyAll() + + def test_dnf_install(self): + install_list = [] + for pack in ('httpd', 'wordpress', 'mysql-server'): + self.mock_unorder_cmd_run( + ['su', 'root', '-c', 'rpm -q %s' % pack]) \ + .AndReturn(FakePOpen(returncode=1)) + self.mock_unorder_cmd_run( + ['su', 'root', '-c', + 'dnf -y --showduplicates list available %s' % pack]) \ + .AndReturn(FakePOpen(returncode=0)) + install_list.append(pack) + + # This mock call corresponding to 'su root -c dnf -y --best install .*' + # But there is no way to ignore the order of the parameters, so only + # check the return value. + self.mock_cmd_run(mox.IgnoreArg()).AndReturn(FakePOpen( + returncode=0)) + + self.m.ReplayAll() + packages = { + "dnf": { + "mysql-server": [], + "httpd": [], + "wordpress": [] + } + } + + cfn_helper.PackagesHandler(packages).apply_packages() + self.m.VerifyAll() + def test_zypper_install(self): install_list = [] for pack in ('httpd', 'wordpress', 'mysql-server'):