From 6110e4bdf03aadad7cff0f603b698075bea6b140 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Tue, 11 Aug 2026 13:07:03 -0400 Subject: [PATCH 01/25] Refactor: python: Re-enable audits in cts-lab. These have been disabled due to nebulous long-ago refactorings since 2017, which is a doubly long time ago. Let's re-enable them and see what happens. --- python/pacemaker/_cts/audits.py | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 2634d6a0878..41d8b1156ab 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -540,13 +540,7 @@ def __call__(self): def is_applicable(self): """Return True if this audit is applicable in the current test configuration.""" - # @TODO Due to long-ago refactoring, this name test would never match, - # so this audit (and those derived from it) would never run. - # Uncommenting the next lines fixes the name test, but that then - # exposes pre-existing bugs that need to be fixed. - # if self._cm.name == "crm-corosync": - # return True - return False + return self._cm.name == "crm-corosync" class GroupAudit(PrimitiveAudit): @@ -760,13 +754,7 @@ def __call__(self): def is_applicable(self): """Return True if this audit is applicable in the current test configuration.""" - # @TODO Due to long-ago refactoring, this name test would never match, - # so this audit (and those derived from it) would never run. - # Uncommenting the next lines fixes the name test, but that then - # exposes pre-existing bugs that need to be fixed. - # if self._cm.name == "crm-corosync": - # return True - return False + return self._cm.name == "crm-corosync" class CIBAudit(ClusterAudit): @@ -865,13 +853,7 @@ def _store_remote_cib(self, node, target): def is_applicable(self): """Return True if this audit is applicable in the current test configuration.""" - # @TODO Due to long-ago refactoring, this name test would never match, - # so this audit (and those derived from it) would never run. - # Uncommenting the next lines fixes the name test, but that then - # exposes pre-existing bugs that need to be fixed. - # if self._cm.name == "crm-corosync": - # return True - return False + return self._cm.name == "crm-corosync" class PartitionAudit(ClusterAudit): @@ -1018,13 +1000,7 @@ def _audit_partition(self, partition): def is_applicable(self): """Return True if this audit is applicable in the current test configuration.""" - # @TODO Due to long-ago refactoring, this name test would never match, - # so this audit (and those derived from it) would never run. - # Uncommenting the next lines fixes the name test, but that then - # exposes pre-existing bugs that need to be fixed. - # if self._cm.name == "crm-corosync": - # return True - return False + return self._cm.name == "crm-corosync" # pylint: disable=invalid-name From c90584d02f594d7026776718f9175ff5e36b02e6 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Tue, 11 Aug 2026 13:59:23 -0400 Subject: [PATCH 02/25] Refactor: python: Remove epoch check from PartitionAudit. This uses `crm_node -e` to figure out what epoch a node joined the cluster. However, this option was removed by 55ab749b in 2017 and the whole epoch concept doesn't even apply to a non-heartbeat cluster. So, this check can be removed. --- python/pacemaker/_cts/audits.py | 36 +------------------------------ python/pacemaker/_cts/patterns.py | 1 - 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 41d8b1156ab..8cdab9a55d3 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -865,7 +865,7 @@ class PartitionAudit(ClusterAudit): * The number of partitions and the nodes in each is as expected * Each node is active when it should be active and inactive when it should be inactive - * The status and epoch of each node is as expected + * The status of each node is as expected * A partition has quorum * A partition has a DC when expected """ @@ -880,7 +880,6 @@ def __init__(self, cm): ClusterAudit.__init__(self, cm) self.name = "PartitionAudit" - self._node_epoch = {} self._node_state = {} self._node_quorum = {} @@ -929,7 +928,6 @@ def _audit_partition(self, partition): passed = True dc_found = [] dc_allowed_list = [] - lowest_epoch = None node_list = partition.split() self.debug(f"Auditing partition: {partition}") @@ -943,45 +941,18 @@ def _audit_partition(self, partition): (_, out) = self._cm.rsh.call(node, self._cm.templates["StatusCmd"] % node, verbose=1) self._node_state[node] = out[0].strip() - (_, out) = self._cm.rsh.call(node, self._cm.templates["EpochCmd"], verbose=1) - self._node_epoch[node] = out[0].strip() - (_, out) = self._cm.rsh.call(node, self._cm.templates["QuorumCmd"], verbose=1) self._node_quorum[node] = out[0].strip() - self.debug(f"Node {node}: {self._node_state[node]} - {self._node_epoch[node]} - {self._node_quorum[node]}.") self._node_state[node] = self._trim_string(self._node_state[node]) - self._node_epoch[node] = self._trim2int(self._node_epoch[node]) self._node_quorum[node] = self._trim_string(self._node_quorum[node]) - if not self._node_epoch[node]: - logging.log(f"Warn: Node {node} disappeared: can't determine epoch") - self._cm.expected_status[node] = "down" - # not in itself a reason to fail the audit (not what we're - # checking for in this audit) - elif lowest_epoch is None or self._node_epoch[node] < lowest_epoch: - lowest_epoch = self._node_epoch[node] - - if not lowest_epoch: - logging.log(f"Lowest epoch not determined in {partition}") - passed = False - for node in node_list: if self._cm.expected_status[node] != "up": continue if self._cm.is_node_dc(node, self._node_state[node]): dc_found.append(node) - if self._node_epoch[node] == lowest_epoch: - self.debug(f"{node}: OK") - elif not self._node_epoch[node]: - self.debug(f"Check on {node} ignored: no node epoch") - elif not lowest_epoch: - self.debug(f"Check on {node} ignored: no lowest epoch") - else: - logging.log(f"DC {node} is not the oldest node " - f"({self._node_epoch[node]} vs. {lowest_epoch})") - passed = False if not dc_found: logging.log(f"DC not found on any of the {len(dc_allowed_list)} allowed " @@ -991,11 +962,6 @@ def _audit_partition(self, partition): logging.log(f"{len(dc_found)} DCs ({dc_found}) found in cluster partition: {node_list}") passed = False - if not passed: - for node in node_list: - if self._cm.expected_status[node] == "up": - logging.log(f"epoch {self._node_epoch[node]} : {self._node_state[node]}") - return passed def is_applicable(self): diff --git a/python/pacemaker/_cts/patterns.py b/python/pacemaker/_cts/patterns.py index 963f460f739..f737ef10a30 100644 --- a/python/pacemaker/_cts/patterns.py +++ b/python/pacemaker/_cts/patterns.py @@ -146,7 +146,6 @@ def __init__(self): "StartCmd": "service corosync start && service pacemaker start", "StopCmd": "service pacemaker stop; [ ! -e /usr/sbin/pacemaker-remoted ] || service pacemaker_remote stop; service corosync stop", - "EpochCmd": "crm_node -e", "QuorumCmd": "crm_node -q", "PartitionCmd": "crm_node -p", }) From 7232e358fef434423fdbb24f7e4a4e2c71e04c6b Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Tue, 11 Aug 2026 14:19:05 -0400 Subject: [PATCH 03/25] Refactor: python: Remove the -f argument from crm_diff. I can't find any reference to this argument as far back as the latest state of the 1.1 branch, which is where I stopped looking. --- python/pacemaker/_cts/audits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 8cdab9a55d3..5b892a4f81c 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -810,7 +810,7 @@ def _audit_cib_contents(self, hostlist): else: (rc, result) = self._cm.rsh.call( - node0, f"crm_diff -VV -cf --new {node_xml} --original {node0_xml}", verbose=1) + node0, f"crm_diff -VV -c --new {node_xml} --original {node0_xml}", verbose=1) if rc != 0: logging.log(f"Diff between {node0_xml} and {node_xml} failed: {rc}") From 94201353ccd98a8a88d224ed2ce333692c798012 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Tue, 11 Aug 2026 14:52:37 -0400 Subject: [PATCH 04/25] Low: python: Apply scores to constraints in cts-lab CIB. This may affect other attributes besides score. This was introduced by 66f20f73596. --- python/pacemaker/_cts/cibxml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pacemaker/_cts/cibxml.py b/python/pacemaker/_cts/cibxml.py index b0b1a4a1481..7c5711dc822 100644 --- a/python/pacemaker/_cts/cibxml.py +++ b/python/pacemaker/_cts/cibxml.py @@ -546,7 +546,7 @@ def _constraints(self): for (k, kargs) in self._coloc.items(): attrs = {"id": f"{self.name}-with-{k}", "rsc": self.name, "with-rsc": k} - text += element("rsc_colocation", **attrs) + text += element("rsc_colocation", **attrs, **kargs) text += "" return text From 8b6f45fdabc2b0ed4c79b3f7af7d6d342fc7e369 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Wed, 12 Aug 2026 14:38:58 -0400 Subject: [PATCH 05/25] Refactor: python: Disable certain audits on the split brain test. These audits check if a resource is running in more than one place which is always going to be the case in the split brain test. --- python/pacemaker/_cts/tests/splitbraintest.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/python/pacemaker/_cts/tests/splitbraintest.py b/python/pacemaker/_cts/tests/splitbraintest.py index d0d9fafa8cb..90709bb6306 100644 --- a/python/pacemaker/_cts/tests/splitbraintest.py +++ b/python/pacemaker/_cts/tests/splitbraintest.py @@ -185,6 +185,22 @@ def __call__(self, node): return self.failure("See previous errors") + def audit(self): + """Perform all the relevant audits (see ClusterAudit), returning whether or not they all passed.""" + passed = True + + for audit in self.audits: + # These audits don't work well on the split brain test + if audit.name in ["GroupAudit", "PrimitiveAudit"]: + continue + + if not audit(): + logging.log(f"Internal {self.name} Audit {audit.name} FAILED.") + self.incr("auditfail") + passed = False + + return passed + @property def errors_to_ignore(self): """Return a list of errors which should be ignored.""" From 89973ae6edc7bca3d2349efdbd00d0b44839aafc Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Wed, 12 Aug 2026 14:40:22 -0400 Subject: [PATCH 06/25] Refactor: python: Rewrite CIBAudit._audit_cib_contents. This code was ssh'ing into each host, running cibadmin -Q, writing that to a file, and then scp'ing that file back to a host. There was also some juggling around to handle the first host in the list differently. It also didn't clean up the files it wrote. This is all a little more complicated than it needs to be. Instead, we can just capture stdout from the ssh command and write that to a file on the executor, diff things there, and clean up. --- python/pacemaker/_cts/audits.py | 77 +++++++++++++++------------------ 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 5b892a4f81c..60c50df424c 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -4,7 +4,10 @@ __copyright__ = "Copyright 2000-2026 the Pacemaker project contributors" __license__ = "GNU General Public License version 2 or later (GPLv2+) WITHOUT ANY WARRANTY" +import glob +import os import re +import subprocess import time import uuid @@ -786,70 +789,62 @@ def __call__(self): return passed + def _cleanup_cibs(self): + """Remove any fetched CIB files.""" + for f in glob.glob("/tmp/ctsaudit.*.xml"): + os.remove(f) + def _audit_cib_contents(self, hostlist): """Perform the CIB audit on the given hosts.""" passed = True - node0 = None - node0_xml = None partition_hosts = hostlist.split() for node in partition_hosts: - node_xml = self._store_remote_cib(node, node0) + node_xml = self._get_remote_cib(node) if node_xml is None: + # If we failed to fetch the CIB from a single node, the audit + # will fail. Clean up anything we did fetch and return. logging.log(f"Could not perform audit: No configuration from {node}") passed = False + self._cleanup_cibs() + return passed - elif node0 is None: - node0 = node - node0_xml = node_xml + with open(f"/tmp/ctsaudit.{node}.xml", "w", encoding="utf-8") as f: + for line in node_xml: + f.write(line) - elif node0_xml is None: - logging.log(f"Could not perform audit: No configuration from {node0}") - passed = False + (first, rest) = (partition_hosts[0], partition_hosts[1:]) + first_xml = f"/tmp/ctsaudit.{first}.xml" - else: - (rc, result) = self._cm.rsh.call( - node0, f"crm_diff -VV -c --new {node_xml} --original {node0_xml}", verbose=1) + for node in rest: + node_xml = f"/tmp/ctsaudit.{node}.xml" + proc = subprocess.run(["crm_diff", "-VV", "-c", "--new", node_xml, + "--original", first_xml], + check=False, capture_output=True, universal_newlines=True) - if rc != 0: - logging.log(f"Diff between {node0_xml} and {node_xml} failed: {rc}") - passed = False + if proc.returncode != 0: + logging.log(f"Diff between {first_xml} and {node_xml} failed: {proc.returncode}") + passed = False - for line in result: - if not re.search("", line): - passed = False - self.debug(f"CibDiff[{node0}-{node}]: {line}") - else: - self.debug(f"CibDiff[{node0}-{node}] Ignoring: {line}") + for line in proc.stdout.splitlines(): + if not re.search("", line): + passed = False + self.debug(f"CibDiff[{first}-{node}]: {line}") + else: + self.debug(f"CibDiff[{first}-{node}] Ignoring: {line}") + self._cleanup_cibs() return passed - def _store_remote_cib(self, node, target): - """ - Store a copy of the given node's CIB on the given target node. - - If no target is given, store the CIB on the given node. - """ - filename = f"/tmp/ctsaudit.{node}.xml" - - if not target: - target = node - + def _get_remote_cib(self, node): + """Fetch a copy of the given node's CIB and return it as a list.""" (rc, lines) = self._cm.rsh.call(node, self._cm.templates["CibQuery"], verbose=1) if rc != 0: logging.log("Could not retrieve configuration") return None - self._cm.rsh.call("localhost", f"rm -f {filename}") - for line in lines: - self._cm.rsh.call("localhost", f"echo \'{line[:-1]}\' >> {filename}", verbose=0) - - if self._cm.rsh.copy(filename, f"root@{target}:{filename}") != 0: - logging.log("Could not store configuration") - return None - - return filename + return lines def is_applicable(self): """Return True if this audit is applicable in the current test configuration.""" From 92bb3e6f64e7f3580ab75dc893c8719568b3fc29 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Wed, 12 Aug 2026 15:30:04 -0400 Subject: [PATCH 07/25] Refactor: python: Ignore differences in cib-last-written. Sometimes, the CIBAudit test fails because different nodes have cib-last-written timestamps that are different. However, nothing else in the CIB is different. So, just remove the timestamp to remove the possibility of any differences there. --- python/pacemaker/_cts/audits.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 60c50df424c..8d24b4ac5a7 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -812,6 +812,7 @@ def _audit_cib_contents(self, hostlist): with open(f"/tmp/ctsaudit.{node}.xml", "w", encoding="utf-8") as f: for line in node_xml: + line = re.sub(r'cib-last-written="[^"]+"', 'cib-last-written=""', line) f.write(line) (first, rest) = (partition_hosts[0], partition_hosts[1:]) From cb63241a117141cce4d7d3aaad760038cb57ee04 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Thu, 13 Aug 2026 13:04:33 -0400 Subject: [PATCH 08/25] Refactor: python: Allow audits to be run more than once. I don't think they should be run more than once, certainly not things like the PrimitiveAudit which runs at least once before any of the tests run, and then runs during the tests as well. But that's a bigger problem to solve. The problem here is that every time PrimitiveAudit (or its subclasses) run, they append to various lists without first resetting them. Over time, this means that the same checks get run over and over again. This really drags down the tests. So, just reset the lists. --- python/pacemaker/_cts/audits.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 8d24b4ac5a7..451118c0e64 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -443,12 +443,7 @@ def __init__(self, cm): """ ClusterAudit.__init__(self, cm) self.name = "PrimitiveAudit" - - self._active_nodes = [] - self._constraints = [] - self._inactive_nodes = [] - self._resources = [] - self._target = None + self._reset() def _audit_resource(self, resource, quorum): """Perform the audit of a single resource.""" @@ -490,12 +485,23 @@ def _audit_resource(self, resource, quorum): return rc + # pylint: disable=attribute-defined-outside-init + def _reset(self): + """Reset internal lists.""" + self._active_nodes = [] + self._constraints = [] + self._inactive_nodes = [] + self._resources = [] + self._target = None + def _setup(self): """ Verify cluster nodes are active. Collect resource and colocation information used for performing the audit. """ + self._reset() + for node in self._cm.env["nodes"]: if self._cm.expected_status[node] == "up": self._active_nodes.append(node) From 024df268c63c5d2599d3669ecc3af0f3d4cbf303 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:01:16 -0400 Subject: [PATCH 09/25] Refactor: python: Improve a block in FileAudit.__call__. The clean variable is unnecessary and makes this block kind of weird to read, so just remove it. --- python/pacemaker/_cts/audits.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 451118c0e64..226a9dab4a8 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -320,15 +320,14 @@ def __call__(self): passed = False if self._cm.expected_status.get(node) == "down": - clean = False (_, lsout) = self._cm.rsh.call(node, "ls -al /dev/shm | grep qb-", verbose=1) - for line in lsout: + if lsout: passed = False - clean = True - logging.log(f"Warning: Stale IPC file on {node}: {line}") - if clean: + for line in lsout: + logging.log(f"Warning: Stale IPC file on {node}: {line}") + (_, lsout) = self._cm.rsh.call(node, "ps axf | grep -e pacemaker -e corosync", verbose=1) for line in lsout: From a275ce0ebf02f69c3ac86e20c6ed2e80443137ef Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:09:27 -0400 Subject: [PATCH 10/25] Refactor: python: Further simplify FileAudit.__call__. * Move the have_systemd test into _find_core_with_coredumpctl so we have one less case to consider in the caller. * Get rid of the unnecessary intermediate found variable. --- python/pacemaker/_cts/audits.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 226a9dab4a8..b67c3a90910 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -283,6 +283,9 @@ def _output_has_core(self, output, node): def _find_core_with_coredumpctl(self, node): """Use coredumpctl to find core dumps on the given node.""" + if not self._cm.env["have_systemd"]: + return False + (_, lsout) = self._cm.rsh.call(node, "coredumpctl --no-legend --no-pager") return self._output_has_core(lsout, node) @@ -299,13 +302,9 @@ def __call__(self): self._cm.ns.wait_for_all_nodes(self._cm.env["nodes"]) for node in self._cm.env["nodes"]: - found = False - - # If systemd is present, first see if coredumpctl logged any core dumps. - if self._cm.env["have_systemd"]: - found = self._find_core_with_coredumpctl(node) - if found: - passed = False + # First, try to use coredumpctl to find any core dumps. + if self._find_core_with_coredumpctl(node): + passed = False # If we didn't find any core dumps, it's for one of three reasons: # (1) Nothing crashed @@ -313,11 +312,9 @@ def __call__(self): # (3) systemd is present but coredumpctl is not enabled # # To handle the last two cases, check the other filesystem locations. - if not found: - found = self._find_core_on_fs(node, ["/var/lib/pacemaker/cores/*", - "/var/lib/corosync"]) - if found: - passed = False + elif self._find_core_on_fs(node, ["/var/lib/pacemaker/cores/*", + "/var/lib/corosync"]): + passed = False if self._cm.expected_status.get(node) == "down": (_, lsout) = self._cm.rsh.call(node, "ls -al /dev/shm | grep qb-", verbose=1) From b2e4be849fa9033903c7e9d1edbbc3e999de93a4 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:12:06 -0400 Subject: [PATCH 11/25] Refactor: python: Unindent FileAudit.__call__. This is just the same trick where we invert the test and continue, allowing everything else to be unindented. --- python/pacemaker/_cts/audits.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index b67c3a90910..9969ab20569 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -316,24 +316,23 @@ def __call__(self): "/var/lib/corosync"]): passed = False - if self._cm.expected_status.get(node) == "down": - (_, lsout) = self._cm.rsh.call(node, "ls -al /dev/shm | grep qb-", verbose=1) - - if lsout: - passed = False + if self._cm.expected_status.get(node) != "down": + logging.debug(f"Skipping {node}") + continue - for line in lsout: - logging.log(f"Warning: Stale IPC file on {node}: {line}") + (_, lsout) = self._cm.rsh.call(node, "ls -al /dev/shm | grep qb-", verbose=1) + if lsout: + passed = False - (_, lsout) = self._cm.rsh.call(node, "ps axf | grep -e pacemaker -e corosync", verbose=1) + for line in lsout: + logging.log(f"Warning: Stale IPC file on {node}: {line}") - for line in lsout: - logging.debug(f"ps[{node}]: {line}") + (_, lsout) = self._cm.rsh.call(node, "ps axf | grep -e pacemaker -e corosync", verbose=1) - self._cm.rsh.call(node, "rm -rf /dev/shm/qb-*") + for line in lsout: + logging.debug(f"ps[{node}]: {line}") - else: - logging.debug(f"Skipping {node}") + self._cm.rsh.call(node, "rm -rf /dev/shm/qb-*") return passed From aac39435a80c228d6db7c6d6dd42c7e4b146e75a Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:16:50 -0400 Subject: [PATCH 12/25] Refactor: python: Improve spacing in LogAudit._test_logging. --- python/pacemaker/_cts/audits.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 9969ab20569..9863c21079f 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -130,11 +130,14 @@ def _test_logging(self): kinds = [LogKind.LOCAL_FILE] if self._cm.env["have_systemd"]: kinds.append(LogKind.JOURNAL) + kinds.append(LogKind.REMOTE_FILE) for k in kinds: watch[k] = self._create_watcher(patterns, k) + logging.log(f"Logging test message with identifier {suffix}") + else: watch[watch_pref] = self._create_watcher(patterns, watch_pref) @@ -147,10 +150,12 @@ def _test_logging(self): logging.log(f"Checking for test message in {k} logs") w.look_for_all(silent=True) + if not w.unmatched: if watch_pref is None: logging.log(f"Found test message in {k} logs") self._cm.env["log_kind"] = k + return True for regex in w.unmatched: From 483b6a897b777de338e0d388f86c7958d955b003 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:37:39 -0400 Subject: [PATCH 13/25] Refactor: python: Simplify return values in LogAudit. --- python/pacemaker/_cts/audits.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 9863c21079f..61cf82e1da5 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -167,7 +167,6 @@ def __call__(self): """Perform the audit action.""" max_attempts = 3 attempt = 0 - passed = True self._cm.ns.wait_for_all_nodes(self._cm.env["nodes"]) while attempt <= max_attempts and not self._test_logging(): @@ -177,16 +176,13 @@ def __call__(self): if attempt > max_attempts: logging.log("ERROR: Cluster logging unrecoverable.") - passed = False + return False - return passed + return True def is_applicable(self): """Return True if this audit is applicable in the current test configuration.""" - if self._cm.env["LogAuditDisabled"]: - return False - - return True + return not self._cm.env["LogAuditDisabled"] class DiskAudit(ClusterAudit): From b4313c64af6117ad7fc27b6fcfdf89dbe9a39039 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:48:02 -0400 Subject: [PATCH 14/25] Refactor: python: Remove an unnecessary loop in PrimitiveAudit._setup. We don't need to loop through the nodes twice. --- python/pacemaker/_cts/audits.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 61cf82e1da5..797f3a4ca26 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -501,13 +501,12 @@ def _setup(self): for node in self._cm.env["nodes"]: if self._cm.expected_status[node] == "up": self._active_nodes.append(node) + + if self._target is None: + self._target = node else: self._inactive_nodes.append(node) - for node in self._cm.env["nodes"]: - if self._target is None and self._cm.expected_status[node] == "up": - self._target = node - if not self._target: # TODO: In Pacemaker 1.0 clusters we'll be able to run crm_resource # with CIB_file=/path/to/cib.xml even when the cluster isn't running From d91d7ae52a6377006319e0635adaac89785bbada Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:53:00 -0400 Subject: [PATCH 15/25] Refactor: python: Unindent in ColocationAudit. --- python/pacemaker/_cts/audits.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 797f3a4ca26..fd6cde1ac90 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -669,12 +669,14 @@ def _crm_location(self, resource): (rc, lines) = self._cm.rsh.call(self._target, f"crm_resource --locate -r {resource} -Q", verbose=1) + if rc != 0: + return [] + hosts = [] - if rc == 0: - for line in lines: - fields = line.split() - hosts.append(fields[0]) + for line in lines: + fields = line.split() + hosts.append(fields[0]) return hosts @@ -693,15 +695,16 @@ def __call__(self): if not source: self.debug(f"Colocation audit ({coloc.id}): {coloc.rsc} not running") - else: - for node in source: - if node not in target: - passed = False - logging.log(f"Colocation audit ({coloc.id}): {coloc.rsc} running " - f"on {node} (not in {target!r})") - else: - self.debug(f"Colocation audit ({coloc.id}): {coloc.rsc} running " - f"on {node} (in {target!r})") + continue + + for node in source: + if node not in target: + passed = False + logging.log(f"Colocation audit ({coloc.id}): {coloc.rsc} running " + f"on {node} (not in {target!r})") + else: + self.debug(f"Colocation audit ({coloc.id}): {coloc.rsc} running " + f"on {node} (in {target!r})") return passed From 1b66161d4ecd9d4759d5589d4994317c25f6e466 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 10:54:32 -0400 Subject: [PATCH 16/25] Refactor: python: Use booleans correctly in audits.py. --- python/pacemaker/_cts/audits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index fd6cde1ac90..f8d664ba074 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -788,7 +788,7 @@ def __call__(self): for partition in ccm_partitions: self.debug(f"\tAuditing CIB consistency for: {partition}") - if self._audit_cib_contents(partition) == 0: + if not self._audit_cib_contents(partition): passed = False return passed @@ -900,7 +900,7 @@ def __call__(self): logging.log(f"\t {partition}") for partition in ccm_partitions: - if self._audit_partition(partition) == 0: + if not self._audit_partition(partition): passed = False return passed From 32f68add2c73da122cb3289a41b9ce20c8551b55 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 11:01:26 -0400 Subject: [PATCH 17/25] Refactor: python: Simplify string manipulation in PartitionAudit. * Nothing uses the _trim2int function. * Move the strip call into _trim_string to simplify callers. * Simplify callers by making everything a single call instead of two. --- python/pacemaker/_cts/audits.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index f8d664ba074..28ef9214f7a 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -907,6 +907,8 @@ def __call__(self): def _trim_string(self, avalue): """Remove the last character from a multi-character string.""" + avalue = avalue.strip() + if not avalue: return None @@ -915,14 +917,6 @@ def _trim_string(self, avalue): return avalue - def _trim2int(self, avalue): - """Remove the last character from a multi-character string and convert the result to an int.""" - trimmed = self._trim_string(avalue) - if trimmed: - return int(trimmed) - - return None - def _audit_partition(self, partition): """Perform the audit of a single partition.""" passed = True @@ -939,13 +933,10 @@ def _audit_partition(self, partition): # checking for in this audit) (_, out) = self._cm.rsh.call(node, self._cm.templates["StatusCmd"] % node, verbose=1) - self._node_state[node] = out[0].strip() + self._node_state[node] = self._trim_string(out[0]) (_, out) = self._cm.rsh.call(node, self._cm.templates["QuorumCmd"], verbose=1) - self._node_quorum[node] = out[0].strip() - - self._node_state[node] = self._trim_string(self._node_state[node]) - self._node_quorum[node] = self._trim_string(self._node_quorum[node]) + self._node_quorum[node] = self._trim_string(out[0]) for node in node_list: if self._cm.expected_status[node] != "up": From 3baa541cd8b7dadfd1a764854a4b7b4e6971018d Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 11:22:03 -0400 Subject: [PATCH 18/25] Refactor: python: Get rid of the dc_allowed_list check. This has been an empty list since a refactoring commit in 2005 (73281cee8cb). --- python/pacemaker/_cts/audits.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 28ef9214f7a..f19c6c7e933 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -921,7 +921,6 @@ def _audit_partition(self, partition): """Perform the audit of a single partition.""" passed = True dc_found = [] - dc_allowed_list = [] node_list = partition.split() self.debug(f"Auditing partition: {partition}") @@ -946,8 +945,7 @@ def _audit_partition(self, partition): dc_found.append(node) if not dc_found: - logging.log(f"DC not found on any of the {len(dc_allowed_list)} allowed " - f"nodes: {dc_allowed_list} (of {node_list})") + logging.log("DC not found on any node") elif len(dc_found) > 1: logging.log(f"{len(dc_found)} DCs ({dc_found}) found in cluster partition: {node_list}") From 34f10102e7d26d482916a9259e23a6822e1df914 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 11:33:42 -0400 Subject: [PATCH 19/25] Refactor: python: Use an f-string in audits.py. --- python/pacemaker/_cts/audits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index f19c6c7e933..5dcd169df5b 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -211,7 +211,7 @@ def __call__(self): passed = True # @TODO Use directory of PCMK_logfile if set on host - dfcmd = "df -BM %s | tail -1 | awk '{print $(NF-1)\" \"$(NF-2)}' | tr -d 'M%%'" % BuildOptions.LOG_DIR + dfcmd = f"df -BM {BuildOptions.LOG_DIR} | tail -1 | awk '{{print $(NF-1)\" \"$(NF-2)}}' | tr -d 'M%%'" self._cm.ns.wait_for_all_nodes(self._cm.env["nodes"]) for node in self._cm.env["nodes"]: From 897bcabeaae88c23b3eceded4798dc1e9c856bb7 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 11:35:49 -0400 Subject: [PATCH 20/25] Refactor: python: Don't log a tuple in DiskAudit.__call__. If the split fails with an exception, it's possible one or both members of the tuple won't exist, in which case the logging message will also raise an exception. I don't think logging the tuple members is useful, so just remove it. --- python/pacemaker/_cts/audits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 5dcd169df5b..50e5cf83b16 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -227,7 +227,7 @@ def __call__(self): used_percent = int(used) remaining_mb = int(remain) except (ValueError, TypeError): - logging.log(f"Warning: df output '{dfout}' from {node} was invalid [{used}, {remain}]") + logging.log(f"Warning: df output '{dfout}' from {node} was invalid") else: if remaining_mb < 10 or used_percent > 95: logging.log(f"CRIT: Out of log disk space on {node} ({used_percent}% / {remaining_mb}MB)") From 321534f57078f77f5ff6d0e8668a86309acd041a Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 11:51:23 -0400 Subject: [PATCH 21/25] Refactor: python: Don't use the re module if we don't have to. --- python/pacemaker/_cts/audits.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 50e5cf83b16..7e728ba2cd9 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -517,9 +517,9 @@ def _setup(self): verbose=1) for line in lines: - if re.search("^Resource", line): + if line.startswith("Resource"): self._resources.append(AuditResource(self._cm, line)) - elif re.search("^Constraint", line): + elif line.startswith("Constraint"): self._constraints.append(AuditConstraint(self._cm, line)) else: logging.log(f"Unknown entry: {line}") @@ -833,11 +833,11 @@ def _audit_cib_contents(self, hostlist): passed = False for line in proc.stdout.splitlines(): - if not re.search("", line): + if "" in line: + self.debug(f"CibDiff[{first}-{node}] Ignoring: {line}") + else: passed = False self.debug(f"CibDiff[{first}-{node}]: {line}") - else: - self.debug(f"CibDiff[{first}-{node}] Ignoring: {line}") self._cleanup_cibs() return passed From 96d359c926c1509efb0e2a1e5bd138cb99fb671a Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 12:20:11 -0400 Subject: [PATCH 22/25] Refactor: python: Use bool() in AuditResource. --- python/pacemaker/_cts/audits.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 7e728ba2cd9..1fe1b0dae93 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -376,17 +376,17 @@ def __init__(self, cm, line): @property def unique(self): """Return True if this resource is unique.""" - return self.flags & 0x20 + return bool(self.flags & 0x20) @property def orphan(self): """Return True if this resource is an orphan.""" - return self.flags & 0x01 + return bool(self.flags & 0x01) @property def managed(self): """Return True if this resource is managed by the cluster.""" - return self.flags & 0x02 + return bool(self.flags & 0x02) class AuditConstraint: From 6c55c4e7d44095400a678d85a6a72038d7219d23 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 12:25:40 -0400 Subject: [PATCH 23/25] Refactor: python: Treat needs_quorum as a boolean. --- python/pacemaker/_cts/audits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 1fe1b0dae93..559149ff7b5 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -366,7 +366,7 @@ def __init__(self, cm, line): self.rclass = fields[6] self.rtype = fields[7] self.host = fields[8] - self.needs_quorum = fields[9] + self.needs_quorum = fields[9] == "1" self.flags = int(fields[10]) self.flags_s = fields[11] @@ -450,7 +450,7 @@ def _audit_resource(self, resource, quorum): if quorum: self.debug(f"Resource {resource.id} active on {active!r}") - elif resource.needs_quorum == 1: + elif resource.needs_quorum: logging.log(f"Resource {resource.id} active without quorum: {active!r}") rc = False From aae649429f7039cb64667fdb8944ad0767b11ce8 Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 12:57:54 -0400 Subject: [PATCH 24/25] Refactor: python: Be more defensive in PartitionAudit._audit_partition. --- python/pacemaker/_cts/audits.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index 559149ff7b5..a1fbb96eda6 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -932,9 +932,19 @@ def _audit_partition(self, partition): # checking for in this audit) (_, out) = self._cm.rsh.call(node, self._cm.templates["StatusCmd"] % node, verbose=1) + if not out: + logging.log(f"ERROR: Could not determine status for node {node}") + passed = False + return passed + self._node_state[node] = self._trim_string(out[0]) (_, out) = self._cm.rsh.call(node, self._cm.templates["QuorumCmd"], verbose=1) + if not out: + logging.log(f"ERROR: Could not determine quorum on node {node}") + passed = False + return passed + self._node_quorum[node] = self._trim_string(out[0]) for node in node_list: From 99ca0382cac65ef4dd77ccf892c7db9afc1190df Mon Sep 17 00:00:00 2001 From: Chris Lumens Date: Fri, 14 Aug 2026 14:37:05 -0400 Subject: [PATCH 25/25] Refactor: python: Actually test for quorum in PartitionAudit. We were querying each node in a partition to see if it had quorum, but not doing anything with that information. So, let's start testing it. --- python/pacemaker/_cts/audits.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/pacemaker/_cts/audits.py b/python/pacemaker/_cts/audits.py index a1fbb96eda6..7d89b5cfb76 100644 --- a/python/pacemaker/_cts/audits.py +++ b/python/pacemaker/_cts/audits.py @@ -903,6 +903,10 @@ def __call__(self): if not self._audit_partition(partition): passed = False + if not any(v == "1" for v in self._node_quorum.values()): + logging.log(f"ERROR: No node has quorum") + passed = False + return passed def _trim_string(self, avalue):