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