Revert "Revert "oran-shell-release: release image for F""
[pti/rtp.git] / meta-starlingx / meta-stx-flock / stx-update / files / 0006-Migrate-patch-agent-to-use-DNF-for-swmgmt.patch
1 diff --git a/cgcs-patch/cgcs-patch/cgcs_patch/patch_agent.py b/cgcs-patch/cgcs-patch/cgcs_patch/patch_agent.py
2 index 3abd891..d8bc375 100644
3 --- a/cgcs-patch/cgcs-patch/cgcs_patch/patch_agent.py
4 +++ b/cgcs-patch/cgcs-patch/cgcs_patch/patch_agent.py
5 @@ -5,22 +5,26 @@ SPDX-License-Identifier: Apache-2.0
6  
7  """
8  
9 -import os
10 -import time
11 -import socket
12 +import dnf
13 +import dnf.callback
14 +import dnf.comps
15 +import dnf.exceptions
16 +import dnf.rpm
17 +import dnf.sack
18 +import dnf.transaction
19  import json
20 -import select
21 -import subprocess
22 +import libdnf.transaction
23 +import os
24  import random
25  import requests
26 -import xml.etree.ElementTree as ElementTree
27 -import rpm
28 -import sys
29 -import yaml
30 +import select
31  import shutil
32 +import socket
33 +import subprocess
34 +import sys
35 +import time
36  
37  from cgcs_patch.patch_functions import configure_logging
38 -from cgcs_patch.patch_functions import parse_pkgver
39  from cgcs_patch.patch_functions import LOG
40  import cgcs_patch.config as cfg
41  from cgcs_patch.base import PatchService
42 @@ -50,19 +54,13 @@ pa = None
43  
44  http_port_real = http_port
45  
46 -# Smart commands
47 -smart_cmd = ["/usr/bin/smart"]
48 -smart_quiet = smart_cmd + ["--quiet"]
49 -smart_update = smart_quiet + ["update"]
50 -smart_newer = smart_quiet + ["newer"]
51 -smart_orphans = smart_quiet + ["query", "--orphans", "--show-format", "$name\n"]
52 -smart_query = smart_quiet + ["query"]
53 -smart_query_repos = smart_quiet + ["query", "--channel=base", "--channel=updates"]
54 -smart_install_cmd = smart_cmd + ["install", "--yes", "--explain"]
55 -smart_remove_cmd = smart_cmd + ["remove", "--yes", "--explain"]
56 -smart_query_installed = smart_quiet + ["query", "--installed", "--show-format", "$name $version\n"]
57 -smart_query_base = smart_quiet + ["query", "--channel=base", "--show-format", "$name $version\n"]
58 -smart_query_updates = smart_quiet + ["query", "--channel=updates", "--show-format", "$name $version\n"]
59 +# DNF commands
60 +dnf_cmd = ['/usr/bin/dnf']
61 +dnf_quiet = dnf_cmd + ['--quiet']
62 +dnf_makecache = dnf_quiet + ['makecache',
63 +                             '--disablerepo="*"',
64 +                             '--enablerepo', 'platform-base',
65 +                             '--enablerepo', 'platform-updates']
66  
67  
68  def setflag(fname):
69 @@ -123,10 +121,6 @@ class PatchMessageHelloAgent(messages.PatchMessage):
70      def handle(self, sock, addr):
71          # Send response
72  
73 -        # Run the smart config audit
74 -        global pa
75 -        pa.timed_audit_smart_config()
76 -
77          #
78          # If a user tries to do a host-install on an unlocked node,
79          # without bypassing the lock check (either via in-service
80 @@ -289,6 +283,46 @@ class PatchMessageAgentInstallResp(messages.PatchMessage):
81          resp.send(sock)
82  
83  
84 +class PatchAgentDnfTransLogCB(dnf.callback.TransactionProgress):
85 +    def __init__(self):
86 +        dnf.callback.TransactionProgress.__init__(self)
87 +
88 +        self.log_prefix = 'dnf trans'
89 +
90 +    def progress(self, package, action, ti_done, ti_total, ts_done, ts_total):
91 +        if action in dnf.transaction.ACTIONS:
92 +            action_str = dnf.transaction.ACTIONS[action]
93 +        elif action == dnf.transaction.TRANS_POST:
94 +            action_str = 'Post transaction'
95 +        else:
96 +            action_str = 'unknown(%d)' % action
97 +
98 +        if ti_done is not None:
99 +            # To reduce the volume of logs, only log 0% and 100%
100 +            if ti_done == 0 or ti_done == ti_total:
101 +                LOG.info('%s PROGRESS %s: %s %0.1f%% [%s/%s]',
102 +                         self.log_prefix, action_str, package,
103 +                         (ti_done * 100 / ti_total),
104 +                         ts_done, ts_total)
105 +        else:
106 +            LOG.info('%s PROGRESS %s: %s [%s/%s]',
107 +                     self.log_prefix, action_str, package, ts_done, ts_total)
108 +
109 +    def filelog(self, package, action):
110 +        if action in dnf.transaction.FILE_ACTIONS:
111 +            msg = '%s: %s' % (dnf.transaction.FILE_ACTIONS[action], package)
112 +        else:
113 +            msg = '%s: %s' % (package, action)
114 +        LOG.info('%s FILELOG %s', self.log_prefix, msg)
115 +
116 +    def scriptout(self, msgs):
117 +        if msgs:
118 +            LOG.info("%s SCRIPTOUT :\n%s", self.log_prefix, msgs)
119 +
120 +    def error(self, message):
121 +        LOG.error("%s ERROR: %s", self.log_prefix, message)
122 +
123 +
124  class PatchAgent(PatchService):
125      def __init__(self):
126          PatchService.__init__(self)
127 @@ -298,9 +332,14 @@ class PatchAgent(PatchService):
128          self.listener = None
129          self.changes = False
130          self.installed = {}
131 +        self.installed_dnf = []
132          self.to_install = {}
133 +        self.to_install_dnf = []
134 +        self.to_downgrade_dnf = []
135          self.to_remove = []
136 +        self.to_remove_dnf = []
137          self.missing_pkgs = []
138 +        self.missing_pkgs_dnf = []
139          self.patch_op_counter = 0
140          self.node_is_patched = os.path.exists(node_is_patched_file)
141          self.node_is_patched_timestamp = 0
142 @@ -308,6 +347,7 @@ class PatchAgent(PatchService):
143          self.state = constants.PATCH_AGENT_STATE_IDLE
144          self.last_config_audit = 0
145          self.rejection_timestamp = 0
146 +        self.dnfb = None
147  
148          # Check state flags
149          if os.path.exists(patch_installing_file):
150 @@ -343,289 +383,40 @@ class PatchAgent(PatchService):
151          self.listener.bind(('', self.port))
152          self.listener.listen(2)  # Allow two connections, for two controllers
153  
154 -    def audit_smart_config(self):
155 -        LOG.info("Auditing smart configuration")
156 -
157 -        # Get the current channel config
158 -        try:
159 -            output = subprocess.check_output(smart_cmd +
160 -                                             ["channel", "--yaml"],
161 -                                             stderr=subprocess.STDOUT)
162 -            config = yaml.load(output)
163 -        except subprocess.CalledProcessError as e:
164 -            LOG.exception("Failed to query channels")
165 -            LOG.error("Command output: %s", e.output)
166 -            return False
167 -        except Exception:
168 -            LOG.exception("Failed to query channels")
169 -            return False
170 -
171 -        expected = [{'channel': 'rpmdb',
172 -                     'type': 'rpm-sys',
173 -                     'name': 'RPM Database',
174 -                     'baseurl': None},
175 -                    {'channel': 'base',
176 -                     'type': 'rpm-md',
177 -                     'name': 'Base',
178 -                     'baseurl': "http://controller:%s/feed/rel-%s" % (http_port_real, SW_VERSION)},
179 -                    {'channel': 'updates',
180 -                     'type': 'rpm-md',
181 -                     'name': 'Patches',
182 -                     'baseurl': "http://controller:%s/updates/rel-%s" % (http_port_real, SW_VERSION)}]
183 -
184 -        updated = False
185 -
186 -        for item in expected:
187 -            channel = item['channel']
188 -            ch_type = item['type']
189 -            ch_name = item['name']
190 -            ch_baseurl = item['baseurl']
191 -
192 -            add_channel = False
193 -
194 -            if channel in config:
195 -                # Verify existing channel config
196 -                if (config[channel].get('type') != ch_type or
197 -                        config[channel].get('name') != ch_name or
198 -                        config[channel].get('baseurl') != ch_baseurl):
199 -                    # Config is invalid
200 -                    add_channel = True
201 -                    LOG.warning("Invalid smart config found for %s", channel)
202 -                    try:
203 -                        output = subprocess.check_output(smart_cmd +
204 -                                                         ["channel", "--yes",
205 -                                                          "--remove", channel],
206 -                                                         stderr=subprocess.STDOUT)
207 -                    except subprocess.CalledProcessError as e:
208 -                        LOG.exception("Failed to configure %s channel", channel)
209 -                        LOG.error("Command output: %s", e.output)
210 -                        return False
211 -            else:
212 -                # Channel is missing
213 -                add_channel = True
214 -                LOG.warning("Channel %s is missing from config", channel)
215 -
216 -            if add_channel:
217 -                LOG.info("Adding channel %s", channel)
218 -                cmd_args = ["channel", "--yes", "--add", channel,
219 -                            "type=%s" % ch_type,
220 -                            "name=%s" % ch_name]
221 -                if ch_baseurl is not None:
222 -                    cmd_args += ["baseurl=%s" % ch_baseurl]
223 -
224 -                try:
225 -                    output = subprocess.check_output(smart_cmd + cmd_args,
226 -                                                     stderr=subprocess.STDOUT)
227 -                except subprocess.CalledProcessError as e:
228 -                    LOG.exception("Failed to configure %s channel", channel)
229 -                    LOG.error("Command output: %s", e.output)
230 -                    return False
231 -
232 -                updated = True
233 -
234 -        # Validate the smart config
235 -        try:
236 -            output = subprocess.check_output(smart_cmd +
237 -                                             ["config", "--yaml"],
238 -                                             stderr=subprocess.STDOUT)
239 -            config = yaml.load(output)
240 -        except subprocess.CalledProcessError as e:
241 -            LOG.exception("Failed to query smart config")
242 -            LOG.error("Command output: %s", e.output)
243 -            return False
244 -        except Exception:
245 -            LOG.exception("Failed to query smart config")
246 -            return False
247 -
248 -        # Check for the rpm-nolinktos flag
249 -        nolinktos = 'rpm-nolinktos'
250 -        if config.get(nolinktos) is not True:
251 -            # Set the flag
252 -            LOG.warning("Setting %s option", nolinktos)
253 -            try:
254 -                output = subprocess.check_output(smart_cmd +
255 -                                                 ["config", "--set",
256 -                                                  "%s=true" % nolinktos],
257 -                                                 stderr=subprocess.STDOUT)
258 -            except subprocess.CalledProcessError as e:
259 -                LOG.exception("Failed to configure %s option", nolinktos)
260 -                LOG.error("Command output: %s", e.output)
261 -                return False
262 -
263 -            updated = True
264 -
265 -        # Check for the rpm-check-signatures flag
266 -        nosignature = 'rpm-check-signatures'
267 -        if config.get(nosignature) is not False:
268 -            # Set the flag
269 -            LOG.warning("Setting %s option", nosignature)
270 -            try:
271 -                output = subprocess.check_output(smart_cmd +
272 -                                                 ["config", "--set",
273 -                                                  "%s=false" % nosignature],
274 -                                                 stderr=subprocess.STDOUT)
275 -            except subprocess.CalledProcessError as e:
276 -                LOG.exception("Failed to configure %s option", nosignature)
277 -                LOG.error("Command output: %s", e.output)
278 -                return False
279 -
280 -            updated = True
281 -
282 -        if updated:
283 -            try:
284 -                subprocess.check_output(smart_update, stderr=subprocess.STDOUT)
285 -            except subprocess.CalledProcessError as e:
286 -                LOG.exception("Failed to update smartpm")
287 -                LOG.error("Command output: %s", e.output)
288 -                return False
289 -
290 -            # Reset the patch op counter to force a detailed query
291 -            self.patch_op_counter = 0
292 -
293 -        self.last_config_audit = time.time()
294 -        return True
295 -
296 -    def timed_audit_smart_config(self):
297 -        rc = True
298 -        if (time.time() - self.last_config_audit) > 1800:
299 -            # It's been 30 minutes since the last completed audit
300 -            LOG.info("Kicking timed audit")
301 -            rc = self.audit_smart_config()
302 -
303 -        return rc
304 -
305      @staticmethod
306 -    def parse_smart_pkglist(output):
307 -        pkglist = {}
308 -        for line in output.splitlines():
309 -            if line == '':
310 -                continue
311 -
312 -            fields = line.split()
313 -            pkgname = fields[0]
314 -            (version, arch) = fields[1].split('@')
315 -
316 -            if pkgname not in pkglist:
317 -                pkglist[pkgname] = {}
318 -                pkglist[pkgname][arch] = version
319 -            elif arch not in pkglist[pkgname]:
320 -                pkglist[pkgname][arch] = version
321 +    def pkgobjs_to_list(pkgobjs):
322 +        # Transform pkgobj list to format used by patch-controller
323 +        output = {}
324 +        for pkg in pkgobjs:
325 +            if pkg.epoch != 0:
326 +                output[pkg.name] = "%s:%s-%s@%s" % (pkg.epoch, pkg.version, pkg.release, pkg.arch)
327              else:
328 -                stored_ver = pkglist[pkgname][arch]
329 -
330 -                # The rpm.labelCompare takes version broken into 3 components
331 -                # It returns:
332 -                #     1, if first arg is higher version
333 -                #     0, if versions are same
334 -                #     -1, if first arg is lower version
335 -                rc = rpm.labelCompare(parse_pkgver(version),
336 -                                      parse_pkgver(stored_ver))
337 +                output[pkg.name] = "%s-%s@%s" % (pkg.version, pkg.release, pkg.arch)
338  
339 -                if rc > 0:
340 -                    # Update version
341 -                    pkglist[pkgname][arch] = version
342 +        return output
343  
344 -        return pkglist
345 +    def dnf_reset_client(self):
346 +        if self.dnfb is not None:
347 +            self.dnfb.close()
348 +            self.dnfb = None
349  
350 -    @staticmethod
351 -    def get_pkg_version(pkglist, pkg, arch):
352 -        if pkg not in pkglist:
353 -            return None
354 -        if arch not in pkglist[pkg]:
355 -            return None
356 -        return pkglist[pkg][arch]
357 -
358 -    def parse_smart_newer(self, output):
359 -        # Skip the first two lines, which are headers
360 -        for line in output.splitlines()[2:]:
361 -            if line == '':
362 -                continue
363 -
364 -            fields = line.split()
365 -            pkgname = fields[0]
366 -            installedver = fields[2]
367 -            newver = fields[5]
368 +        self.dnfb = dnf.Base()
369 +        self.dnfb.conf.substitutions['infra'] = 'stock'
370  
371 -            self.installed[pkgname] = installedver
372 -            self.to_install[pkgname] = newver
373 -
374 -    def parse_smart_orphans(self, output):
375 -        for pkgname in output.splitlines():
376 -            if pkgname == '':
377 -                continue
378 +        # Reset default installonlypkgs list
379 +        self.dnfb.conf.installonlypkgs = []
380  
381 -            highest_version = None
382 +        self.dnfb.read_all_repos()
383  
384 -            try:
385 -                query = subprocess.check_output(smart_query_repos + ["--show-format", '$version\n', pkgname])
386 -                # The last non-blank version is the highest
387 -                for version in query.splitlines():
388 -                    if version == '':
389 -                        continue
390 -                    highest_version = version.split('@')[0]
391 -
392 -            except subprocess.CalledProcessError:
393 -                # Package is not in the repo
394 -                highest_version = None
395 -
396 -            if highest_version is None:
397 -                # Package is to be removed
398 -                self.to_remove.append(pkgname)
399 +        # Ensure only platform repos are enabled for transaction
400 +        for repo in self.dnfb.repos.all():
401 +            if repo.id == 'platform-base' or repo.id == 'platform-updates':
402 +                repo.enable()
403              else:
404 -                # Rollback to the highest version
405 -                self.to_install[pkgname] = highest_version
406 +                repo.disable()
407  
408 -            # Get the installed version
409 -            try:
410 -                query = subprocess.check_output(smart_query + ["--installed", "--show-format", '$version\n', pkgname])
411 -                for version in query.splitlines():
412 -                    if version == '':
413 -                        continue
414 -                    self.installed[pkgname] = version.split('@')[0]
415 -                    break
416 -            except subprocess.CalledProcessError:
417 -                LOG.error("Failed to query installed version of %s", pkgname)
418 -
419 -            self.changes = True
420 -
421 -    def check_groups(self):
422 -        # Get the groups file
423 -        mygroup = "updates-%s" % "-".join(subfunctions)
424 -        self.missing_pkgs = []
425 -        installed_pkgs = []
426 -
427 -        groups_url = "http://controller:%s/updates/rel-%s/comps.xml" % (http_port_real, SW_VERSION)
428 -        try:
429 -            req = requests.get(groups_url)
430 -            if req.status_code != 200:
431 -                LOG.error("Failed to get groups list from server")
432 -                return False
433 -        except requests.ConnectionError:
434 -            LOG.error("Failed to connect to server")
435 -            return False
436 -
437 -        # Get list of installed packages
438 -        try:
439 -            query = subprocess.check_output(["rpm", "-qa", "--queryformat", "%{NAME}\n"])
440 -            installed_pkgs = query.split()
441 -        except subprocess.CalledProcessError:
442 -            LOG.exception("Failed to query RPMs")
443 -            return False
444 -
445 -        root = ElementTree.fromstring(req.text)
446 -        for child in root:
447 -            group_id = child.find('id')
448 -            if group_id is None or group_id.text != mygroup:
449 -                continue
450 -
451 -            pkglist = child.find('packagelist')
452 -            if pkglist is None:
453 -                continue
454 -
455 -            for pkg in pkglist.findall('packagereq'):
456 -                if pkg.text not in installed_pkgs and pkg.text not in self.missing_pkgs:
457 -                    self.missing_pkgs.append(pkg.text)
458 -                    self.changes = True
459 +        # Read repo info
460 +        self.dnfb.fill_sack()
461  
462      def query(self):
463          """ Check current patch state """
464 @@ -633,14 +424,15 @@ class PatchAgent(PatchService):
465              LOG.info("Failed install_uuid check. Skipping query")
466              return False
467  
468 -        if not self.audit_smart_config():
469 -            # Set a state to "unknown"?
470 -            return False
471 +        if self.dnfb is not None:
472 +            self.dnfb.close()
473 +            self.dnfb = None
474  
475 +        # TODO(dpenney): Use python APIs for makecache
476          try:
477 -            subprocess.check_output(smart_update, stderr=subprocess.STDOUT)
478 +            subprocess.check_output(dnf_makecache, stderr=subprocess.STDOUT)
479          except subprocess.CalledProcessError as e:
480 -            LOG.error("Failed to update smartpm")
481 +            LOG.error("Failed to run dnf makecache")
482              LOG.error("Command output: %s", e.output)
483              # Set a state to "unknown"?
484              return False
485 @@ -649,78 +441,72 @@ class PatchAgent(PatchService):
486          self.query_id = random.random()
487  
488          self.changes = False
489 +        self.installed_dnf = []
490          self.installed = {}
491 -        self.to_install = {}
492 +        self.to_install_dnf = []
493 +        self.to_downgrade_dnf = []
494          self.to_remove = []
495 +        self.to_remove_dnf = []
496          self.missing_pkgs = []
497 +        self.missing_pkgs_dnf = []
498  
499 -        # Get the repo data
500 -        pkgs_installed = {}
501 -        pkgs_base = {}
502 -        pkgs_updates = {}
503 -
504 -        try:
505 -            output = subprocess.check_output(smart_query_installed)
506 -            pkgs_installed = self.parse_smart_pkglist(output)
507 -        except subprocess.CalledProcessError as e:
508 -            LOG.error("Failed to query installed pkgs: %s", e.output)
509 -            # Set a state to "unknown"?
510 -            return False
511 -
512 -        try:
513 -            output = subprocess.check_output(smart_query_base)
514 -            pkgs_base = self.parse_smart_pkglist(output)
515 -        except subprocess.CalledProcessError as e:
516 -            LOG.error("Failed to query base pkgs: %s", e.output)
517 -            # Set a state to "unknown"?
518 -            return False
519 +        self.dnf_reset_client()
520  
521 -        try:
522 -            output = subprocess.check_output(smart_query_updates)
523 -            pkgs_updates = self.parse_smart_pkglist(output)
524 -        except subprocess.CalledProcessError as e:
525 -            LOG.error("Failed to query patched pkgs: %s", e.output)
526 -            # Set a state to "unknown"?
527 -            return False
528 +        # Get the repo data
529 +        pkgs_installed = dnf.sack._rpmdb_sack(self.dnfb).query().installed()  # pylint: disable=protected-access
530 +        avail = self.dnfb.sack.query().available().latest()
531  
532 -        # There are four possible actions:
533 -        # 1. If installed pkg is not in base or updates, remove it.
534 -        # 2. If installed pkg version is higher than highest in base
535 -        #    or updates, downgrade it.
536 -        # 3. If installed pkg version is lower than highest in updates,
537 -        #    upgrade it.
538 -        # 4. If pkg in grouplist is not in installed, install it.
539 +        # There are three possible actions:
540 +        # 1. If installed pkg is not in a repo, remove it.
541 +        # 2. If installed pkg version does not match newest repo version, update it.
542 +        # 3. If a package in the grouplist is not installed, install it.
543  
544          for pkg in pkgs_installed:
545 -            for arch in pkgs_installed[pkg]:
546 -                installed_version = pkgs_installed[pkg][arch]
547 -                updates_version = self.get_pkg_version(pkgs_updates, pkg, arch)
548 -                base_version = self.get_pkg_version(pkgs_base, pkg, arch)
549 -
550 -                if updates_version is None and base_version is None:
551 -                    # Remove it
552 -                    self.to_remove.append(pkg)
553 -                    self.changes = True
554 -                    continue
555 +            highest = avail.filter(name=pkg.name, arch=pkg.arch)
556 +            if highest:
557 +                highest_pkg = highest[0]
558  
559 -                compare_version = updates_version
560 -                if compare_version is None:
561 -                    compare_version = base_version
562 -
563 -                # Compare the installed version to what's in the repo
564 -                rc = rpm.labelCompare(parse_pkgver(installed_version),
565 -                                      parse_pkgver(compare_version))
566 -                if rc == 0:
567 -                    # Versions match, nothing to do.
568 +                if pkg.evr_eq(highest_pkg):
569                      continue
570 +
571 +                if pkg.evr_gt(highest_pkg):
572 +                    self.to_downgrade_dnf.append(highest_pkg)
573                  else:
574 -                    # Install the version from the repo
575 -                    self.to_install[pkg] = "@".join([compare_version, arch])
576 -                    self.installed[pkg] = "@".join([installed_version, arch])
577 -                    self.changes = True
578 +                    self.to_install_dnf.append(highest_pkg)
579 +            else:
580 +                self.to_remove_dnf.append(pkg)
581 +                self.to_remove.append(pkg.name)
582 +
583 +            self.installed_dnf.append(pkg)
584 +            self.changes = True
585  
586          # Look for new packages
587 -        self.check_groups()
588 +        self.dnfb.read_comps()
589 +        grp_id = 'updates-%s' % '-'.join(subfunctions)
590 +        pkggrp = None
591 +        for grp in self.dnfb.comps.groups_iter():
592 +            if grp.id == grp_id:
593 +                pkggrp = grp
594 +                break
595 +
596 +        if pkggrp is None:
597 +            LOG.error("Could not find software group: %s", grp_id)
598 +
599 +        for pkg in pkggrp.packages_iter():
600 +            try:
601 +                res = pkgs_installed.filter(name=pkg.name)
602 +                if len(res) == 0:
603 +                    found_pkg = avail.filter(name=pkg.name)
604 +                    self.missing_pkgs_dnf.append(found_pkg[0])
605 +                    self.missing_pkgs.append(found_pkg[0].name)
606 +                    self.changes = True
607 +            except dnf.exceptions.PackageNotFoundError:
608 +                self.missing_pkgs_dnf.append(pkg)
609 +                self.missing_pkgs.append(pkg.name)
610 +                self.changes = True
611 +
612 +        self.installed = self.pkgobjs_to_list(self.installed_dnf)
613 +        self.to_install = self.pkgobjs_to_list(self.to_install_dnf + self.to_downgrade_dnf)
614  
615          LOG.info("Patch state query returns %s", self.changes)
616          LOG.info("Installed: %s", self.installed)
617 @@ -730,6 +516,35 @@ class PatchAgent(PatchService):
618  
619          return True
620  
621 +    def resolve_dnf_transaction(self, undo_failure=True):
622 +        LOG.info("Starting to process transaction: undo_failure=%s", undo_failure)
623 +        self.dnfb.resolve()
624 +        self.dnfb.download_packages(self.dnfb.transaction.install_set)
625 +
626 +        tid = self.dnfb.do_transaction(display=PatchAgentDnfTransLogCB())
627 +
628 +        transaction_rc = True
629 +        for t in self.dnfb.transaction:
630 +            if t.state != libdnf.transaction.TransactionItemState_DONE:
631 +                transaction_rc = False
632 +                break
633 +
634 +        self.dnf_reset_client()
635 +
636 +        if not transaction_rc:
637 +            if undo_failure:
638 +                LOG.error("Failure occurred... Undoing last transaction (%s)", tid)
639 +                old = self.dnfb.history.old((tid,))[0]
640 +                mobj = dnf.db.history.MergedTransactionWrapper(old)
641 +
642 +                self.dnfb._history_undo_operations(mobj, old.tid, True)  # pylint: disable=protected-access
643 +
644 +                if not self.resolve_dnf_transaction(undo_failure=False):
645 +                    LOG.error("Failed to undo transaction")
646 +
647 +        LOG.info("Transaction complete: undo_failure=%s, success=%s", undo_failure, transaction_rc)
648 +        return transaction_rc
649 +
650      def handle_install(self, verbose_to_stdout=False, disallow_insvc_patch=False):
651          #
652          # The disallow_insvc_patch parameter is set when we're installing
653 @@ -781,64 +596,54 @@ class PatchAgent(PatchService):
654          if verbose_to_stdout:
655              print("Checking for software updates...")
656          self.query()
657 -        install_set = []
658 -        for pkg, version in self.to_install.items():
659 -            install_set.append("%s-%s" % (pkg, version))
660 -
661 -        install_set += self.missing_pkgs
662  
663          changed = False
664          rc = True
665  
666 -        if len(install_set) > 0:
667 +        if len(self.to_install_dnf) > 0 or len(self.to_downgrade_dnf) > 0:
668 +            LOG.info("Adding pkgs to installation set: %s", self.to_install)
669 +            for pkg in self.to_install_dnf:
670 +                self.dnfb.package_install(pkg)
671 +
672 +            for pkg in self.to_downgrade_dnf:
673 +                self.dnfb.package_downgrade(pkg)
674 +
675 +            changed = True
676 +
677 +        if len(self.missing_pkgs_dnf) > 0:
678 +            LOG.info("Adding missing pkgs to installation set: %s", self.missing_pkgs)
679 +            for pkg in self.missing_pkgs_dnf:
680 +                self.dnfb.package_install(pkg)
681 +            changed = True
682 +
683 +        if len(self.to_remove_dnf) > 0:
684 +            LOG.info("Adding pkgs to be removed: %s", self.to_remove)
685 +            for pkg in self.to_remove_dnf:
686 +                self.dnfb.package_remove(pkg)
687 +            changed = True
688 +
689 +        if changed:
690 +            # Run the transaction set
691 +            transaction_rc = False
692              try:
693 -                if verbose_to_stdout:
694 -                    print("Installing software updates...")
695 -                LOG.info("Installing: %s", ", ".join(install_set))
696 -                output = subprocess.check_output(smart_install_cmd + install_set, stderr=subprocess.STDOUT)
697 -                changed = True
698 -                for line in output.split('\n'):
699 -                    LOG.info("INSTALL: %s", line)
700 -                if verbose_to_stdout:
701 -                    print("Software updated.")
702 -            except subprocess.CalledProcessError as e:
703 -                LOG.exception("Failed to install RPMs")
704 -                LOG.error("Command output: %s", e.output)
705 +                transaction_rc = self.resolve_dnf_transaction()
706 +            except dnf.exceptions.DepsolveError:
707 +                LOG.error("Failures resolving dependencies in transaction")
708 +            except dnf.exceptions.DownloadError:
709 +                LOG.error("Failures downloading in transaction")
710 +
711 +            if not transaction_rc:
712 +                LOG.error("Failures occurred during transaction")
713                  rc = False
714                  if verbose_to_stdout:
715                      print("WARNING: Software update failed.")
716 +
717          else:
718              if verbose_to_stdout:
719                  print("Nothing to install.")
720              LOG.info("Nothing to install")
721  
722 -        if rc:
723 -            self.query()
724 -            remove_set = self.to_remove
725 -
726 -            if len(remove_set) > 0:
727 -                try:
728 -                    if verbose_to_stdout:
729 -                        print("Handling patch removal...")
730 -                    LOG.info("Removing: %s", ", ".join(remove_set))
731 -                    output = subprocess.check_output(smart_remove_cmd + remove_set, stderr=subprocess.STDOUT)
732 -                    changed = True
733 -                    for line in output.split('\n'):
734 -                        LOG.info("REMOVE: %s", line)
735 -                    if verbose_to_stdout:
736 -                        print("Patch removal complete.")
737 -                except subprocess.CalledProcessError as e:
738 -                    LOG.exception("Failed to remove RPMs")
739 -                    LOG.error("Command output: %s", e.output)
740 -                    rc = False
741 -                    if verbose_to_stdout:
742 -                        print("WARNING: Patch removal failed.")
743 -            else:
744 -                if verbose_to_stdout:
745 -                    print("Nothing to remove.")
746 -                LOG.info("Nothing to remove")
747 -
748 -        if changed:
749 +        if changed and rc:
750              # Update the node_is_patched flag
751              setflag(node_is_patched_file)
752  
753 @@ -1057,7 +862,7 @@ class PatchAgent(PatchService):
754  def main():
755      global pa
756  
757 -    configure_logging()
758 +    configure_logging(dnf_log=True)
759  
760      cfg.read_config()
761  
762 diff --git a/cgcs-patch/cgcs-patch/cgcs_patch/patch_functions.py b/cgcs-patch/cgcs-patch/cgcs_patch/patch_functions.py
763 index e9017f2..2ee9fce 100644
764 --- a/cgcs-patch/cgcs-patch/cgcs_patch/patch_functions.py
765 +++ b/cgcs-patch/cgcs-patch/cgcs_patch/patch_functions.py
766 @@ -69,7 +69,7 @@ def handle_exception(exc_type, exc_value, exc_traceback):
767      sys.__excepthook__(exc_type, exc_value, exc_traceback)
768  
769  
770 -def configure_logging(logtofile=True, level=logging.INFO):
771 +def configure_logging(logtofile=True, level=logging.INFO, dnf_log=False):
772      if logtofile:
773          my_exec = os.path.basename(sys.argv[0])
774  
775 @@ -84,6 +84,11 @@ def configure_logging(logtofile=True, level=logging.INFO):
776          main_log_handler = logging.FileHandler(logfile)
777          main_log_handler.setFormatter(formatter)
778          LOG.addHandler(main_log_handler)
779 +
780 +        if dnf_log:
781 +            dnf_logger = logging.getLogger('dnf')
782 +            dnf_logger.addHandler(main_log_handler)
783 +
784          try:
785              os.chmod(logfile, 0o640)
786          except Exception:
787 diff --git a/cgcs-patch/cgcs-patch/cgcs_patch/tests/test_patch_agent.py b/cgcs-patch/cgcs-patch/cgcs_patch/tests/test_patch_agent.py
788 index bd1eef9..7e30fc5 100644
789 --- a/cgcs-patch/cgcs-patch/cgcs_patch/tests/test_patch_agent.py
790 +++ b/cgcs-patch/cgcs-patch/cgcs_patch/tests/test_patch_agent.py
791 @@ -10,6 +10,15 @@ import sys
792  import testtools
793  
794  sys.modules['rpm'] = mock.Mock()
795 +sys.modules['dnf'] = mock.Mock()
796 +sys.modules['dnf.callback'] = mock.Mock()
797 +sys.modules['dnf.comps'] = mock.Mock()
798 +sys.modules['dnf.exceptions'] = mock.Mock()
799 +sys.modules['dnf.rpm'] = mock.Mock()
800 +sys.modules['dnf.sack'] = mock.Mock()
801 +sys.modules['dnf.transaction'] = mock.Mock()
802 +sys.modules['libdnf'] = mock.Mock()
803 +sys.modules['libdnf.transaction'] = mock.Mock()
804  
805  import cgcs_patch.patch_agent  # noqa: E402
806  
807 diff --git a/cgcs-patch/cgcs-patch/pylint.rc b/cgcs-patch/cgcs-patch/pylint.rc
808 index 57a9829..f511718 100644
809 --- a/cgcs-patch/cgcs-patch/pylint.rc
810 +++ b/cgcs-patch/cgcs-patch/pylint.rc
811 @@ -45,10 +45,11 @@ symbols=no
812  # no Warning level messages displayed, use"--disable=all --enable=classes
813  # --disable=W"
814  # W0107 unnecessary-pass
815 +# W0511 fixme
816  # W0603 global-statement
817  # W0703 broad-except
818  # W1505, deprecated-method
819 -disable=C, R, W0107, W0603, W0703, W1505
820 +disable=C, R, W0107, W0511, W0603, W0703, W1505
821  
822  
823  [REPORTS]
824 @@ -235,7 +236,7 @@ ignore-mixin-members=yes
825  # List of module names for which member attributes should not be checked
826  # (useful for modules/projects where namespaces are manipulated during runtime
827  # and thus existing member attributes cannot be deduced by static analysis
828 -ignored-modules=
829 +ignored-modules=dnf,libdnf
830  
831  # List of classes names for which member attributes should not be checked
832  # (useful for classes with attributes dynamically set).
833 diff --git a/cgcs-patch/cgcs-patch/test-requirements.txt b/cgcs-patch/cgcs-patch/test-requirements.txt
834 index 3f4e581..56e4806 100644
835 --- a/cgcs-patch/cgcs-patch/test-requirements.txt
836 +++ b/cgcs-patch/cgcs-patch/test-requirements.txt
837 @@ -8,4 +8,3 @@ coverage!=4.4,>=4.0 # Apache-2.0
838  mock>=2.0.0 # BSD
839  stestr>=1.0.0 # Apache-2.0
840  testtools>=2.2.0 # MIT
841 -